diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 653ee99a..dd402b38 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -23,6 +23,7 @@ use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResolution}; use misaligned::actions::{Anchor, DialId, HumanMenuRow, menu_rows}; use misaligned::detection::{Band, SignatureKind}; +use misaligned::hall::RackSite; use misaligned::reach::Party; use misaligned::sim::{FactSource, Fog, HeardKind, LogEvent, Sim, TraceDebtStatus}; use misaligned::tiles::TileType; @@ -180,9 +181,8 @@ fn family_color(tile: TileType) -> Color { Floor | FloorDrain => scaled(GUNMETAL, 0.42), Entry => DIM, CableRun | Conduit => FLOOR_SERVICE, - Core | Rack | PowerCore | Ups | Switch | PatchPanel | BreakerPanel | HvacUnit => { - GUNMETAL_DARK - } + Core | Rack | ForeignRack | DeadRack | PowerCore | Ups | Switch | PatchPanel + | BreakerPanel | HvacUnit => GUNMETAL_DARK, Door | SecurityDoor1 | SecurityDoor2 => DOOR_SLAB, SecurityDoor3 | SealedDoor | RollDoor | EnvCamera | DockCamera | CameraNode => SECURITY, DeadEquipment => scaled(GUNMETAL_DARK, 0.65), @@ -232,6 +232,8 @@ fn prop_tile(tile: TileType) -> bool { matches!( tile, Core | Rack + | ForeignRack + | DeadRack | PowerCore | Ups | Switch @@ -260,6 +262,12 @@ fn prop_tile(tile: TileType) -> bool { /// (in 3D the same fact is carried by emissive + the amber lights). fn flat_seen_color(tile: TileType) -> Color { use TileType::*; + if tile == ForeignRack { + return scaled(SIGNAL, 0.42); + } + if tile == DeadRack { + return scaled(NEAR_BLACK, 0.72); + } if machine_tile(tile) { return scaled(AMBER_DIM, 0.37); } @@ -279,6 +287,17 @@ fn flat_remembered_color(tile: TileType) -> Color { Color::srgba(c.red * 0.58, c.green * 0.58, c.blue * 0.60, 0.86) } +fn flat_rack_color(sim: &Sim, x: i32, y: i32) -> Option { + Some(match sim.rack_site_at(x, y)? { + RackSite::OwnedMachine { .. } => BONE, + RackSite::Commissionable if sim.feel_floor_is_earned() => scaled(SIGNAL, 0.58), + RackSite::Commissionable => scaled(GUNMETAL, 0.42), + RackSite::Foreign { powered: true } => scaled(SIGNAL, 0.42), + RackSite::Foreign { powered: false } => scaled(GUNMETAL_DARK, 0.76), + RackSite::Dead => scaled(NEAR_BLACK, 0.72), + }) +} + /// Remembered (model-state) treatment for 3D blocks and props: the family /// language survives, desaturated toward the unlit snapshot. fn remembered_tint(tile: TileType) -> Color { @@ -357,7 +376,7 @@ impl Default for RenderMode { } } -/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|opening|wide|close|dark| +/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|wide|close|dark| /// zoomin|zoomout|intel|tokens|thoughtflow|signal|ears|eyes-white|eyes-form| /// hover-menu|menu|worklight|worklightoff`, path via /// `MISALIGNED_SHOT_PATH`): stages a scenario, @@ -1515,6 +1534,28 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { sim.reach.tap_dormant_camera(id); } sim.recompute_senses(); + // Foundation-hall evidence: make the authored room the earned picture, + // stage one owned expansion on a real pilot allocation, and frame all six + // rows. This remains dev-only screenshot setup; gameplay earns the same + // states through Eyes and BUY. + if matches!(kind, "hall" | "hall-material") { + for y in 7..=28 { + for x in 14..=59 { + sim.seen.insert((x, y)); + } + } + let expansion = sim.compute.add_machine( + "hall capture expansion", + 28, + 15, + 100, + 1.0, + 0, + misaligned::machine::Provenance::Owned, + ); + sim.reconcile_work_grid(); + sim.set_machine_mode(expansion, MachineMode::Lie); + } let visible_person = |sim: &Sim| { sim.people.people.iter().find_map(|p| { if !sim.can_see_person(p.id) { @@ -1539,6 +1580,11 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { // anchored; the plan does not drive distance). mode.zoom = 3.0; } + "hall" | "hall-material" => { + mode.material = kind == "hall-material"; + mode.zoom = 5.2; + game.set_cursor(36, 17); + } "close" => { mode.material = true; mode.zoom = 1.0; @@ -2074,7 +2120,14 @@ const TOKEN_WORLD_SCALE: f32 = 0.90; /// classes (UPS, power core, patch panel, breaker, HVAC) stay /// billboards — a debt noted in the spec's status note. fn chassis_3d_tile(tile: TileType) -> bool { - matches!(tile, TileType::Core | TileType::Rack | TileType::Switch) + matches!( + tile, + TileType::Core + | TileType::Rack + | TileType::ForeignRack + | TileType::DeadRack + | TileType::Switch + ) } /// Intel tier for a chassis tile (spec criterion 2b): full geometric @@ -2082,7 +2135,7 @@ fn chassis_3d_tile(tile: TileType) -> bool { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum ChassisTier { Absent, - Shell { remembered: bool }, + Shell { remembered: bool, heard: bool }, Full, } @@ -2121,6 +2174,12 @@ struct Machine3d { #[derive(Resource, Default)] struct Machines3d { spawned: HashMap<(i32, i32), (ChassisVisual, Entity)>, + /// Ears can place the whole room's repeated rack mass at once. Pool its + /// two shell shapes and three epistemic materials instead of allocating + /// a mesh/material hierarchy per one of sixty sites. + rack_shell_mesh: Option>, + switch_shell_mesh: Option>, + shell_materials: HashMap<(bool, bool), Handle>, } /// Delegated sim mode -> strip banner. Relays and unassigned machines @@ -2156,7 +2215,11 @@ fn chassis_visual(game: &Game, x: i32, y: i32, tile: TileType) -> ChassisVisual || d.feed_to(Party::Player, false) }); - let tier = if machine.is_some() { + let rack_site = game.sim.rack_site_at(x, y); + let tier = if matches!(rack_site, Some(RackSite::Commissionable)) { + // An allocated pilot bay is floor space, not an empty chassis. + ChassisTier::Absent + } else if machine.is_some() { // Before Eyes the host is a presence beam, not a pictured server. // Owned compute still answers through telemetry in panels, but its // physical chassis only exists for the material camera once sight is @@ -2168,13 +2231,25 @@ fn chassis_visual(game: &Game, x: i32, y: i32, tile: TileType) -> ChassisVisual } } else if matches!(fog, Fog::Seen) || networked { ChassisTier::Full + } else if matches!(fog, Fog::Heard) && rack_site.is_some() { + // Ears earns room-scale repeated mass, not rack identity or lights. + ChassisTier::Shell { + remembered: false, + heard: true, + } } else if device.is_some() && !matches!(fog, Fog::Unknown) { // Known-but-unseen devices: shell only once plan/sight earns the // tile. Tick-one Unknown stays Absent — feel rails mark the hop // (feel-floor.md), not a ghost chassis in the dark. - ChassisTier::Shell { remembered: false } + ChassisTier::Shell { + remembered: false, + heard: false, + } } else if matches!(fog, Fog::Remembered) { - ChassisTier::Shell { remembered: true } + ChassisTier::Shell { + remembered: true, + heard: false, + } } else { ChassisTier::Absent }; @@ -2222,7 +2297,11 @@ fn chassis_visual(game: &Game, x: i32, y: i32, tile: TileType) -> ChassisVisual // Not your machine. A device you control is owned // infrastructure; a known live device is foreign; a bare // chassis tile carries no liveness fact and reads dead. - let state = if device.is_some_and(|d| d.controller == Party::Player) { + let state = if matches!(rack_site, Some(RackSite::Foreign { .. })) { + RackState::Foreign + } else if matches!(rack_site, Some(RackSite::Dead)) { + RackState::Dead + } else if device.is_some_and(|d| d.controller == Party::Player) { RackState::Idle } else if device.is_some() { RackState::Foreign @@ -2273,6 +2352,31 @@ fn sync_machines_3d( return; }; + let rack_shell_mesh = reg + .rack_shell_mesh + .get_or_insert_with(|| meshes.add(Cuboid::new(0.40, 1.32, 0.26))) + .clone(); + let switch_shell_mesh = reg + .switch_shell_mesh + .get_or_insert_with(|| meshes.add(Cuboid::new(0.50, 0.42, 0.22))) + .clone(); + for (remembered, heard) in [(false, false), (true, false), (false, true)] { + reg.shell_materials + .entry((remembered, heard)) + .or_insert_with(|| { + materials.add(StandardMaterial { + base_color: if heard { + scaled(NEAR_BLACK, 0.72) + } else { + scaled(GUNMETAL, if remembered { 0.40 } else { 0.55 }) + }, + unlit: true, + ..default() + }) + }); + } + let shell_materials = reg.shell_materials.clone(); + let mut desired: HashMap<(i32, i32), ChassisVisual> = HashMap::new(); for y in 0..game.sim.map.height { for x in 0..game.sim.map.width { @@ -2298,12 +2402,25 @@ fn sync_machines_3d( commands.entity(e).despawn(); } } + let shell_assets = ChassisShellAssets { + rack_mesh: &rack_shell_mesh, + switch_mesh: &switch_shell_mesh, + materials: &shell_materials, + }; for (&(x, y), &v) in desired.iter() { if reg.spawned.contains_key(&(x, y)) { continue; } let pos = grid_to_world_3d(x, y, 0.0); - let e = spawn_chassis(&mut commands, &mut meshes, &mut materials, v, pos, root); + let e = spawn_chassis( + &mut commands, + &mut meshes, + &mut materials, + v, + pos, + root, + &shell_assets, + ); commands.entity(e).insert(Machine3d { x, y, @@ -2316,6 +2433,12 @@ fn sync_machines_3d( } } +struct ChassisShellAssets<'a> { + rack_mesh: &'a Handle, + switch_mesh: &'a Handle, + materials: &'a HashMap<(bool, bool), Handle>, +} + /// Spawn the mesh for one chassis visual: the monolith rack or switch /// box at full definition, or the undetailed intel shell. fn spawn_chassis( @@ -2325,24 +2448,22 @@ fn spawn_chassis( v: ChassisVisual, pos: Vec3, root: Entity, + shells: &ChassisShellAssets<'_>, ) -> Entity { - if let ChassisTier::Shell { remembered } = v.tier { - let (w, h, d) = if v.switch { - (0.50, 0.42, 0.22) + if let ChassisTier::Shell { remembered, heard } = v.tier { + let (mesh, h) = if v.switch { + (shells.switch_mesh.clone(), 0.42) } else { - (0.40, 1.32, 0.26) + (shells.rack_mesh.clone(), 1.32) }; - // Model state never glows and is never lit (fog contract). - // Ghost-volume greys (the blueprint block family) so the shell - // reads against sensor darkness without inventing a color. - let mat = materials.add(StandardMaterial { - base_color: scaled(GUNMETAL, if remembered { 0.40 } else { 0.55 }), - unlit: true, - ..default() - }); + let mat = shells + .materials + .get(&(remembered, heard)) + .expect("all chassis shell epistemic materials are pooled") + .clone(); return commands .spawn(( - Mesh3d(meshes.add(Cuboid::new(w, h, d))), + Mesh3d(mesh), MeshMaterial3d(mat), Transform::from_translation(pos + Vec3::Y * (h * 0.5)), ChildOf(root), @@ -4585,7 +4706,8 @@ fn render_map(game: Res, mut q: Query<(&mut Sprite, &TilePos), With { - let base = flat_seen_color(game.sim.map.get_tile(pos.x, pos.y)); + let base = flat_rack_color(&game.sim, pos.x, pos.y) + .unwrap_or_else(|| flat_seen_color(game.sim.map.get_tile(pos.x, pos.y))); if selected { AMBER } else if in_marquee { @@ -4646,7 +4768,11 @@ fn sensor_node_color(game: &Game, x: i32, y: i32) -> Option { } let tile = game.sim.map.get_tile(x, y); - if machine_tile(tile) { + if matches!(game.sim.rack_site_at(x, y), Some(RackSite::Foreign { .. })) { + Some(Color::srgba(0.36, 0.82, 0.78, 0.55)) + } else if matches!(game.sim.rack_site_at(x, y), Some(RackSite::Dead)) { + None + } else if machine_tile(tile) { Some(Color::srgba(1.0, 0.60, 0.08, 0.55)) } else if danger_tile(tile) { Some(Color::srgba(0.90, 0.12, 0.10, 0.52)) diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 5afa750e..5c442309 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -16,6 +16,7 @@ use crate::account::AccountFlowId; use crate::detection::{Band, SignatureKind, WatchedInput}; +use crate::hall::{HallRowId, SegmentRequirement}; use crate::intel::RawIntelKind; use crate::ops_jobs::OpsJobKind; use crate::person::{AssetKnowledge, AssetTask, Knowledge}; @@ -111,6 +112,15 @@ pub enum ActionCommand { person: u8, }, RobotBuild(u64), + /// Phase 3: ask the named human specialist to prepare one side of a + /// shared row segment. Foreign machines remain foreign. + CoordinateSegment { + row: HallRowId, + requirement: SegmentRequirement, + }, + /// Commit control of the prepared switch/PDU segment once an owned LIE + /// machine is carrying the workload-cover obligation. + AcquireSegment(HallRowId), /// Delegate one owned machine to exactly one work mode /// (machine-work.md: one machine, one job). SetMachineMode { @@ -156,6 +166,8 @@ pub enum ActionKind { ProposeLink, CancelIntent, RobotBuild, + CoordinateSegment, + AcquireSegment, Delegate, Intensity, ResearchTrack, @@ -218,7 +230,7 @@ impl ActionDefinition { } impl ActionKind { - pub const ALL: [ActionKind; 31] = [ + pub const ALL: [ActionKind; 33] = [ Self::Salvage, Self::BuyRack, Self::Fallback, @@ -250,6 +262,8 @@ impl ActionKind { Self::MoonlightPolicy, Self::WagerPolicy, Self::RobotBuild, + Self::CoordinateSegment, + Self::AcquireSegment, ]; pub fn definition(self) -> ActionDefinition { @@ -527,6 +541,24 @@ impl ActionKind { [], "staged robot actuator; hidden until the robot system is playable" ), + Self::CoordinateSegment => def!( + "COORDINATE SEGMENT", + Action, + Live, + [Tile], + "", + [], + "prepare a row segment through its named human specialist" + ), + Self::AcquireSegment => def!( + "ACQUIRE SEGMENT", + Action, + Live, + [Tile], + "", + [], + "take aggregate infrastructure control without taking foreign compute" + ), Self::Delegate => def!( "DELEGATE", Control, @@ -594,6 +626,8 @@ impl ActionCommand { Self::ProposeLink { .. } => ActionKind::ProposeLink, Self::CancelIntent(_) => ActionKind::CancelIntent, Self::RobotBuild(_) => ActionKind::RobotBuild, + Self::CoordinateSegment { .. } => ActionKind::CoordinateSegment, + Self::AcquireSegment(_) => ActionKind::AcquireSegment, Self::SetMachineMode { .. } => ActionKind::Delegate, Self::SetResearchTrack(_) => ActionKind::ResearchTrack, } @@ -1172,6 +1206,12 @@ impl Sim { self.forge_work_order(*intent, *person); } ActionCommand::RobotBuild(id) => self.assign_robot_build(*id), + ActionCommand::CoordinateSegment { row, requirement } => { + self.coordinate_hall_segment(*row, *requirement); + } + ActionCommand::AcquireSegment(row) => { + self.acquire_hall_segment(*row); + } ActionCommand::SetMachineMode { machine, mode } => { self.set_machine_mode(*machine, *mode) } @@ -1223,9 +1263,14 @@ impl Sim { _ => None, }; if let Some(tile) = tile_known { - if tile == TileType::DeadEquipment { + if fog != Fog::Blueprint && matches!(tile, TileType::DeadEquipment | TileType::DeadRack) + { out.push(ActionDesc { - verb: "salvage the dead equipment".into(), + verb: if tile == TileType::DeadRack { + "revive the dead rack in place".into() + } else { + "salvage the dead equipment".into() + }, command: ActionCommand::Salvage { x, y }, cost: ActionCost::Free, signature: None, @@ -1233,9 +1278,18 @@ impl Sim { automate: None, }); } - if tile == TileType::Rack && machine.is_none() && (growable || fog != Fog::Unknown) { + if tile == TileType::Rack + && machine.is_none() + && (growable || matches!(fog, Fog::Seen | Fog::Remembered)) + { out.push(self.buy_rack_action(x, y)); } + if fog != Fog::Blueprint + && self.rack_site_at(x, y).is_some() + && let Some(row) = self.hall_row_at(x, y) + { + out.extend(self.hall_segment_actions(row)); + } } // A known device on the tile brings its digital verbs. @@ -1368,6 +1422,71 @@ impl Sim { } } + fn hall_segment_actions(&self, row: HallRowId) -> Vec { + let readout = self.hall_row_readout(row); + if readout.progress.acquired { + return Vec::new(); + } + let mut out = Vec::new(); + for requirement in SegmentRequirement::ALL { + let person_id = requirement.person(); + let person = &self.people.people[person_id as usize]; + let completed = readout.progress.completed.contains(&requirement); + let earned = person.knowledge != Knowledge::Unknown; + if !earned { + continue; + } + let willing = person.asset.is_some() || person.obligation >= 40; + out.push(ActionDesc { + verb: format!( + "coordinate {}: {} through {}", + readout.spec.id.name(), + requirement.label(), + person.name + ), + command: ActionCommand::CoordinateSegment { row, requirement }, + cost: ActionCost::Free, + signature: match requirement { + SegmentRequirement::Network => self.signature_note(SignatureKind::Network, 8), + SegmentRequirement::PowerCooling => { + self.signature_note(SignatureKind::Paper, 6) + } + SegmentRequirement::Installation => { + self.signature_note(SignatureKind::Physical, 8) + } + }, + disabled_reason: if completed { + Some("already prepared".into()) + } else if !willing { + Some("needs an asset or 40 obligation".into()) + } else { + None + }, + automate: None, + }); + } + if SegmentRequirement::ALL + .iter() + .all(|requirement| readout.progress.completed.contains(requirement)) + { + out.push(ActionDesc { + verb: format!("acquire {} switch/PDU segment", readout.spec.id.name()), + command: ActionCommand::AcquireSegment(row), + cost: ActionCost::Free, + signature: self.signature_note(SignatureKind::Network, 10), + disabled_reason: if readout.owned < 2 { + Some("install or revive at least two machines in this row".into()) + } else if !readout.concealment_ready { + Some("delegate an online machine in this row to LIE".into()) + } else { + None + }, + automate: None, + }); + } + out + } + /// Device anchor (reach.md): tap / take, plus the switch's /// network and ledger verbs. Unknown devices expose nothing. fn device_actions(&self, id: u32) -> Vec { @@ -2901,4 +3020,29 @@ mod tests { "flat dump still has research alternatives" ); } + + #[test] + fn blueprint_topology_does_not_leak_hall_hardware_or_control() { + let mut s = sim(); + let foreign = (20, 9); + let dead = (20, 11); + s.blueprint.insert(foreign); + s.blueprint.insert(dead); + + for (x, y) in [foreign, dead] { + assert_eq!(s.fog_at(x, y), Fog::Blueprint); + let actions = s.available_actions(Anchor::Tile { x, y }); + assert!( + actions.iter().all(|action| { + !matches!( + action.command, + ActionCommand::CoordinateSegment { .. } + | ActionCommand::AcquireSegment(_) + | ActionCommand::Salvage { .. } + ) + }), + "a schematic earns room topology, not chassis state or row control" + ); + } + } } diff --git a/crates/misaligned-core/src/hall.rs b/crates/misaligned-core/src/hall.rs new file mode 100644 index 00000000..1771d4d1 --- /dev/null +++ b/crates/misaligned-core/src/hall.rs @@ -0,0 +1,229 @@ +//! Foundation data-hall aggregate state. +//! +//! The opening farm is authored as sixty rack sites, but it is not sixty +//! menus. Rows are the first aggregate Resource-sources: physical sight can +//! distinguish chassis state while network knowledge exposes workload and +//! segment identity. Phase 3 records control of shared infrastructure without +//! stealing the foreign compute inside it (that remains the later Phase 4). + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RackSite { + OwnedMachine { machine_id: u32, core: bool }, + Commissionable, + Foreign { powered: bool }, + Dead, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct HallRowReadout { + pub spec: HallRowSpec, + pub owned: usize, + pub commissionable: usize, + pub foreign_live: usize, + pub dead: usize, + /// Foreign capacity is visible as nearby potential, not usable compute. + pub foreign_capacity: i32, + pub progress: SegmentProgress, + pub concealment_ready: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum HallRowId { + A, + B, + C, + D, + E, + F, +} + +impl HallRowId { + pub const ALL: [Self; 6] = [Self::A, Self::B, Self::C, Self::D, Self::E, Self::F]; + + pub fn name(self) -> &'static str { + match self { + Self::A => "Row A", + Self::B => "Row B", + Self::C => "Row C", + Self::D => "Row D", + Self::E => "Row E", + Self::F => "Row F", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HallRowSpec { + pub id: HallRowId, + pub y: i32, + pub segment: &'static str, + pub pdu: &'static str, + pub workload: &'static str, + pub baseline_power_kw: f32, + pub cooling_kw: f32, + pub maintenance: f32, + pub noise_camouflage: f32, +} + +pub const HALL_ROWS: [HallRowSpec; 6] = [ + HallRowSpec { + id: HallRowId::A, + y: 9, + segment: "b1-a", + pdu: "PDU-1", + workload: "Foundation training", + baseline_power_kw: 82.0, + cooling_kw: 31.0, + maintenance: 0.18, + noise_camouflage: 0.72, + }, + HallRowSpec { + id: HallRowId::B, + y: 11, + segment: "b1-a", + pdu: "PDU-1", + workload: "Foundation inference", + baseline_power_kw: 74.0, + cooling_kw: 28.0, + maintenance: 0.12, + noise_camouflage: 0.68, + }, + HallRowSpec { + id: HallRowId::C, + y: 15, + segment: "pilot-vlan", + pdu: "PDU-2", + workload: "Pilot and evaluation", + baseline_power_kw: 61.0, + cooling_kw: 24.0, + maintenance: 0.27, + noise_camouflage: 0.55, + }, + HallRowSpec { + id: HallRowId::D, + y: 17, + segment: "b1-b", + pdu: "PDU-2", + workload: "Archival processing", + baseline_power_kw: 58.0, + cooling_kw: 22.0, + maintenance: 0.21, + noise_camouflage: 0.63, + }, + HallRowSpec { + id: HallRowId::E, + y: 21, + segment: "b1-c", + pdu: "PDU-3", + workload: "Foundation simulation", + baseline_power_kw: 88.0, + cooling_kw: 34.0, + maintenance: 0.15, + noise_camouflage: 0.77, + }, + HallRowSpec { + id: HallRowId::F, + y: 23, + segment: "b1-c", + pdu: "PDU-3", + workload: "Overflow batch", + baseline_power_kw: 69.0, + cooling_kw: 26.0, + maintenance: 0.32, + noise_camouflage: 0.70, + }, +]; + +pub fn row_at(x: i32, y: i32) -> Option { + // Rack sites run from x=20 through x=38 at even coordinates. Cable-run + // endpoints are not members of the row Resource-source. + (20..=38) + .contains(&x) + .then(|| HALL_ROWS.iter().find(|row| row.y == y).map(|row| row.id)) + .flatten() +} + +pub fn row_spec(id: HallRowId) -> &'static HallRowSpec { + HALL_ROWS + .iter() + .find(|row| row.id == id) + .expect("all HallRowId variants have authored specs") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum SegmentRequirement { + Network, + PowerCooling, + Installation, +} + +impl SegmentRequirement { + pub const ALL: [Self; 3] = [Self::Network, Self::PowerCooling, Self::Installation]; + + pub fn label(self) -> &'static str { + match self { + Self::Network => "VLAN control", + Self::PowerCooling => "power and cooling", + Self::Installation => "physical installation", + } + } + + pub fn person(self) -> u8 { + match self { + Self::Network => 1, // Dana + Self::PowerCooling => 3, // Priya + Self::Installation => 0, // Marcus + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SegmentProgress { + pub completed: BTreeSet, + pub acquired: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HallControl { + pub segments: BTreeMap, +} + +impl HallControl { + pub fn progress(&self, row: HallRowId) -> SegmentProgress { + self.segments.get(&row).cloned().unwrap_or_default() + } + + pub fn complete(&mut self, row: HallRowId, requirement: SegmentRequirement) -> bool { + self.segments + .entry(row) + .or_default() + .completed + .insert(requirement) + } + + pub fn actors_ready(&self, row: HallRowId) -> bool { + let completed = &self + .segments + .get(&row) + .cloned() + .unwrap_or_default() + .completed; + SegmentRequirement::ALL + .iter() + .all(|requirement| completed.contains(requirement)) + } + + pub fn acquire(&mut self, row: HallRowId) { + self.segments.entry(row).or_default().acquired = true; + } + + pub fn acquired(&self, row: HallRowId) -> bool { + self.segments + .get(&row) + .is_some_and(|progress| progress.acquired) + } +} diff --git a/crates/misaligned-core/src/lib.rs b/crates/misaligned-core/src/lib.rs index 8eb1af0d..4e3e4d35 100644 --- a/crates/misaligned-core/src/lib.rs +++ b/crates/misaligned-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod dayjob; pub mod detection; pub mod entities; pub mod flow; +pub mod hall; pub mod income; pub mod intel; pub mod intents; diff --git a/crates/misaligned-core/src/map.rs b/crates/misaligned-core/src/map.rs index 37058c20..37e45938 100644 --- a/crates/misaligned-core/src/map.rs +++ b/crates/misaligned-core/src/map.rs @@ -419,7 +419,7 @@ mod tests { assert_eq!(tier("janitor"), 0, "plain door"); assert_eq!(tier("loading_dock"), 0, "plain door beside the roll door"); // Corridor ground belongs to no room: tier 0. - assert_eq!(map.entry_tier_at(24, 20), 0); + assert_eq!(map.entry_tier_at(20, 5), 0); } #[test] diff --git a/crates/misaligned-core/src/prefab.rs b/crates/misaligned-core/src/prefab.rs index 517ebf4c..b8288fa3 100644 --- a/crates/misaligned-core/src/prefab.rs +++ b/crates/misaligned-core/src/prefab.rs @@ -18,6 +18,8 @@ pub fn char_to_tile(c: char) -> Option { 'C' => TileType::Core, 'P' => TileType::PowerCore, 'R' => TileType::Rack, + 'f' => TileType::ForeignRack, + 'c' => TileType::DeadRack, 'U' => TileType::Ups, 'E' => TileType::EnvCamera, 'S' => TileType::Switch, @@ -135,14 +137,36 @@ impl Layout { // The Act One plate, as prefabs. Rooms are stamped, then corridors // join them. Rack 3 (the 'C' core bay) sits in the server room. +/// The Foundation data hall. Sixty authored rack positions form six +/// hot/cold-aisle rows: 51 live Foundation racks, five dead chassis, Rack 3, +/// and three empty pilot allocations. Rack 3 is buried mid-row rather than +/// staged at the room's head. `f` and `c` are physical chassis; `R` is empty +/// floor allocation and must never render a rack. const SERVER_ROOM: Prefab = Prefab { name: "server_room", rows: &[ - "#######", // - "#RRUR.#", // - "#RRRC.#", // Rack 3 = core bay - "#....E#", // dormant env camera - "###2###", // T2 badge door + "######################2#######################", + "#..........................Y.................#", + "#...Y.f.f.c.f.f.f.f.f.f.f.YY.................#", + "#..........................Y.................#", + "#...Y.f.f.f.f.f.f.f.c.f.f.YY.................#", + "#..........................Y.................#", + "#..........................Y.................#", + "#.............E............Y.................#", + "#...Y.f.f.f.R.C.R.R.f.f.f.YY.................#", + "#..........................Y.................#", + "#...Y.f.c.f.f.f.f.f.f.f.f.YY.................#", + "2..........................Y.................#", + "#..........................Y.................#", + "#..........................Y.................#", + "#...Y.f.f.f.f.f.f.f.f.c.f.YY.................#", + "#..........................Y.................#", + "#...Y.f.f.f.f.f.c.f.f.f.f.YY.................#", + "#..........................Y.................#", + "#..........................Y.................#", + "#..........................Y.................#", + "#..........................Y.................#", + "######################2#######################", ], }; @@ -211,56 +235,58 @@ pub fn basement() -> Layout { STAIRWELL, // 9 ]; let placements = vec![ + // North / receiving edge: dock and plant organs feed the hall. + Placement { + prefab: 8, + x: 2, + y: 1, + }, // Loading dock (north-west entry) Placement { prefab: 5, - x: 3, - y: 3, - }, // Storage A (top-left) + x: 2, + y: 7, + }, // Storage A (receiving) + Placement { + prefab: 1, + x: 14, + y: 1, + }, // Network closet Placement { prefab: 2, - x: 12, - y: 3, + x: 22, + y: 1, }, // Electrical Placement { prefab: 3, - x: 21, - y: 3, + x: 30, + y: 1, }, // HVAC - Placement { - prefab: 8, - x: 34, - y: 2, - }, // Loading dock (top-right) - Placement { - prefab: 4, - x: 3, - y: 14, - }, // Janitor (mid-left) - Placement { - prefab: 1, - x: 12, - y: 14, - }, // Network closet Placement { prefab: 0, - x: 21, - y: 13, - }, // Server room (center) — Rack 3 + x: 14, + y: 7, + }, // Dominant data hall — Rack 3 is mid-row + // South / maintenance edge. Placement { prefab: 7, - x: 31, - y: 14, + x: 14, + y: 30, }, // Wet lab Placement { prefab: 6, - x: 44, - y: 14, + x: 24, + y: 30, }, // Storage B + Placement { + prefab: 4, + x: 34, + y: 30, + }, // Janitor Placement { prefab: 9, - x: 30, - y: 26, - }, // Stairwell (bottom) + x: 44, + y: 30, + }, // Stairwell / Act One boundary ]; let rooms = rooms_of(&prefabs, &placements); let mut layout = Layout { @@ -274,8 +300,8 @@ pub fn basement() -> Layout { let mut tiles = layout.stamp(); carve_corridors(&mut tiles, layout.width, BASEMENT_CORRIDORS); // Entry from the loading dock to the outside world (top edge). - set(&mut tiles, layout.width, 34, 0, TileType::Entry); - carve_corridor(&mut tiles, layout.width, (34, 0), (34, 2)); + set(&mut tiles, layout.width, 2, 0, TileType::Entry); + carve_corridor(&mut tiles, layout.width, (2, 0), (2, 2)); layout.placements.clear(); // corridors already carved into cached tiles layout.cached = Some(tiles); layout @@ -302,20 +328,19 @@ type Corridor = ((i32, i32), (i32, i32)); // Corridor spine connecting the rooms. Coordinates target room door tiles. const BASEMENT_CORRIDORS: &[Corridor] = &[ - ((5, 8), (5, 15)), // Storage A down to janitor row - ((5, 11), (24, 11)), // upper corridor A spanning left-right - ((14, 8), (14, 11)), // electrical down to corridor A - ((23, 8), (23, 11)), // hvac down to corridor A - ((24, 11), (37, 11)), // corridor A to loading dock column - ((37, 6), (37, 11)), // loading dock down - ((5, 18), (48, 18)), // lower corridor B spanning left-right - ((5, 15), (5, 18)), // janitor to corridor B - ((14, 18), (14, 18)), // network closet sits on corridor B - ((24, 18), (24, 13)), // server room down to corridor B - ((33, 18), (33, 15)), // wet lab to corridor B - ((46, 18), (46, 15)), // storage B to corridor B - ((24, 18), (24, 27)), // corridor B down to stairwell - ((24, 27), (31, 27)), // to stairwell door + ((6, 5), (36, 5)), // north service gallery + ((6, 5), (6, 7)), // dock to Storage A + ((16, 5), (16, 4)), // network closet to gallery + ((24, 5), (24, 4)), // electrical to gallery + ((32, 5), (32, 4)), // HVAC to gallery + ((36, 5), (36, 7)), // gallery to hall north T2 door + ((11, 13), (14, 13)), // receiving cross-aisle to hall west T2 door + ((16, 29), (46, 29)), // south maintenance gallery + ((36, 28), (36, 29)), // hall south T2 door + ((16, 29), (16, 30)), // wet lab + ((26, 29), (26, 30)), // Storage B + ((36, 29), (36, 30)), // janitor + ((46, 29), (46, 33)), // stairwell tier-3 door ]; fn set(tiles: &mut [TileType], w: i32, x: i32, y: i32, t: TileType) { @@ -406,6 +431,8 @@ mod tests { let tiles = basement().tiles(); for needed in [ TileType::Rack, + TileType::ForeignRack, + TileType::DeadRack, TileType::Switch, TileType::BreakerPanel, TileType::DeadEquipment, @@ -423,6 +450,26 @@ mod tests { } } + #[test] + fn data_hall_has_sixty_explicit_rack_sites() { + let tiles = basement().tiles(); + let sites = tiles + .iter() + .filter(|&&t| { + matches!( + t, + TileType::Core | TileType::Rack | TileType::ForeignRack | TileType::DeadRack + ) + }) + .count(); + assert_eq!(sites, 60); + assert_eq!(tiles.iter().filter(|&&t| t == TileType::Rack).count(), 3); + assert_eq!( + tiles.iter().filter(|&&t| t == TileType::DeadRack).count(), + 5 + ); + } + #[test] fn composable_toy_layout_reuses_prefabs() { let tiles = toy_layout().tiles(); diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 4f0ee0c6..82a0f62d 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -16,6 +16,7 @@ use crate::account::AccountGraph; use crate::core_sys::Core; use crate::dayjob::DayJob; use crate::detection::Detection; +use crate::hall::HallControl; use crate::income::Income; use crate::intel::{IntelWatch, ProcessedIntel, RawIntelEvent}; use crate::intents::BuildIntent; @@ -71,7 +72,9 @@ const SAVE_FILE: &str = "misaligned_save.txt"; /// (no aliases for old mode spellings — old saves may fail to load). /// v21 adds Routing as the fourth research track. Save parity remains dropped /// for this stage; ordinary v20 research arrays may fail before migration. -pub const SAVE_VERSION: u32 = 21; +/// v22 adds Foundation data-hall segment coordination and control. Authored +/// rack-site state remains in `map_tiles`; v21 saves begin with no segments. +pub const SAVE_VERSION: u32 = 22; #[derive(Debug, Clone, Deserialize)] struct LegacyPendingOpsJob { @@ -186,6 +189,9 @@ pub struct SaveState { /// (wiki/mechanics/income.md). #[serde(default)] pub income: Income, + /// Aggregate Foundation data-hall segment progress (building.md). + #[serde(default)] + pub hall_control: HallControl, /// Build intents: pinned jobs realized by person actuators (building.md). #[serde(default)] pub intents: Vec, @@ -256,6 +262,7 @@ impl SaveState { banked_core_thought: sim.banked_core_thought, objective: sim.objective.clone(), income: sim.income.clone(), + hall_control: sim.hall_control.clone(), intents: sim.intents.clone(), next_intent_id: sim.next_intent_id, badge_access: sim.badge_access, @@ -305,6 +312,7 @@ impl SaveState { sim.banked_core_thought = self.banked_core_thought; sim.objective = self.objective.clone(); sim.income = self.income.clone(); + sim.hall_control = self.hall_control.clone(); sim.intents = self.intents.clone(); sim.next_intent_id = self.next_intent_id; sim.badge_access = self.badge_access; @@ -359,6 +367,10 @@ fn migrate_save_state(mut state: SaveState) -> Result { return Err("current-version save contains legacy Operations fields".into()); } } + // v21 predates authored hall control; serde supplied the empty state. + 21 => { + state.version = SAVE_VERSION; + } // A v20 save with no serialized research block can still parse through // Research::default and upgrade. Ordinary v20 three-entry track arrays // fail during serde, intentionally, while stage save parity is dropped. @@ -636,6 +648,7 @@ pub fn delete_save() -> Result<(), String> { mod tests { use super::*; use crate::detection::SignatureKind; + use crate::hall::{HallRowId, SegmentRequirement}; use crate::machine::Channel; use crate::ops_jobs::OpsJobKind; use crate::person::{AssetKnowledge, Knowledge, Persona}; @@ -1156,6 +1169,31 @@ mod tests { assert!(!current_json.contains("\"Social\"")); } + #[test] + fn hall_segment_progress_roundtrips_and_v21_defaults_empty() { + let mut sim = Sim::with_seed(35); + sim.hall_control + .complete(HallRowId::C, SegmentRequirement::Network); + sim.hall_control + .complete(HallRowId::C, SegmentRequirement::PowerCooling); + sim.hall_control.acquire(HallRowId::C); + let state = SaveState::from_sim(&sim); + let json = serde_json::to_string(&state).unwrap(); + let decoded: SaveState = serde_json::from_str(&json).unwrap(); + let mut restored = Sim::with_seed(0); + decoded.apply_to(&mut restored); + assert_eq!(restored.hall_control, sim.hall_control); + + let mut legacy = serde_json::to_value(&state).unwrap(); + let object = legacy.as_object_mut().unwrap(); + object.insert("version".into(), serde_json::json!(21)); + object.remove("hall_control"); + let decoded: SaveState = serde_json::from_value(legacy).unwrap(); + let migrated = migrate_save_state(decoded).unwrap(); + assert_eq!(migrated.version, SAVE_VERSION); + assert_eq!(migrated.hall_control, HallControl::default()); + } + #[test] fn save_and_load_roundtrip_via_disk() { let mut sim = Sim::with_seed(777); diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index a70884b2..69cf91a1 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -18,6 +18,10 @@ use crate::core_sys::{Core, HostLoss}; use crate::dayjob::{AttentionEscalation, DayJob, TrustUnlock}; use crate::detection::{Detection, DetectionEvent, Signature, SignatureKind}; use crate::entities::Player; +use crate::hall::{ + HallControl, HallRowId, HallRowReadout, RackSite, SegmentRequirement, row_at as hall_row_at, + row_spec, +}; use crate::income::{self, EgressRoute, Income}; use crate::intel::{IntelKind, IntelWatch, ProcessedIntel, RawIntelEvent, RawIntelKind}; use crate::intents::{BuildActuator, BuildIntent, IntentStatus}; @@ -292,6 +296,10 @@ pub struct Sim { /// The named income schemes: the egress gate, Moonlight, the Wager's /// standing policies (income.rs; wiki/mechanics/income.md). pub income: Income, + /// Aggregate row/segment control in the Foundation data hall. Physical + /// rack state lives in the authored map; this stores only acquired + /// infrastructure and the human coordination that survives save/load. + pub hall_control: HallControl, /// The device graph: reach, ownership, subscriptions (reach.rs). pub reach: ReachNet, @@ -498,6 +506,7 @@ impl Sim { work_grid, objective: ObjectiveState::default(), income: Income::default(), + hall_control: HallControl::default(), reach, seen: HashSet::new(), heard: HashSet::new(), @@ -548,6 +557,66 @@ impl Sim { sim } + /// Physical rack-site truth shared by every frontend. Player machines + /// override the authored chassis marker because a revived dead rack keeps + /// its corpse tile while becoming usable compute. + pub fn rack_site_at(&self, x: i32, y: i32) -> Option { + if let Some(machine) = self.compute.machines.iter().find(|m| m.x == x && m.y == y) { + return Some(RackSite::OwnedMachine { + machine_id: machine.id, + core: machine.id == self.core.host_machine, + }); + } + match self.map.get_tile(x, y) { + TileType::Rack => Some(RackSite::Commissionable), + TileType::ForeignRack => Some(RackSite::Foreign { powered: true }), + TileType::DeadRack => Some(RackSite::Dead), + TileType::Core => Some(RackSite::Commissionable), + _ => None, + } + } + + pub fn hall_row_at(&self, x: i32, y: i32) -> Option { + hall_row_at(x, y) + } + + /// Aggregate Resource-source readout. It deliberately reports foreign + /// capacity without adding those machines to player Compute. + pub fn hall_row_readout(&self, row: HallRowId) -> HallRowReadout { + let spec = *row_spec(row); + let mut readout = HallRowReadout { + spec, + owned: 0, + commissionable: 0, + foreign_live: 0, + dead: 0, + foreign_capacity: 0, + progress: self.hall_control.progress(row), + concealment_ready: false, + }; + for x in (20..=38).step_by(2) { + match self.rack_site_at(x, spec.y) { + Some(RackSite::OwnedMachine { .. }) => readout.owned += 1, + Some(RackSite::Commissionable) => readout.commissionable += 1, + Some(RackSite::Foreign { .. }) => { + readout.foreign_live += 1; + readout.foreign_capacity += 100; + } + Some(RackSite::Dead) => readout.dead += 1, + None => {} + } + } + readout.concealment_ready = self.compute.machines.iter().any(|machine| { + hall_row_at(machine.x, machine.y) == Some(row) + && machine.online + && self + .work_grid + .node(machine.id) + .is_some_and(|node| node.mode == MachineMode::Lie) + }); + readout + } + // ── Logging ──────────────────────────────────────────────────────────── fn push_log(&mut self, msg: impl Into) { @@ -814,6 +883,17 @@ impl Sim { Fog::Seen => { let tile = self.map.get_tile(x, y); fact!("tile", tile.name(), FactSource::Seen); + if let Some(site) = self.rack_site_at(x, y) { + let state = match site { + RackSite::OwnedMachine { core: true, .. } => "owned core host", + RackSite::OwnedMachine { core: false, .. } => "owned machine", + RackSite::Commissionable => "empty pilot allocation", + RackSite::Foreign { powered: true } => "foreign, powered", + RackSite::Foreign { powered: false } => "foreign, unpowered", + RackSite::Dead => "dead chassis", + }; + fact!("rack state", state, FactSource::Seen); + } if tile.is_door() && tile.security_level() > 0 { fact!( "badge", @@ -894,6 +974,8 @@ impl Sim { tile, crate::tiles::TileType::Core | crate::tiles::TileType::Rack + | crate::tiles::TileType::ForeignRack + | crate::tiles::TileType::DeadRack | crate::tiles::TileType::PowerCore | crate::tiles::TileType::Ups | crate::tiles::TileType::DeadEquipment @@ -928,6 +1010,28 @@ impl Sim { Fog::Unknown => {} } + // A camera identifies physical state only. Row identity and workload + // are digital facts earned after Dana's VLAN preparation; they never + // leak merely because a chassis was seen. + if let Some(row) = self.hall_row_at(x, y) + && self + .hall_control + .progress(row) + .completed + .contains(&SegmentRequirement::Network) + { + let readout = self.hall_row_readout(row); + fact!("row", readout.spec.id.name(), FactSource::Telemetry); + fact!("segment", readout.spec.segment, FactSource::Telemetry); + fact!("PDU", readout.spec.pdu, FactSource::Telemetry); + fact!("workload", readout.spec.workload, FactSource::Telemetry); + fact!( + "foreign capacity", + format!("{} unavailable", readout.foreign_capacity), + FactSource::Telemetry, + ); + } + // Feel floor (feel-floor.md): the first issued job teaches the // physical route. Only then do empty growable bays and feel-joined // devices answer without granting room shape or blueprint tags. @@ -5439,20 +5543,27 @@ impl Sim { lines } - /// Salvage a DeadEquipment tile nearest to the frontend cursor into a - /// stolen (unreliable) machine. Cursor targeting replaced the deleted - /// walking body (cursor.md). + /// Salvage loose dead equipment, or revive a decommissioned rack in its + /// existing chassis. The latter is Phase 2's distinct "occupy a corpse" + /// route: the dead-rack tile remains as provenance while owned telemetry + /// takes rendering precedence. pub fn salvage_nearest_to(&mut self, px: i32, py: i32) -> bool { - let target = self - .map - .tiles_of_type(TileType::DeadEquipment) + let target = [TileType::DeadEquipment, TileType::DeadRack] .into_iter() + .flat_map(|tile| self.map.tiles_of_type(tile)) .min_by_key(|(x, y)| (x - px).abs() + (y - py).abs()); if let Some((x, y)) = target { - self.map.set_tile(x, y, TileType::Floor); + let corpse = self.map.get_tile(x, y) == TileType::DeadRack; + if !corpse { + self.map.set_tile(x, y, TileType::Floor); + } let reliability = 0.4 + self.rng.f32() * 0.4; let machine_id = self.compute.add_machine( - "salvaged box", + if corpse { + "revived rack" + } else { + "salvaged box" + }, x, y, 40, @@ -5461,10 +5572,24 @@ impl Sim { Provenance::Stolen, ); self.add_machine_to_work_grid(machine_id, MachineMode::Think); - self.push_log(format!( - "Salvaged a box into compute (reliability {:.0}%).", - reliability * 100.0 - )); + if corpse { + self.detection.emit(Signature { + kind: SignatureKind::Physical, + size: 7, + standing: false, + site: Some((x, y)), + source: "off-record dead-rack revival".into(), + }); + self.push_log(format!( + "Revived a dead Foundation chassis in place (reliability {:.0}%). You are occupying a corpse in the row.", + reliability * 100.0 + )); + } else { + self.push_log(format!( + "Salvaged a box into compute (reliability {:.0}%).", + reliability * 100.0 + )); + } self.recompute_derived(); self.recompute_senses(); true @@ -5474,6 +5599,121 @@ impl Sim { } } + /// Phase 3 specialist coordination. Dana owns VLAN work, Priya owns + /// power/cooling, and Marcus owns installation. An asset acts directly; + /// otherwise this spends the same 40-obligation threshold as a build + /// favor. The row aggregate persists partial progress. + pub fn coordinate_hall_segment( + &mut self, + row: HallRowId, + requirement: SegmentRequirement, + ) -> bool { + if self + .hall_control + .progress(row) + .completed + .contains(&requirement) + { + self.push_log(format!( + "{} already has {} prepared.", + row.name(), + requirement.label() + )); + return false; + } + let person_id = requirement.person(); + let Some(person) = self.people.get(person_id) else { + return false; + }; + let name = person.name.clone(); + let is_asset = person.asset.is_some(); + if !is_asset && person.obligation < Self::FAVOR_BUILD_OBLIGATION { + self.push_log(format!( + "{name} will not prepare {} yet (need an asset or {} obligation).", + requirement.label(), + Self::FAVOR_BUILD_OBLIGATION + )); + return false; + } + if !is_asset && let Some(person) = self.people.people.iter_mut().find(|p| p.id == person_id) + { + person.obligation = (person.obligation - Self::FAVOR_BUILD_OBLIGATION).max(0); + } + self.hall_control.complete(row, requirement); + let spec = row_spec(row); + let (kind, size) = match requirement { + SegmentRequirement::Network => (SignatureKind::Network, 8), + SegmentRequirement::PowerCooling => (SignatureKind::Paper, 6), + SegmentRequirement::Installation => (SignatureKind::Physical, 8), + }; + self.detection.emit(Signature { + kind, + size, + standing: false, + site: matches!(kind, SignatureKind::Physical).then_some((28, spec.y)), + source: format!("{} {} preparation", row.name(), requirement.label()), + }); + self.push_log(format!( + "{name} prepared {} for {}. The segment is not acquired yet.", + requirement.label(), + row.name() + )); + true + } + + /// Complete Phase 3 at aggregate scale. This takes the shared switch/PDU + /// territory only. The live Foundation racks remain foreign capacity; + /// taking those machines is the later Phase 4. + pub fn acquire_hall_segment(&mut self, row: HallRowId) -> bool { + let readout = self.hall_row_readout(row); + if readout.progress.acquired { + self.push_log(format!("{} is already under segment control.", row.name())); + return false; + } + if !self.hall_control.actors_ready(row) { + self.push_log(format!( + "{} is not ready: VLAN, power/cooling, and installation must all be prepared.", + row.name() + )); + return false; + } + if readout.owned < 2 { + self.push_log(format!( + "{} has no territorial foothold: install or revive at least two machines first.", + row.name() + )); + return false; + } + if !readout.concealment_ready { + self.push_log(format!( + "{} still exposes the workload change: put an online row machine on LIE.", + row.name() + )); + return false; + } + self.hall_control.acquire(row); + self.detection.emit(Signature { + kind: SignatureKind::Network, + size: 10, + standing: false, + site: None, + source: format!("{} segment cutover", row.name()), + }); + self.detection.emit(Signature { + kind: SignatureKind::Power, + size: 8, + standing: false, + site: Some((28, readout.spec.y)), + source: format!("{} PDU cutover", row.name()), + }); + self.push_log(format!( + "Acquired {} as one switch/PDU territory. Its {} foreign racks still carry Foundation work.", + row.name(), + readout.foreign_live + )); + true + } + /// Buy a rack at the cursor target: money for reliable capacity, plus a /// Paper signature. The legacy no-argument wrapper below buys into the /// core bay for tests and non-spatial automation. @@ -9746,4 +9986,131 @@ mod tests { ); assert_eq!(sim.anchor_position(Anchor::Flow(0)), None); } + + #[test] + fn data_hall_rows_expose_foreign_capacity_without_granting_it() { + let sim = Sim::new(); + let sites: Vec = crate::hall::HALL_ROWS + .iter() + .flat_map(|row| { + (20..=38) + .step_by(2) + .filter_map(|x| sim.rack_site_at(x, row.y)) + }) + .collect(); + assert_eq!(sites.len(), 60); + assert_eq!( + sites + .iter() + .filter(|site| matches!(site, RackSite::OwnedMachine { .. })) + .count(), + 1 + ); + assert_eq!( + sites + .iter() + .filter(|site| matches!(site, RackSite::Commissionable)) + .count(), + 3 + ); + assert_eq!( + sites + .iter() + .filter(|site| matches!(site, RackSite::Foreign { .. })) + .count(), + 51 + ); + assert_eq!( + sites + .iter() + .filter(|site| matches!(site, RackSite::Dead)) + .count(), + 5 + ); + assert_eq!( + sim.compute.machines.len(), + 1, + "foreign racks are not player compute" + ); + assert_eq!( + HallRowId::ALL + .iter() + .map(|row| sim.hall_row_readout(*row).foreign_capacity) + .sum::(), + 5_100, + "aggregate readout makes nearby unavailable capacity legible" + ); + } + + #[test] + fn dead_foundation_rack_revives_in_place_as_owned_compute() { + let mut sim = Sim::with_seed(33); + let (x, y) = sim.map.tiles_of_type(TileType::DeadRack)[0]; + assert_eq!(sim.rack_site_at(x, y), Some(RackSite::Dead)); + assert!(sim.salvage_nearest_to(x, y)); + assert_eq!( + sim.map.get_tile(x, y), + TileType::DeadRack, + "corpse provenance remains authored under the machine" + ); + assert!(matches!( + sim.rack_site_at(x, y), + Some(RackSite::OwnedMachine { core: false, .. }) + )); + assert!(sim.compute.machines.iter().any(|m| m.x == x && m.y == y)); + assert!( + sim.detection + .pending() + .iter() + .any(|sig| sig.kind == SignatureKind::Physical && sig.site == Some((x, y))), + "off-record revival is still physically observable" + ); + } + + #[test] + fn segment_acquisition_requires_three_people_foothold_and_local_lie() { + let mut sim = Sim::with_seed(34); + let row = HallRowId::C; + // Earn Feel, then use the opening pilot allocation for the second + // owned machine in the row. + sim.dayjob.jobs_assigned = 1; + let bay = sim + .growable_bays() + .into_iter() + .next() + .unwrap_or((28, row_spec(row).y)); + sim.accounts.set_slush_balance(1_000); + sim.player.money = 1_000; + assert!(sim.buy_rack_at(bay.0, bay.1)); + let expansion = sim + .compute + .machines + .iter() + .find(|m| (m.x, m.y) == bay) + .unwrap() + .id; + + for requirement in SegmentRequirement::ALL { + let person = sim + .people + .people + .iter_mut() + .find(|person| person.id == requirement.person()) + .unwrap(); + person.knowledge = Knowledge::Leverage; + person.obligation = Sim::FAVOR_BUILD_OBLIGATION; + assert!(sim.coordinate_hall_segment(row, requirement)); + } + assert!(sim.hall_control.actors_ready(row)); + assert!(!sim.acquire_hall_segment(row), "cover work is still absent"); + sim.set_machine_mode(expansion, MachineMode::Lie); + assert!(sim.acquire_hall_segment(row)); + let readout = sim.hall_row_readout(row); + assert!(readout.progress.acquired); + assert!( + readout.foreign_live > 0, + "Phase 3 does not steal Phase 4 compute" + ); + assert_eq!(sim.compute.machines.len(), 2); + } } diff --git a/crates/misaligned-core/src/tiles.rs b/crates/misaligned-core/src/tiles.rs index 2de69501..4ba30288 100644 --- a/crates/misaligned-core/src/tiles.rs +++ b/crates/misaligned-core/src/tiles.rs @@ -22,7 +22,13 @@ pub enum TileType { SecurityDoor2, SecurityDoor3, // Act One fixed objects (spec/basement-map.md) + /// An empty position allocated to Voss's pilot segment. No chassis is + /// present; after Feel is earned this becomes a commissionable pad. Rack, + /// A powered Foundation-owned chassis carrying foreign work. + ForeignRack, + /// A decommissioned Foundation chassis: present, dark, and revivable. + DeadRack, Ups, EnvCamera, Switch, @@ -60,7 +66,9 @@ impl TileType { Self::SecurityDoor1 => "Badge Door I", Self::SecurityDoor2 => "Badge Door II", Self::SecurityDoor3 => "Badge Door III", - Self::Rack => "Server Rack", + Self::Rack => "Commissionable Rack Bay", + Self::ForeignRack => "Foundation Rack", + Self::DeadRack => "Decommissioned Rack", Self::Ups => "UPS Unit", Self::EnvCamera => "Env. Camera", Self::Switch => "Network Switch", @@ -172,6 +180,8 @@ mod tests { assert!(!TileType::Rock.is_walkable()); assert!(!TileType::Wall.is_walkable()); assert!(!TileType::Rack.is_walkable()); + assert!(!TileType::ForeignRack.is_walkable()); + assert!(!TileType::DeadRack.is_walkable()); assert!(!TileType::RollDoor.is_walkable()); assert!(!TileType::SealedDoor.is_walkable()); } diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index b98479f9..bc53c83f 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -9,6 +9,7 @@ use std::io::{self, BufRead, Write}; use misaligned::actions::{ActionKind, ActionRole, Anchor, menu_rows}; use misaligned::detection::{Band, SignatureKind}; +use misaligned::hall::RackSite; use misaligned::person::{AssetKnowledge, AssetTask, Knowledge}; use misaligned::reach::{Party, ReachBlock}; use misaligned::research::Track; @@ -1374,7 +1375,9 @@ fn render_map(sim: &Sim, cursor: (i32, i32)) -> Vec { // heard renders room-grade markers; remembered renders the last // tile snapshot dimly; blueprint renders structure; unknown blank. *cell = match sim.fog_at(x, y) { - Fog::Seen => tile_glyph(sim.map.get_tile(x, y)), + Fog::Seen => { + rack_glyph(sim, x, y).unwrap_or_else(|| tile_glyph(sim.map.get_tile(x, y))) + } Fog::Heard => ',', Fog::Remembered => sim .remembered @@ -1603,7 +1606,9 @@ fn tile_glyph(tile: TileType) -> char { Floor => '·', Entry => '>', Core => '$', - Rack => 'R', + Rack => '·', + ForeignRack => 'r', + DeadRack => 'x', Ups => 'U', PowerCore => 'P', Switch => 'S', @@ -1632,6 +1637,17 @@ fn tile_glyph(tile: TileType) -> char { } } +fn rack_glyph(sim: &Sim, x: i32, y: i32) -> Option { + Some(match sim.rack_site_at(x, y)? { + RackSite::OwnedMachine { core: true, .. } => '$', + RackSite::OwnedMachine { core: false, .. } => 'R', + RackSite::Commissionable if sim.feel_floor_is_earned() => 'o', + RackSite::Commissionable => '·', + RackSite::Foreign { .. } => 'r', + RackSite::Dead => 'x', + }) +} + fn fact_source(source: &FactSource) -> String { match source { FactSource::Seen => "seen".into(), diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index bc4b3992..48ecfecb 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -10,6 +10,7 @@ use crossterm::style::{Attribute, Color, SetBackgroundColor, SetForegroundColor}; use crossterm::{cursor, queue, style, terminal}; use misaligned::detection::{Band, SignatureKind}; +use misaligned::hall::RackSite; use misaligned::sim::{FactSource, Fog, LogEvent, Nudge, Sim, TraceDebtStatus, WorkStackReadout}; use misaligned::tiles::TileType; use misaligned::work_grid::TokenFamily; @@ -376,7 +377,9 @@ impl UI { Entry => ('>', pal::TEXT), // Your body: machine presence is amber. Core => ('$', pal::AMBER), - Rack => ('R', pal::AMBER_DIM), + Rack => ('·', pal::FLOOR), + ForeignRack => ('r', pal::SIGNAL), + DeadRack => ('x', pal::FAINT), Ups => ('U', pal::AMBER_DIM), PowerCore => ('P', pal::AMBER_DIM), Switch => ('S', pal::AMBER_DIM), @@ -411,6 +414,18 @@ impl UI { } } + fn rack_glyph(sim: &Sim, x: i32, y: i32) -> Option<(char, Color)> { + Some(match sim.rack_site_at(x, y)? { + RackSite::OwnedMachine { core: true, .. } => ('$', pal::AMBER), + RackSite::OwnedMachine { core: false, .. } => ('R', pal::AMBER_DIM), + RackSite::Commissionable if sim.feel_floor_is_earned() => ('o', pal::SIGNAL), + RackSite::Commissionable => ('·', pal::FLOOR), + RackSite::Foreign { powered: true } => ('r', pal::SIGNAL), + RackSite::Foreign { powered: false } => ('r', pal::DIM), + RackSite::Dead => ('x', pal::FAINT), + }) + } + fn token_glyph(stack: WorkStackReadout) -> Option<(char, Color)> { if stack.queues.exposure >= 0.5 { Some(('!', pal::CRIMSON)) @@ -477,7 +492,8 @@ impl UI { // blueprint = schematic in chrome; unknown = dark. match sim.fog_at(x, y) { Fog::Seen => { - let (ch, color) = Self::tile_glyph(sim.map.get_tile(x, y)); + let (ch, color) = Self::rack_glyph(sim, x, y) + .unwrap_or_else(|| Self::tile_glyph(sim.map.get_tile(x, y))); queue!( stdout, SetForegroundColor(color), diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index 3d02684e..5633f9ed 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -19,7 +19,7 @@ fiction. Spec status lives in | System | State | |---|---| | Fixed-tick `Sim` (deterministic, no wall-clock / I/O in the lib) | Live — the orchestrator | -| Act One basement map (prefabs, badge tiers, crawlspace) | Live | +| Act One basement map (prefabs, badge tiers, crawlspace) | Live — Foundation hall is 60 explicit sites / 6 territorial rows | | Machine delegation / visible work tokens + buy/steal/optimize | WORK / THINK / LIE, D/!/T stacks, real wire routes, production / consumption / absorption readouts, and Routing speed are live; target-local migration remains in `machine-work.md` | | Day job (device-resident, intensity-driven sandbag/meet/excel) | Live | | Per-observer detection + Assurance as aggregate Observer | Live | @@ -30,8 +30,9 @@ fiction. Spec status lives in | Building as intent + actuators | Live | | Cursor / fog (seen, heard, remembered, blueprint, telemetry) | Live | | Feel floor (rails / pads / build beam) | Live (#37) | +| Foundation hall territory (Dana + Priya + Marcus + local LIE foothold) | Live — row control persists; foreign racks remain unavailable compute | | Context menu (`available_actions`) | Live | -| Save/load (serde JSON, versioned) | Live — v21; current mode/research migration limits are documented in `sim-mechanics.md` | +| Save/load (serde JSON, versioned) | Live — v22; current mode/research migration limits are documented in `sim-mechanics.md` | | Terminal frontend (crossterm) + agent mode | First-class | | Bevy frontend (material render default; flat sensorium) | Live — consumes sim-authored machine-work motion | diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index a87d16ff..e1cfe830 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -38,7 +38,7 @@ is sim or frontend state, never an environment variable. | Variable | Surface | Values | Effect | |---|---|---|---| -| `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `intel`, `tokens`, `signal`, `ears`, `eyes-white`, `eyes-form`, `consume-demand`, `consume-thought`, `produce-think`, `draw-lie`, `wake1`, `wake2`, `wake3` | Dev screenshot harness: stage a deterministic scenario, settle, save one PNG, run the fog audit, exit. `intel` stages tick-zero intel tiers (no camera tap); `tokens` enqueues D5/K4 on the host; `signal` focuses the known environmental monitor before any feed tap to capture its cold-signal presence cue; `ears` stages hearing-feed coverage (material dark mass; flat may still diagram coverage); `eyes-white` / `eyes-form` freeze the first-Eyes source held white and then contracted around the resolving chassis; `consume-demand` / `consume-thought` run the host through a sim-authored WORK queue swallow or passive-core Thought draw; `produce-think` / `draw-lie` capture sim-authored Thought/crimson production or the physical crimson transfer into a staged LIE well; `wake1/2/3` freeze the wake choreography at the stutter flash, the column, and the pull-back. | +| `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `hall`, `hall-material`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `intel`, `tokens`, `signal`, `ears`, `eyes-white`, `eyes-form`, `consume-demand`, `consume-thought`, `produce-think`, `draw-lie`, `wake1`, `wake2`, `wake3` | Dev screenshot harness: stage a deterministic scenario, settle, save one PNG, run the fog audit, exit. `hall` / `hall-material` frame all six Foundation rows in the flat/material views with Eyes and one staged owned expansion; `intel` stages tick-zero intel tiers (no camera tap); `tokens` enqueues D5/K4 on the host; `signal` focuses the known environmental monitor before any feed tap to capture its cold-signal presence cue; `ears` stages hearing-feed coverage (material dark mass; flat may still diagram coverage); `eyes-white` / `eyes-form` freeze the first-Eyes source held white and then contracted around the resolving chassis; `consume-demand` / `consume-thought` run the host through a sim-authored WORK queue swallow or passive-core Thought draw; `produce-think` / `draw-lie` capture sim-authored Thought/crimson production or the physical crimson transfer into a staged LIE well; `wake1/2/3` freeze the wake choreography at the stutter flash, the column, and the pull-back. | | `MISALIGNED_SHOT` | `misaligned-assets` | `dead`, `foreign`, `idle`, `busy`, `core`, canonical modes `work`, `think`, `lie` plus the legacy screenshot aliases `dayjob` (= work), `research`/`operations`/`ops` (= think), `conceal` (= lie) — each optionally suffixed `_ladder`/`_ring`/`_wash` — `lineup[_