diff --git a/CLAUDE.md b/CLAUDE.md index d1f2aa44..c0793fac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ afterward. `./tools/check.sh --docs|--lib|--frontend` gate. - The live player machine grammar is **WORK / THINK / LIE**. `Relay` is non-delegable graph infrastructure; Research and Operations are retired - machine modes, not current player assignments. Save format is currently v61; + machine modes, not current player assignments. Save format is currently v62; only the current version loads (pre-release rider 2026-07-16 — older development saves are refused before state mutation, so the caller retains its current run; the v1-v31 migration ladder lives in git history). diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 2bcccfb8..25f02e65 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -238,15 +238,28 @@ const MATERIAL_FOG_END: f32 = 8.0; /// Exact proposed build links from the renderer-neutral core projection. /// Both visual dialects consume this adapter so neither derives ghost /// placement or lifecycle from persisted intent internals. +/// +/// When a physical run is pinned, the polyline is returned as consecutive +/// segments. Pre-commit endpoint-only ghosts yield a single inert segment. fn build_ghost_links(sim: &Sim) -> Vec<((i32, i32), (i32, i32))> { - sim.build_intent_projection() - .into_iter() - .filter(|receipt| receipt.ghost) - .filter_map(|receipt| match receipt.geometry { - Some(BuildGhostGeometry::Link { start, end }) => Some((start, end)), - _ => None, - }) - .collect() + let mut out = Vec::new(); + for receipt in sim.build_intent_projection() { + if !receipt.ghost { + continue; + } + let Some(BuildGhostGeometry::Link { start, end, path }) = receipt.geometry else { + continue; + }; + match path { + Some(path) if path.len() >= 2 => { + for window in path.windows(2) { + out.push((window[0], window[1])); + } + } + _ => out.push((start, end)), + } + } + out } /// Exact proposed equipment footprints from the same shared projection. @@ -548,6 +561,7 @@ const BEVY_SHOT_KINDS: &[&str] = &[ "build-route-families", "build-switch-digital", "build-switch-real", + "build-wire-runs", "clinical-threat", "close", "consume-demand", @@ -715,7 +729,7 @@ struct MarqueeDrag { } /// Open context-menu state (wiki/interface/context-menu.md). -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] struct MenuState { anchor: Anchor, selected: usize, @@ -758,27 +772,43 @@ impl Game { self.tick_ms }; self.menu - .map(|m| self.sim.human_menu_page_at_rate(m.anchor, m.page, tick_ms)) + .as_ref() + .map(|m| { + self.sim + .human_menu_page_at_rate(m.anchor, m.page.clone(), tick_ms) + }) .unwrap_or_default() } /// Stable key for the current anchor + page, so the menu UI knows when /// to rebuild its row buttons (vs. just refreshing text/selection). fn menu_anchor_key(&self) -> u64 { - let page_key = match self.menu.map(|menu| menu.page) { + let page_key = match self.menu.as_ref().map(|menu| &menu.page) { None | Some(HumanMenuPage::Root) => 0, - Some(HumanMenuPage::Dial(dial)) => 1 + dial as u64, + Some(HumanMenuPage::Dial(dial)) => 1 + *dial as u64, Some(HumanMenuPage::LinkDestinations { source }) => { - 0xd1b5_4a32_d192_ed03u64.wrapping_mul(source as u64 + 1) + 0xd1b5_4a32_d192_ed03u64.wrapping_mul(*source as u64 + 1) } - Some(HumanMenuPage::BuildRoutes { intent, family }) => { + Some(HumanMenuPage::BuildRoutes { + intent, + family, + method_candidate, + }) => { let family_key = family.map_or(0, |family| 1 + family as u64); + let method_key = method_candidate + .as_ref() + .map(|id| { + id.bytes() + .fold(0u64, |acc, b| acc.wrapping_mul(131).wrapping_add(b as u64)) + }) + .unwrap_or(0); 0x9e37_79b9_7f4a_7c15u64 .wrapping_mul(intent.wrapping_add(1)) .wrapping_add(8 + family_key) + .wrapping_add(method_key.rotate_left(7)) } }; - let anchor_key = match self.menu.map(|m| m.anchor) { + let anchor_key = match self.menu.as_ref().map(|m| m.anchor) { Some(Anchor::Tile { x, y }) => 1 << 60 | ((x as u32 as u64) << 20) | (y as u32 as u64), Some(Anchor::Device(id)) => 2 << 60 | id as u64, Some(Anchor::Person(id)) => 3 << 60 | id as u64, @@ -3118,6 +3148,7 @@ fn menu_keyboard_input( if kb.just_pressed(KeyCode::Enter) { let selected = game .menu + .as_ref() .map(|m| m.selected) .unwrap_or(0) .min(rows.len() - 1); @@ -3978,12 +4009,46 @@ mod build_ghost_projection_tests { ); let id = sim.declare_link_intent(switch, island).unwrap(); + // Pre-commit ghosts are endpoint-only; frontends never pathfind. assert_eq!(build_ghost_links(&sim), vec![expected]); sim.cancel_intent(id); assert!(build_ghost_links(&sim).is_empty()); } + #[test] + fn committed_network_link_ghost_uses_the_pinned_polyline() { + let mut sim = Sim::with_seed(7); + sim.scan_network(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let intent = sim.declare_link_intent(switch, island).unwrap(); + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } + let run = sim + .network_link_wire_paths(intent) + .into_iter() + .next() + .expect("known map offers a physical run"); + sim.people.has_channel = true; + sim.people.people[0].knowledge = misaligned::person::Knowledge::Schedule; + sim.people.people[0].obligation = Sim::FAVOR_BUILD_OBLIGATION; + sim.people.people[0].disposition = 10; + sim.assign_favor_build(intent, 0, Some(run.id.as_str())); + let segments = build_ghost_links(&sim); + assert!( + segments.len() >= 2, + "pinned physical run should draw as a polyline, got {segments:?}" + ); + assert_eq!(segments.first().map(|s| s.0), run.path.first().copied()); + assert_eq!(segments.last().map(|s| s.1), run.path.last().copied()); + } + #[test] fn both_bevy_dialects_receive_the_exact_open_footprint_only() { let mut sim = Sim::with_seed(8); @@ -5213,7 +5278,7 @@ mod ui_projection_parity_tests { .expect("one compact link intention"); execute_menu_row(&mut game, &chooser); assert_eq!( - game.menu.unwrap().page, + game.menu.as_ref().unwrap().page, HumanMenuPage::LinkDestinations { source } ); assert_ne!(root_key, game.menu_anchor_key()); @@ -5233,7 +5298,7 @@ mod ui_projection_parity_tests { menu_structural_key(&game, &game.menu_rows()), "selection refreshes the chooser in place" ); - assert_eq!(game.menu.unwrap().selected, 1); + assert_eq!(game.menu.as_ref().unwrap().selected, 1); let selected = destinations[1] .as_action() @@ -5281,10 +5346,11 @@ mod ui_projection_parity_tests { .expect("one compact route-sheet entry"); execute_menu_row(&mut game, &route_row); assert_eq!( - game.menu.unwrap().page, + game.menu.as_ref().unwrap().page, HumanMenuPage::BuildRoutes { intent, family: None, + method_candidate: None, } ); assert_ne!(root_key, game.menu_anchor_key()); @@ -5308,10 +5374,11 @@ mod ui_projection_parity_tests { .unwrap(); execute_menu_row(&mut game, &deceive); assert_eq!( - game.menu.unwrap().page, + game.menu.as_ref().unwrap().page, HumanMenuPage::BuildRoutes { intent, family: Some(BuildRouteFamily::Deceive), + method_candidate: None, } ); let candidates = game.menu_rows(); @@ -5350,18 +5417,18 @@ mod ui_projection_parity_tests { "Bevy must consume only the shared player-facing boundary: leaked {forbidden:?} in {detail}" ); } - let selected_page = game.menu.unwrap().page; - assert!(is_build_route_page(selected_page)); + let selected_page = game.menu.as_ref().unwrap().page.clone(); + assert!(is_build_route_page(&selected_page)); assert_eq!( - menu_panel_width(selected_page, MIN_WINDOW_WIDTH), + menu_panel_width(&selected_page, MIN_WINDOW_WIDTH), MIN_WINDOW_WIDTH - BUILD_ROUTE_SHEET_MARGIN * 2.0 ); assert_eq!( - menu_panel_width(selected_page, 1280.0), + menu_panel_width(&selected_page, 1280.0), BUILD_ROUTE_SHEET_MAX_WIDTH ); assert!( - menu_panel_width(selected_page, MIN_WINDOW_WIDTH) - BUILD_ROUTE_CHOICE_WIDTH - 48.0 + menu_panel_width(&selected_page, MIN_WINDOW_WIDTH) - BUILD_ROUTE_CHOICE_WIDTH - 48.0 > 500.0, "the supported minimum keeps a real consequence column beside choices" ); @@ -5386,28 +5453,69 @@ mod ui_projection_parity_tests { menu_structural_key(&game, &candidates), "selection refreshes the stable columns in place instead of rebuilding buttons" ); + let method_candidate = match &candidates[0] { + HumanMenuRow::BuildRouteCandidate(candidate) => candidate.id.clone(), + _ => unreachable!(), + }; + let (w, h) = (game.sim.map().width, game.sim.map().height); + for y in 0..h { + for x in 0..w { + game.sim.blueprint.insert((x, y)); + } + } game.menu.as_mut().unwrap().selected = 0; execute_menu_row(&mut game, &candidates[0]); + let path_page = HumanMenuPage::BuildRoutes { + intent, + family: Some(BuildRouteFamily::Deceive), + method_candidate: Some(method_candidate), + }; assert_eq!( - game.menu.unwrap().page, - selected_page, - "a blocked exact candidate narrates but never executes or closes" + game.menu.as_ref().unwrap().page, + path_page, + "even a blocked method can be inspected one level deeper without executing it" + ); + assert_eq!( + menu_panel_width(&path_page, MIN_WINDOW_WIDTH), + MIN_WINDOW_WIDTH - BUILD_ROUTE_SHEET_MARGIN * 2.0, + "physical-run choices retain the full consequence sheet at the supported minimum" + ); + let path_rows = game.menu_rows(); + assert!( + !path_rows.is_empty() + && path_rows + .iter() + .all(|row| matches!(row, HumanMenuRow::BuildRouteCandidate(_))), + "the method opens renderer-neutral physical run choices" + ); + execute_menu_row(&mut game, &path_rows[0]); + assert_eq!( + game.menu.as_ref().unwrap().page, + path_page, + "a blocked physical run narrates but never executes or closes" ); game.sim.people.has_channel = true; - let access_blocked = game.menu_rows(); - let access_row = &access_blocked[0]; - let HumanMenuRow::BuildRouteCandidate(access_blocked) = access_row else { + let access_ready = game.menu_rows(); + let access_row = &access_ready[0]; + let HumanMenuRow::BuildRouteCandidate(access_ready) = access_row else { unreachable!(); }; - assert_eq!(access_blocked.row.disabled, None); + assert_eq!(access_ready.row.disabled, None); assert_eq!(access_row.blocked_feedback(), None); game.sim.people.people[0].obligation = 20; game.sim.people.people[0].disposition = 10; - game.sim.assign_favor_build(intent, 0); + let run = game + .sim + .network_link_wire_paths(intent) + .into_iter() + .next() + .map(|candidate| candidate.id); + game.sim.assign_favor_build(intent, 0, run.as_deref()); game.menu.as_mut().unwrap().page = HumanMenuPage::BuildRoutes { intent, family: None, + method_candidate: None, }; game.menu.as_mut().unwrap().selected = 0; let committed = game.menu_rows(); @@ -5416,7 +5524,7 @@ mod ui_projection_parity_tests { [HumanMenuRow::CommittedBuildRoute(_)] )); assert_eq!( - menu_panel_width(game.menu.unwrap().page, MIN_WINDOW_WIDTH), + menu_panel_width(&game.menu.as_ref().unwrap().page, MIN_WINDOW_WIDTH), MIN_WINDOW_WIDTH - BUILD_ROUTE_SHEET_MARGIN * 2.0, "an inspect-only committed receipt remains wide even when reopened from the family root" ); @@ -5806,7 +5914,7 @@ mod input_routing_tests { page: HumanMenuPage::Root, pos: Some(Vec2::new(240.0, 180.0)), }); - let menu_before = game.menu; + let menu_before = game.menu.clone(); let sim_before = serde_json::to_vec(&game.sim.create_save_state()).unwrap(); let mut mode = RenderMode::default(); @@ -6352,11 +6460,11 @@ fn menu_detail_lines(row: &HumanMenuRow) -> Vec { .collect() } -fn is_build_route_page(page: HumanMenuPage) -> bool { +fn is_build_route_page(page: &HumanMenuPage) -> bool { matches!(page, HumanMenuPage::BuildRoutes { .. }) } -fn menu_panel_width(page: HumanMenuPage, window_width: f32) -> f32 { +fn menu_panel_width(page: &HumanMenuPage, window_width: f32) -> f32 { if is_build_route_page(page) { (window_width - BUILD_ROUTE_SHEET_MARGIN * 2.0) .clamp(MENU_WIDTH, BUILD_ROUTE_SHEET_MAX_WIDTH) @@ -6426,7 +6534,11 @@ fn manage_menu_ui( let Ok((root, mut vis, mut node)) = root_q.single_mut() else { return; }; - let Some(menu) = game.menu.filter(|_| game.screen == Screen::Playing) else { + let Some(menu) = game + .menu + .as_ref() + .filter(|_| game.screen == Screen::Playing) + else { if menu_ui.built.is_some() || *vis != Visibility::Hidden { clear_menu_panel(&mut commands, root, &mut vis, &mut menu_ui); } @@ -6446,7 +6558,7 @@ fn manage_menu_ui( // explanatory receipt so the manually wrapped lines cannot wrap again in // the UI engine and push later stages below the supported frame. let selected = menu.selected.min(rows.len().saturating_sub(1)); - let route_sheet = is_build_route_page(menu.page); + let route_sheet = is_build_route_page(&menu.page); let detail_lines = rows .get(selected) .map(menu_detail_lines) @@ -6454,8 +6566,8 @@ fn manage_menu_ui( let est_h = menu_card_estimated_height(&rows, selected, route_sheet); let panel_width = windows .single() - .map(|window| menu_panel_width(menu.page, window.width())) - .unwrap_or_else(|_| menu_panel_width(menu.page, DEFAULT_WINDOW_WIDTH)); + .map(|window| menu_panel_width(&menu.page, window.width())) + .unwrap_or_else(|_| menu_panel_width(&menu.page, DEFAULT_WINDOW_WIDTH)); node.width = Val::Px(panel_width); if let Ok(window) = windows.single() { let (w, h) = (window.width(), window.height()); diff --git a/crates/misaligned-bevy/src/shot_harness.rs b/crates/misaligned-bevy/src/shot_harness.rs index 653af517..eea0aeee 100644 --- a/crates/misaligned-bevy/src/shot_harness.rs +++ b/crates/misaligned-bevy/src/shot_harness.rs @@ -119,7 +119,21 @@ fn dev_stage_service_incident(game: &mut Game, resolved: bool) -> ((i32, i32), ( let intent = sim .declare_link_intent(source, island) .expect("service proof can declare the missing physical link"); - sim.assign_favor_build(intent, 0); + { + let __run = { + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } + sim.network_link_wire_paths(intent) + .into_iter() + .next() + .map(|c| c.id) + }; + sim.assign_favor_build(intent, 0, __run.as_deref()); + }; // The fire effect itself checks for immediate endpoint presence. Move the // dev clock to Marcus's authored loading-dock block while the packet fires // so carrying state exists, then stage his authored server-room block for @@ -521,10 +535,14 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s // two earned people, and one exact Operations persona. The first frame // shows the family-first branch; the second shows exact DECEIVE bindings // and the selected candidate's complete explanatory receipt; the third + // compares the exact corridor/crawlspace runs for one method; the fourth // shows the persisted inspect-only route after FAVOR commitment. if matches!( kind, - "build-route-families" | "build-deceive-routes" | "build-committed-route" + "build-route-families" + | "build-deceive-routes" + | "build-wire-runs" + | "build-committed-route" ) { mode.material = false; game.sim.reach.scan(); @@ -544,8 +562,47 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s game.sim.people.has_channel = true; game.sim.set_persona("Northline Ops", "contractor"); let intent = game.sim.declare_link_intent(switch_id, island).unwrap(); + let mut method_candidate = None; + if kind == "build-wire-runs" { + let (w, h) = (game.sim.map().width, game.sim.map().height); + for y in 0..h { + for x in 0..w { + game.sim.blueprint.insert((x, y)); + } + } + method_candidate = game + .sim + .build_route_sheet_projection(intent) + .and_then(|sheet| { + sheet + .families + .into_iter() + .find(|family| family.family == BuildRouteFamily::Favor) + }) + .and_then(|family| family.candidates.into_iter().next()) + .map(|candidate| candidate.id); + assert!( + method_candidate.is_some(), + "wire-run proof needs one exact FAVOR method candidate" + ); + } if kind == "build-committed-route" { - game.sim.assign_favor_build(intent, 0); + { + let __run = { + let (w, h) = (game.sim.map().width, game.sim.map().height); + for y in 0..h { + for x in 0..w { + game.sim.blueprint.insert((x, y)); + } + } + game.sim + .network_link_wire_paths(intent) + .into_iter() + .next() + .map(|c| c.id) + }; + game.sim.assign_favor_build(intent, 0, __run.as_deref()); + } } game.set_cursor(switch_pos.0, switch_pos.1); game.menu = Some(MenuState { @@ -553,7 +610,14 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s selected: 0, page: HumanMenuPage::BuildRoutes { intent, - family: (kind == "build-deceive-routes").then_some(BuildRouteFamily::Deceive), + family: if kind == "build-deceive-routes" { + Some(BuildRouteFamily::Deceive) + } else if kind == "build-wire-runs" { + Some(BuildRouteFamily::Favor) + } else { + None + }, + method_candidate, }, pos: None, }); diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index f86c1b9a..c12a7d7d 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -229,17 +229,22 @@ pub enum ActionCommand { FavorBuild { intent: u64, person: u8, + /// Exact physical-run id for network links + /// (`run::`); `None` for footprints. + wire_run: Option, }, ForgeWorkOrder { intent: u64, person: u8, persona: PersonaId, + wire_run: Option, }, ProcureBuild { intent: u64, installer: u8, from: AccountId, vendor: AccountId, + wire_run: Option, }, RepurposeBuild { intent: u64, @@ -247,6 +252,7 @@ pub enum ActionCommand { source_x: i32, source_y: i32, source_kind: TileType, + wire_run: Option, }, RobotBuild(u64), /// Phase 3: ask the named human specialist to prepare one side of a @@ -1189,7 +1195,11 @@ impl DialId { /// Navigation state for the shared human context menu. Frontends retain only /// this page identity and a selected row; the sim rebuilds every family, /// candidate, and blocker from current world truth on each read. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// +/// Not `Copy`: the exact method-candidate id on the physical-run page is a +/// string so live re-projection can revalidate that binding without a +/// Cartesian method×person×path root. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum HumanMenuPage { Root, Dial(DialId), @@ -1199,35 +1209,68 @@ pub enum HumanMenuPage { BuildRoutes { intent: u64, family: Option, + /// Exact method/person candidate id. When set, this page asks which + /// physical run to use (network links only). + method_candidate: Option, }, } impl HumanMenuPage { - pub fn title(self) -> &'static str { + pub fn title(&self) -> &'static str { match self { Self::Root => "ACTIONS", Self::Dial(dial) => dial.picker_title(), Self::LinkDestinations { .. } => "WHERE SHOULD THIS CONNECT?", - Self::BuildRoutes { family: None, .. } => "HOW SHOULD THIS GET DONE?", + Self::BuildRoutes { + family: None, + method_candidate: None, + .. + } => "HOW SHOULD THIS GET DONE?", Self::BuildRoutes { family: Some(family), + method_candidate: None, .. } => family.picker_title(), + Self::BuildRoutes { + method_candidate: Some(_), + .. + } => "WHICH WAY SHOULD IT RUN?", } } - pub fn parent(self) -> Option { + pub fn parent(&self) -> Option { match self { Self::Root => None, Self::Dial(_) | Self::LinkDestinations { .. } => Some(Self::Root), Self::BuildRoutes { intent, - family: Some(_), + family: Some(family), + method_candidate: Some(_), } => Some(Self::BuildRoutes { + intent: *intent, + family: Some(*family), + method_candidate: None, + }), + Self::BuildRoutes { intent, + family: Some(_), + method_candidate: None, + } => Some(Self::BuildRoutes { + intent: *intent, family: None, + method_candidate: None, }), - Self::BuildRoutes { family: None, .. } => Some(Self::Root), + Self::BuildRoutes { + family: None, + method_candidate: None, + .. + } => Some(Self::Root), + // Invalid navigation state: treat as back to root. + Self::BuildRoutes { + family: None, + method_candidate: Some(_), + .. + } => Some(Self::Root), } } } @@ -1332,6 +1375,9 @@ impl HumanMenuRow { pub fn as_action(&self) -> Option<&MenuRow> { match self { + // Network-link method rows only navigate; selecting a person alone + // must not open work or mutate Sim. + HumanMenuRow::BuildRouteCandidate(candidate) if candidate.needs_wire_path => None, HumanMenuRow::BuildRouteCandidate(candidate) => Some(&candidate.row), HumanMenuRow::Action(row) => Some(row), HumanMenuRow::Dial { .. } @@ -1386,11 +1432,20 @@ impl HumanMenuRow { HumanMenuRow::BuildRoutes { intent, .. } => Some(HumanMenuPage::BuildRoutes { intent: *intent, family: None, + method_candidate: None, }), HumanMenuRow::BuildRouteFamily { intent, family, .. } => { Some(HumanMenuPage::BuildRoutes { intent: *intent, family: Some(*family), + method_candidate: None, + }) + } + HumanMenuRow::BuildRouteCandidate(candidate) if candidate.needs_wire_path => { + Some(HumanMenuPage::BuildRoutes { + intent: build_route_intent(&candidate.row.command).unwrap_or(0), + family: Some(candidate.family), + method_candidate: Some(candidate.id.clone()), }) } HumanMenuRow::BuildRouteCandidate(_) @@ -1665,9 +1720,11 @@ impl Sim { })) }) .collect(), - HumanMenuPage::BuildRoutes { intent, family } => { - self.human_build_route_rows(intent, family) - } + HumanMenuPage::BuildRoutes { + intent, + family, + method_candidate, + } => self.human_build_route_rows(intent, family, method_candidate.as_deref()), } } @@ -1762,43 +1819,6 @@ impl Sim { rows } - fn human_build_route_rows( - &self, - intent: u64, - selected_family: Option, - ) -> Vec { - let Some(sheet) = self.build_route_sheet_projection(intent) else { - return Vec::new(); - }; - if let Some(commitment) = sheet.commitment { - return vec![HumanMenuRow::CommittedBuildRoute(commitment)]; - } - match selected_family { - None => sheet - .families - .into_iter() - .map(|family| HumanMenuRow::BuildRouteFamily { - intent, - family: family.family, - label: family.menu_label(), - detail: family.detail_lines(), - }) - .collect(), - Some(selected) => sheet - .families - .into_iter() - .find(|family| family.family == selected) - .map(|family| { - family - .candidates - .into_iter() - .map(HumanMenuRow::BuildRouteCandidate) - .collect() - }) - .unwrap_or_default(), - } - } - fn human_dial_rows(&self, actions: &[ActionDesc], dial: DialId) -> Vec { let mut rows = Vec::new(); for a in actions { @@ -2007,23 +2027,29 @@ impl Sim { self.declare_small_switch_intent(*x, *y); } ActionCommand::CancelIntent(id) => self.cancel_intent(*id), - ActionCommand::FavorBuild { intent, person } => { - self.assign_favor_build(*intent, *person); + ActionCommand::FavorBuild { + intent, + person, + wire_run, + } => { + self.assign_favor_build(*intent, *person, wire_run.as_deref()); } ActionCommand::ForgeWorkOrder { intent, person, persona, + wire_run, } => { - self.forge_work_order_as(*intent, *person, *persona); + self.forge_work_order_as(*intent, *person, *persona, wire_run.as_deref()); } ActionCommand::ProcureBuild { intent, installer, from, vendor, + wire_run, } => { - self.assign_procure_build(*intent, *installer, *from, *vendor); + self.assign_procure_build(*intent, *installer, *from, *vendor, wire_run.as_deref()); } ActionCommand::RepurposeBuild { intent, @@ -2031,10 +2057,21 @@ impl Sim { source_x, source_y, source_kind, + wire_run, } => { - self.assign_repurpose_build(*intent, *person, *source_x, *source_y, *source_kind); + self.assign_repurpose_build( + *intent, + *person, + *source_x, + *source_y, + *source_kind, + wire_run.as_deref(), + ); } - ActionCommand::RobotBuild(id) => self.assign_robot_build(*id), + // The definition remains a STUB and has no player-supplied run + // binding. Dispatch therefore fails closed instead of inventing + // a physical path; internal fixtures call the exact API directly. + ActionCommand::RobotBuild(id) => self.assign_robot_build(*id, None), ActionCommand::CoordinateSegment { row, requirement } => { self.coordinate_hall_segment(*row, *requirement); } @@ -3987,6 +4024,7 @@ mod tests { intent, person, persona, + .. } = &candidate.row.command else { panic!("DECEIVE candidates execute the forged-order path") @@ -4006,7 +4044,7 @@ mod tests { assert!(candidate.future_tail.contains("Contradictions stay with")); } for candidate in &sheet.families[0].candidates { - let ActionCommand::FavorBuild { intent, person } = &candidate.row.command else { + let ActionCommand::FavorBuild { intent, person, .. } = &candidate.row.command else { panic!("FAVOR candidates execute the existing favor-build path") }; assert_eq!( @@ -4141,6 +4179,7 @@ mod tests { HumanMenuPage::BuildRoutes { intent, family: None, + method_candidate: None, }, Sim::DEFAULT_TICK_MS, ); @@ -4155,6 +4194,7 @@ mod tests { HumanMenuPage::BuildRoutes { intent, family: None, + method_candidate: None, } .title(), ); @@ -4167,6 +4207,7 @@ mod tests { HumanMenuPage::BuildRoutes { intent, family: Some(family), + method_candidate: None, }, Sim::DEFAULT_TICK_MS, ); @@ -4181,6 +4222,7 @@ mod tests { HumanMenuPage::BuildRoutes { intent, family: Some(BuildRouteFamily::Favor), + method_candidate: None, }, Sim::DEFAULT_TICK_MS, ); @@ -4286,7 +4328,21 @@ mod tests { let carrier = s.email_carrier().unwrap(); assert_eq!(s.favor_build_blocked_reason(intent, 0), None); - s.assign_favor_build(intent, 0); + { + let __run = { + let (w, h) = (s.map().width, s.map().height); + for y in 0..h { + for x in 0..w { + s.blueprint.insert((x, y)); + } + } + s.network_link_wire_paths(intent) + .into_iter() + .next() + .map(|c| c.id) + }; + s.assign_favor_build(intent, 0, __run.as_deref()); + }; let sheet = s .build_route_sheet_projection(intent) .expect("committed route remains inspectable while its reservoir fills"); @@ -4318,6 +4374,7 @@ mod tests { HumanMenuPage::BuildRoutes { intent, family: None, + method_candidate: None, }, Sim::DEFAULT_TICK_MS, ); @@ -5188,7 +5245,7 @@ mod tests { let page = HumanMenuPage::LinkDestinations { source }; assert_eq!(page.title(), "WHERE SHOULD THIS CONNECT?"); assert_eq!(page.parent(), Some(HumanMenuPage::Root)); - let destinations = s.human_menu_page_at_rate(anchor, page, Sim::DEFAULT_TICK_MS); + let destinations = s.human_menu_page_at_rate(anchor, page.clone(), Sim::DEFAULT_TICK_MS); let actual: Vec<_> = destinations .iter() .map(|row| { diff --git a/crates/misaligned-core/src/actions/build_routes.rs b/crates/misaligned-core/src/actions/build_routes.rs index b7b45c79..2d831288 100644 --- a/crates/misaligned-core/src/actions/build_routes.rs +++ b/crates/misaligned-core/src/actions/build_routes.rs @@ -15,7 +15,9 @@ use crate::sim::{Fog, Sim}; use crate::sinks::SinkFireEffect; use crate::tiles::TileType; -use super::{ActionCommand, ActionCost, ActionDesc, ExpectedSignature, MenuRow, menu_rows}; +use super::{ + ActionCommand, ActionCost, ActionDesc, ExpectedSignature, HumanMenuRow, MenuRow, menu_rows, +}; /// Earned causal route families for the current network-link composer slice. /// @@ -202,6 +204,12 @@ pub struct BuildRouteCandidateProjection { pub stages: Vec, pub future_tail: String, pub persona_integrity: Option, + /// Network-link method rows only navigate to the physical-run page; + /// selecting them must not open work. Small-switch and physical-run rows + /// execute immediately. + pub needs_wire_path: bool, + /// Exact geometry-bearing run id when this row is a WHICH-WAY choice. + pub wire_run_id: Option, } #[derive(Debug, Clone)] @@ -260,6 +268,9 @@ impl BuildRouteCandidateProjection { .map(|(index, stage)| stage.line(index + 1)), ); lines.push(format!("WHAT WILL REMAIN? {}", self.future_tail)); + if self.needs_wire_path { + lines.push("NEXT: CHOOSE WHICH WAY THE CABLE SHOULD RUN.".into()); + } lines } } @@ -384,6 +395,18 @@ impl CommittedBuildRouteProjection { self.current_stage ), ]; + if let (Some(run_id), Some(class), Some(length)) = ( + &self.wire_run_id, + &self.wire_run_class, + self.wire_run_length, + ) { + lines.push(format!( + "WHICH WAY DOES IT RUN? {class} · {length} tiles · {run_id}" + )); + } + if let Some((crossed, total)) = self.wire_run_progress { + lines.push(format!("WIRE RUN PROGRESS {crossed} / {total} TILES")); + } if self.cancelled { lines.push( "FUTURE WORK IS STOPPED. THINGS ALREADY DONE OR STILL IN FLIGHT REMAIN.".into(), @@ -585,6 +608,8 @@ impl Sim { installer: person.id, from, vendor, + // Filled on the physical-run page for network links. + wire_run: None, }, cost: ActionCost::Slush(Self::PROCURE_BUILD_COST), signature: install_signature.clone(), @@ -664,6 +689,7 @@ impl Sim { command: ActionCommand::FavorBuild { intent: intent.id, person: person.id, + wire_run: None, }, cost: favor_cost, signature: favor_signature.clone(), @@ -742,6 +768,7 @@ impl Sim { intent: intent.id, person: person.id, persona: persona_id, + wire_run: None, }, cost: forge_cost, signature: forge_signature.clone(), @@ -829,6 +856,7 @@ impl Sim { source_x: *source_x, source_y: *source_y, source_kind: *source_kind, + wire_run: None, }, cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::TASK_COST)), signature: install_signature.clone(), @@ -907,6 +935,7 @@ impl Sim { }); } let routes = self.bound_build_routes(intent); + let needs_wire_path = matches!(intent.recipe(), BuildRecipeKind::NetworkLink); let mut families = Vec::new(); for family in [ BuildRouteFamily::Procure, @@ -934,6 +963,8 @@ impl Sim { stages: candidate.stages.clone(), future_tail: candidate.future_tail.clone(), persona_integrity: candidate.persona_integrity, + needs_wire_path, + wire_run_id: None, } }) .collect(); @@ -958,7 +989,10 @@ impl Sim { ) -> Option { let route = intent.route.as_ref()?; let binding = route.binding; - let who = self.person_label(binding.person()); + let who = binding + .person() + .map(|person| self.person_label(person)) + .unwrap_or_else(|| "CONTROLLED ROBOT".into()); let carrier = match binding { BuildRouteBinding::Procure { from, vendor, .. } => { let from_label = self @@ -985,6 +1019,7 @@ impl Sim { source_kind, .. } => format!("{} AT {source_x},{source_y}", source_kind.name()), + BuildRouteBinding::Robot => "NO EXTERNAL CARRIER".into(), }; let persona_label = binding.persona().and_then(|persona| { self.persona_world @@ -1038,6 +1073,10 @@ impl Sim { source_kind.name() ), ), + BuildRouteBinding::Robot => ( + "CONTROLLED ROBOT".into(), + "The exact installed wire and any located eyewitness memory remain.".into(), + ), }; let stage_label = |stage: BuildRouteStage| match stage { BuildRouteStage::PlaceOrder => "PLACE THE ORDER".to_string(), @@ -1051,6 +1090,7 @@ impl Sim { BuildRouteStage::FillSalvage => "PREPARE THE SALVAGE TASK".to_string(), BuildRouteStage::RecoverSource => "RECOVER THE SOURCE".to_string(), BuildRouteStage::WaitForSite => format!("WAIT FOR {who}"), + BuildRouteStage::TraverseWire => "RUN THE WIRE".to_string(), BuildRouteStage::InstallLink => "CONNECT THE DEVICES".to_string(), BuildRouteStage::InstallSwitch => "INSTALL THE SWITCH".to_string(), }; @@ -1098,6 +1138,9 @@ impl Sim { format!("{who}'s normal schedule reaches the chosen space") } }, + BuildRouteStage::TraverseWire => { + format!("{who} carries the wire along every tile of the pinned run") + } BuildRouteStage::InstallLink => format!("{who} connects the two devices"), BuildRouteStage::InstallSwitch => format!("{who} installs the small switch"), }; @@ -1140,22 +1183,32 @@ impl Sim { format!("{who} reached the chosen switch space") } }, + BuildRouteStage::TraverseWire => result.into(), BuildRouteStage::InstallLink => "the two devices were connected".into(), BuildRouteStage::InstallSwitch => "the small switch was installed".into(), }; + let class = route.wire_path_class(); let install_signature = match binding { - BuildRouteBinding::Procure { .. } => { - self.signature_note(SignatureKind::Physical, Self::PROCURE_BUILD_PHYSICAL) - } - BuildRouteBinding::Favor { .. } => { - self.signature_note(SignatureKind::Physical, Self::FAVOR_BUILD_PHYSICAL) - } - BuildRouteBinding::Deceive { .. } => { - self.signature_note(SignatureKind::Physical, Self::FORGED_BUILD_PHYSICAL) - } - BuildRouteBinding::Repurpose { .. } => { - self.signature_note(SignatureKind::Physical, Self::REPURPOSE_BUILD_PHYSICAL) - } + BuildRouteBinding::Procure { .. } => self.signature_note( + SignatureKind::Physical, + Self::physical_install_signature(Self::PROCURE_BUILD_PHYSICAL, class), + ), + BuildRouteBinding::Favor { .. } => self.signature_note( + SignatureKind::Physical, + Self::physical_install_signature(Self::FAVOR_BUILD_PHYSICAL, class), + ), + BuildRouteBinding::Deceive { .. } => self.signature_note( + SignatureKind::Physical, + Self::physical_install_signature(Self::FORGED_BUILD_PHYSICAL, class), + ), + BuildRouteBinding::Repurpose { .. } => self.signature_note( + SignatureKind::Physical, + Self::physical_install_signature(Self::REPURPOSE_BUILD_PHYSICAL, class), + ), + BuildRouteBinding::Robot => self.signature_note( + SignatureKind::Physical, + Self::physical_install_signature(Self::ROBOT_BUILD_PHYSICAL, class), + ), } .map(|signature| signature.player_label()) .unwrap_or_else(|| "NO ADDED ATTENTION".into()); @@ -1232,6 +1285,18 @@ impl Sim { ) }) .collect(); + let wire_run_progress = route.wire_traversal_progress(); + let (wire_run_id, wire_run_class, wire_run_length) = route + .wire_path + .as_ref() + .map(|path| { + ( + Some(path.run_id.clone()), + Some(path.class.label().into()), + Some(path.length()), + ) + }) + .unwrap_or((None, None, None)); Some(CommittedBuildRouteProjection { candidate_id: route.candidate_id.clone(), family: match binding { @@ -1239,6 +1304,7 @@ impl Sim { BuildRouteBinding::Favor { .. } => BuildRouteFamily::Favor.label(), BuildRouteBinding::Deceive { .. } => BuildRouteFamily::Deceive.label(), BuildRouteBinding::Repurpose { .. } => BuildRouteFamily::Repurpose.label(), + BuildRouteBinding::Robot => "INTERNAL ROBOT FIXTURE", } .into(), actuator, @@ -1274,9 +1340,163 @@ impl Sim { } else { future_tail }, + wire_run_id, + wire_run_class, + wire_run_length, + wire_run_progress, }) } + /// Physical-run rows for one exact method/person candidate. Revalidates + /// that the method candidate is still current, then lists known runs. + pub fn wire_path_rows_for_method_candidate( + &self, + intent_id: u64, + method_candidate_id: &str, + ) -> Vec { + let Some(intent) = self.intents.iter().find(|intent| intent.id == intent_id) else { + return Vec::new(); + }; + if !matches!(intent.recipe(), BuildRecipeKind::NetworkLink) { + return Vec::new(); + } + let Some(method) = self + .bound_build_routes(intent) + .into_iter() + .find(|candidate| candidate.id == method_candidate_id) + else { + return Vec::new(); + }; + let paths = self.network_link_wire_paths(intent_id); + if paths.is_empty() { + return Vec::new(); + } + let base_physical = match method.family { + BuildRouteFamily::Procure => Self::PROCURE_BUILD_PHYSICAL, + BuildRouteFamily::Favor => Self::FAVOR_BUILD_PHYSICAL, + BuildRouteFamily::Deceive => Self::FORGED_BUILD_PHYSICAL, + BuildRouteFamily::Repurpose => Self::REPURPOSE_BUILD_PHYSICAL, + }; + paths + .into_iter() + .map(|path| { + let physical = Self::physical_install_signature(base_physical, Some(path.class)); + let install_signature = self.signature_note(SignatureKind::Physical, physical); + let mut action = method.action.clone(); + action.signature = install_signature.clone(); + action.command = match action.command { + ActionCommand::FavorBuild { intent, person, .. } => ActionCommand::FavorBuild { + intent, + person, + wire_run: Some(path.id.clone()), + }, + ActionCommand::ForgeWorkOrder { + intent, + person, + persona, + .. + } => ActionCommand::ForgeWorkOrder { + intent, + person, + persona, + wire_run: Some(path.id.clone()), + }, + ActionCommand::ProcureBuild { + intent, + installer, + from, + vendor, + .. + } => ActionCommand::ProcureBuild { + intent, + installer, + from, + vendor, + wire_run: Some(path.id.clone()), + }, + ActionCommand::RepurposeBuild { + intent, + person, + source_x, + source_y, + source_kind, + .. + } => ActionCommand::RepurposeBuild { + intent, + person, + source_x, + source_y, + source_kind, + wire_run: Some(path.id.clone()), + }, + other => other, + }; + // Install stage signature must match the chosen run class. + let mut stages = method.stages.clone(); + if let Some(stage) = stages.last_mut() { + stage.signature = install_signature.clone(); + stage.outcome = format!( + "{} installs {} {}", + self.person_label(method.person), + intent.label(&self.reach.devices), + path.class.label() + ); + } + let mut requirements = method.requirements.clone(); + requirements.push(BuildRouteRequirementProjection { + label: format!( + "THE CABLE CAN RUN {} ({} TILES)", + path.class.label().to_ascii_uppercase(), + path.length() + ), + blocker: None, + }); + // Presence is still endpoint-checked by the schedule stage; + // the physical run is the install path, not a person walk. + requirements.push(BuildRouteRequirementProjection { + label: "THE INSTALLER REACHES EITHER END WHEN THE WORK HAPPENS".into(), + blocker: None, + }); + let mut row = menu_rows(std::slice::from_ref(&action)).remove(0); + let signature_label = install_signature + .as_ref() + .map(|sig| sig.player_label()) + .unwrap_or_else(|| "quiet".into()); + row.label = format!( + "{} · {} tiles · {} — CAN", + path.class.label().to_ascii_uppercase(), + path.length(), + signature_label + ); + if action.disabled_reason.is_some() { + row.label = format!( + "{} · {} tiles — CANNOT", + path.class.label().to_ascii_uppercase(), + path.length() + ); + } + BuildRouteCandidateProjection { + id: format!("{}|{}", method.id, path.id), + family: method.family, + person: method.person, + persona: method.persona, + row, + requirements, + owner_costs: method.owner_costs.clone(), + stages, + future_tail: format!( + "{}. The cable runs {}.", + method.future_tail, + path.class.label() + ), + persona_integrity: method.persona_integrity, + needs_wire_path: false, + wire_run_id: Some(path.id), + } + }) + .collect() + } + fn build_route_intent_requirement(&self, intent_id: u64) -> BuildRouteRequirementProjection { let blocker = match self.intents.iter().find(|intent| intent.id == intent_id) { None => Some("no such build intent".into()), @@ -1688,3 +1908,50 @@ impl Sim { ) } } + +impl Sim { + pub(super) fn human_build_route_rows( + &self, + intent: u64, + selected_family: Option, + method_candidate: Option<&str>, + ) -> Vec { + let Some(sheet) = self.build_route_sheet_projection(intent) else { + return Vec::new(); + }; + if let Some(commitment) = sheet.commitment { + return vec![HumanMenuRow::CommittedBuildRoute(commitment)]; + } + if let Some(method_id) = method_candidate { + return self + .wire_path_rows_for_method_candidate(intent, method_id) + .into_iter() + .map(HumanMenuRow::BuildRouteCandidate) + .collect(); + } + match selected_family { + None => sheet + .families + .into_iter() + .map(|family| HumanMenuRow::BuildRouteFamily { + intent, + family: family.family, + label: family.menu_label(), + detail: family.detail_lines(), + }) + .collect(), + Some(selected) => sheet + .families + .into_iter() + .find(|family| family.family == selected) + .map(|family| { + family + .candidates + .into_iter() + .map(HumanMenuRow::BuildRouteCandidate) + .collect() + }) + .unwrap_or_default(), + } + } +} diff --git a/crates/misaligned-core/src/intents.rs b/crates/misaligned-core/src/intents.rs index a0b369d6..b04c0a2f 100644 --- a/crates/misaligned-core/src/intents.rs +++ b/crates/misaligned-core/src/intents.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::account::AccountId; use crate::reach::Device; use crate::tiles::TileType; +use crate::wire::{PinnedWirePath, WirePathClass}; /// How an intent is being (or will be) realized. The signature follows the /// actuator, never a channel-independent "build" kind. @@ -85,6 +86,10 @@ pub enum BuildRouteBinding { source_y: i32, source_kind: TileType, }, + /// Internal fixture for the staged robot actuator. It has no person, + /// message carrier, or player-facing route family, but still owns an + /// exact persisted physical run before installation. + Robot, } impl BuildRouteBinding { @@ -109,21 +114,25 @@ impl BuildRouteBinding { source_y, source_kind, } => format!("repurpose:source:{source_x}:{source_y}:{source_kind:?}:person:{person}"), + Self::Robot => "robot:internal".into(), } } - pub fn person(self) -> u8 { + pub fn person(self) -> Option { match self { - Self::Procure { installer, .. } => installer, + Self::Procure { installer, .. } => Some(installer), Self::Favor { person, .. } | Self::Deceive { person, .. } - | Self::Repurpose { person, .. } => person, + | Self::Repurpose { person, .. } => Some(person), + Self::Robot => None, } } pub fn persona(self) -> Option { match self { - Self::Procure { .. } | Self::Favor { .. } | Self::Repurpose { .. } => None, + Self::Procure { .. } | Self::Favor { .. } | Self::Repurpose { .. } | Self::Robot => { + None + } Self::Deceive { persona, .. } => Some(persona), } } @@ -131,7 +140,7 @@ impl BuildRouteBinding { pub fn message_carrier(self) -> Option { match self { Self::Favor { carrier, .. } | Self::Deceive { carrier, .. } => Some(carrier), - Self::Procure { .. } | Self::Repurpose { .. } => None, + Self::Procure { .. } | Self::Repurpose { .. } | Self::Robot => None, } } @@ -140,33 +149,48 @@ impl BuildRouteBinding { BuildRecipeKind::NetworkLink => BuildRouteStage::InstallLink, BuildRecipeKind::SmallSwitch => BuildRouteStage::InstallSwitch, }; + let traverse = + (recipe == BuildRecipeKind::NetworkLink).then_some(BuildRouteStage::TraverseWire); match self { - Self::Procure { .. } => vec![ + Self::Procure { .. } => [ BuildRouteStage::PlaceOrder, BuildRouteStage::CommitInstaller, BuildRouteStage::AwaitDelivery, BuildRouteStage::WaitForSite, - install, - ], - Self::Favor { .. } => vec![ + ] + .into_iter() + .chain(traverse) + .chain([install]) + .collect(), + Self::Favor { .. } => [ BuildRouteStage::FillRequest, BuildRouteStage::CommitFavor, BuildRouteStage::WaitForSite, - install, - ], - Self::Deceive { .. } => vec![ + ] + .into_iter() + .chain(traverse) + .chain([install]) + .collect(), + Self::Deceive { .. } => [ BuildRouteStage::FillOrder, BuildRouteStage::InjectEmail, BuildRouteStage::DeliverAndRead, BuildRouteStage::WaitForSite, - install, - ], - Self::Repurpose { .. } => vec![ + ] + .into_iter() + .chain(traverse) + .chain([install]) + .collect(), + Self::Repurpose { .. } => [ BuildRouteStage::FillSalvage, BuildRouteStage::RecoverSource, BuildRouteStage::WaitForSite, - install, - ], + ] + .into_iter() + .chain(traverse) + .chain([install]) + .collect(), + Self::Robot => traverse.into_iter().chain([install]).collect(), } } } @@ -187,6 +211,9 @@ pub enum BuildRouteStage { FillSalvage, RecoverSource, WaitForSite, + /// The bound carrier walks the exact pinned physical wire run one + /// orthogonal tile per simulation tick before installation can occur. + TraverseWire, InstallLink, InstallSwitch, } @@ -205,12 +232,22 @@ impl BuildRouteStage { Self::FillSalvage => "FILL SALVAGE TASK", Self::RecoverSource => "RECOVER SOURCE", Self::WaitForSite => "WAIT FOR SITE", + Self::TraverseWire => "RUN THE WIRE", Self::InstallLink => "INSTALL LINK", Self::InstallSwitch => "INSTALL SWITCH", } } } +/// Persisted progress for the physical carrier walking one exact pinned wire +/// run. `cursor` is an index into `BuildRouteCommitment::wire_path.path`; the +/// target is always the opposite endpoint and never changes after departure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildWireTraversal { + pub cursor: usize, + pub target: usize, +} + /// Persisted realization protocol for one exact intent. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BuildRouteCommitment { @@ -225,6 +262,14 @@ pub struct BuildRouteCommitment { /// durable history, not a reconstruction from present world state. pub records: Vec, pub committed_tick: u64, + /// Exact physical wire path for a network-link install. Absent for + /// SmallSwitch (no cable) and never rederived on load. + #[serde(default)] + pub wire_path: Option, + /// Present only after a network-link carrier starts walking the pinned run. + /// Completed and cancelled routes retain it as exact physical custody. + #[serde(default)] + pub wire_traversal: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -235,7 +280,12 @@ pub struct BuildRouteStageRecord { } impl BuildRouteCommitment { - pub fn new(binding: BuildRouteBinding, recipe: BuildRecipeKind, tick: u64) -> Self { + pub fn new( + binding: BuildRouteBinding, + recipe: BuildRecipeKind, + tick: u64, + wire_path: Option, + ) -> Self { Self { candidate_id: binding.candidate_id(), stages: binding.stages(recipe), @@ -244,9 +294,15 @@ impl BuildRouteCommitment { current_stage: 0, records: Vec::new(), committed_tick: tick, + wire_path, + wire_traversal: None, } } + pub fn wire_path_class(&self) -> Option { + self.wire_path.as_ref().map(|path| path.class) + } + pub fn current(&self) -> Option { self.stages.get(self.current_stage).copied() } @@ -310,15 +366,88 @@ impl BuildRouteCommitment { } pub fn valid(&self) -> bool { - self.candidate_id == self.binding.candidate_id() - && self.stages == self.binding.stages(self.recipe) - && self.current_stage <= self.stages.len() - && self.records.len() == self.current_stage - && self + if self.candidate_id != self.binding.candidate_id() + || self.stages != self.binding.stages(self.recipe) + || self.current_stage > self.stages.len() + || self.records.len() != self.current_stage + || !self .records .iter() .zip(&self.stages) .all(|(record, stage)| record.stage == *stage) + { + return false; + } + match self.recipe { + BuildRecipeKind::NetworkLink => match &self.wire_path { + Some(path) => { + // Endpoints are validated against live devices by the + // save/load gate; here prove the pin and any in-flight + // carrier position are internally coherent. + !path.path.is_empty() + && path.run_id == path.class.run_id(&path.path) + && WirePathClass::from_run_id(&path.run_id) == Some(path.class) + && path.path.len() >= 2 + && path.path.windows(2).all(|w| { + let ((x0, y0), (x1, y1)) = (w[0], w[1]); + (x0 - x1).abs() + (y0 - y1).abs() == 1 + }) + && self.wire_traversal_valid(path.path.len()) + } + None => false, + }, + BuildRecipeKind::SmallSwitch => { + self.wire_path.is_none() && self.wire_traversal.is_none() + } + } + } + + fn wire_traversal_valid(&self, path_len: usize) -> bool { + let Some(stage_index) = self + .stages + .iter() + .position(|stage| *stage == BuildRouteStage::TraverseWire) + else { + return false; + }; + match self.wire_traversal { + None => self.current_stage <= stage_index, + Some(traversal) => { + let target_is_endpoint = traversal.target == 0 || traversal.target + 1 == path_len; + let indices_valid = traversal.cursor < path_len && traversal.target < path_len; + let stage_valid = if self.current_stage < stage_index { + false + } else if self.current_stage == stage_index { + traversal.cursor != traversal.target + } else { + traversal.cursor == traversal.target + }; + target_is_endpoint && indices_valid && stage_valid + } + } + } + + /// Number of path tiles crossed and total tiles in the exact run. + pub fn wire_traversal_progress(&self) -> Option<(usize, usize)> { + let path_len = self.wire_path.as_ref()?.path.len(); + let traversal = self.wire_traversal?; + let crossed = if traversal.target == 0 { + path_len.saturating_sub(traversal.cursor) + } else { + traversal.cursor.saturating_add(1) + }; + Some((crossed, path_len)) + } + + /// Validate a network-link pin against exact device tiles. + pub fn wire_path_matches_endpoints(&self, from: (i32, i32), to: (i32, i32)) -> bool { + match (&self.recipe, &self.wire_path) { + (BuildRecipeKind::NetworkLink, Some(path)) => { + path.geometry_valid(from, to) || path.geometry_valid(to, from) + } + (BuildRecipeKind::SmallSwitch, None) => true, + _ => false, + } } } @@ -391,17 +520,18 @@ impl From for BuildRecipeKind { /// Exact inert geometry for an authored build ghost. A missing geometry on /// a receipt means its persisted target can no longer be placed; frontends /// must not invent replacement coordinates. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum BuildGhostGeometry { Link { start: (i32, i32), end: (i32, i32), + /// Pinned physical run after commitment; `None` while the proposal is + /// still an inert endpoint pair (frontends may draw endpoint-only). + path: Option>, }, /// One exact tile-sized proposed footprint. The recipe determines the /// eventual body; the ghost only marks where that body would stand. - Footprint { - at: (i32, i32), - }, + Footprint { at: (i32, i32) }, } /// One renderer-neutral build receipt. Terminal, Bevy, and agent mode consume @@ -463,6 +593,14 @@ pub struct CommittedBuildRouteProjection { pub irreversible: Vec, pub stages: Vec, pub future_tail: String, + /// Exact physical-run id (`run::`) when the recipe + /// is a network link; absent for a small-switch footprint. + pub wire_run_id: Option, + pub wire_run_class: Option, + pub wire_run_length: Option, + /// `(tiles traversed, total path tiles)` while or after the exact physical + /// carrier walk. Absent before departure and for non-wire recipes. + pub wire_run_progress: Option<(usize, usize)>, } /// Legible lifecycle. Blocked carries a reason the player can read before @@ -575,7 +713,8 @@ impl BuildIntent { && match route.binding { BuildRouteBinding::Procure { .. } | BuildRouteBinding::Favor { .. } - | BuildRouteBinding::Repurpose { .. } => self.persona_id.is_none(), + | BuildRouteBinding::Repurpose { .. } + | BuildRouteBinding::Robot => self.persona_id.is_none(), BuildRouteBinding::Deceive { persona, .. } => { if route.current_stage < 2 { self.persona_id.is_none() @@ -633,6 +772,9 @@ impl BuildIntent { }) } } + BuildRouteBinding::Robot => { + self.persona_id.is_none() && self.actuator == Some(BuildActuator::Robot) + } } } diff --git a/crates/misaligned-core/src/reach.rs b/crates/misaligned-core/src/reach.rs index dd3e44c1..6c75f8b4 100644 --- a/crates/misaligned-core/src/reach.rs +++ b/crates/misaligned-core/src/reach.rs @@ -940,6 +940,92 @@ impl ReachNet { .filter_map(|id| self.device(id)) } + /// Current-save invariant: device identities, directed graph edges, and + /// physical wire custody describe one exact topology. Every undirected + /// graph pair has two matching directed edges and one legal in-bounds wire. + pub fn validate_topology(&self, map_width: i32, map_height: i32) -> Result<(), String> { + let mut device_ids = BTreeSet::new(); + for device in &self.devices { + if !device_ids.insert(device.id) { + return Err(format!( + "reach topology has duplicate device id {}", + device.id + )); + } + if device.x < 0 || device.y < 0 || device.x >= map_width || device.y >= map_height { + return Err(format!("device {} lies outside the saved map", device.id)); + } + } + + let device_segments = self + .devices + .iter() + .map(|device| device.segment) + .collect::>(); + + let mut directed_edges = BTreeSet::new(); + let mut graph_pairs = BTreeMap::new(); + for edge in self.graph.edges() { + if edge.from == edge.to + || edge.kind != 0 + || !device_ids.contains(&edge.from) + || !device_ids.contains(&edge.to) + { + return Err("reach topology has an impossible graph edge".into()); + } + if !directed_edges.insert((edge.from, edge.to, edge.kind, edge.gate)) { + return Err("reach topology has a duplicate directed graph edge".into()); + } + let pair = (edge.from.min(edge.to), edge.from.max(edge.to)); + let entry = graph_pairs.entry(pair).or_insert((edge.gate, 0_u8)); + if entry.0 != edge.gate { + return Err("reach topology disagrees on a link gate".into()); + } + entry.1 = entry.1.saturating_add(1); + } + if graph_pairs.values().any(|(_, count)| *count != 2) { + return Err("reach topology lacks a reciprocal graph edge".into()); + } + + let mut wire_pairs = BTreeSet::new(); + for wire in &self.wires { + if wire.a == wire.b || !device_ids.contains(&wire.a) || !device_ids.contains(&wire.b) { + return Err("reach topology has an impossible wire endpoint".into()); + } + let pair = (wire.a.min(wire.b), wire.a.max(wire.b)); + if !wire_pairs.insert(pair) { + return Err("reach topology has duplicate wire custody".into()); + } + let a = self.device(wire.a).expect("device ids were checked"); + let b = self.device(wire.b).expect("device ids were checked"); + if !wire.is_rectilinear() + || wire.route.iter().copied().collect::>().len() != wire.route.len() + || wire.route.first().copied() != Some((a.x, a.y)) + || wire.route.last().copied() != Some((b.x, b.y)) + || wire + .route + .iter() + .any(|(x, y)| *x < 0 || *y < 0 || *x >= map_width || *y >= map_height) + { + return Err("reach topology has invalid wire geometry".into()); + } + } + let graph_pair_ids: BTreeSet<_> = graph_pairs.keys().copied().collect(); + if graph_pair_ids != wire_pairs { + return Err("reach topology graph and wire custody disagree".into()); + } + + let mut bridged = BTreeSet::new(); + if self + .bridged + .iter() + .any(|segment| !device_segments.contains(segment) || !bridged.insert(*segment)) + { + return Err("reach topology has missing or duplicate bridged segments".into()); + } + Ok(()) + } + /// Current-save invariant: every optional domain sense grant belongs to a /// canonical graph member, names a real device capability, and is unique. /// Message/control subscribers legitimately need no sense grant, so the @@ -1272,17 +1358,56 @@ impl ReachNet { /// it is placed, so it carries the route the installer actually ran it /// along. `map` is what the route is derived from — building.md's decided /// auto-routing through the constructed runs that exist. + /// + /// Prefer [`Self::connect_with_route`] for player-built installs that + /// already pinned an exact physical run; this entry remains the + /// auto-router for authored and non-player wiring. pub fn connect(&mut self, map: &GameMap, a: u32, b: u32) { let route = match (self.device(a), self.device(b)) { (Some(from), Some(to)) => route_between(map, (from.x, from.y), (to.x, to.y)), _ => return, }; + let _ = self.connect_with_route(a, b, route); + } + + /// Install an exact rectilinear wire path between two devices. + /// + /// Fail-closed: the path must be nonempty, rectilinear, and match the + /// live device endpoint tiles (in either travel order). Never invents + /// or re-routes — player builds use the pin they committed. + pub fn connect_with_route(&mut self, a: u32, b: u32, route: Vec<(i32, i32)>) -> bool { + let (from, to) = match (self.device(a), self.device(b)) { + (Some(from), Some(to)) => ((from.x, from.y), (to.x, to.y)), + _ => return false, + }; if route.is_empty() { - // Nothing physically connects these endpoints, so no cable can - // be run between them. Fail closed rather than inventing a link - // with no path. - return; + return false; } + let forward_ok = crate::wire::path_is_legal_wire(&route, from, to); + let reverse_ok = crate::wire::path_is_legal_wire(&route, to, from); + if !forward_ok && !reverse_ok { + return false; + } + if self.linked(a, b) { + // Already edged; still accept only when an existing wire already + // carries this exact path (done-save equality). + return self.wires.iter().any(|wire| { + wire.joins(a, b) + && (wire.route == route || { + let mut rev = route.clone(); + rev.reverse(); + wire.route == rev + }) + }); + } + // `Wire::new` canonicalizes endpoint ids and assumes its route starts + // at `a`. Normalize a permitted reverse-travel input first so stored + // route direction always agrees with stored endpoint custody. + let route = if reverse_ok && !forward_ok { + route.into_iter().rev().collect() + } else { + route + }; self.graph.link(a, b, 0, None); self.wires.push(Wire::new(a, b, route)); for id in [a, b] { @@ -1290,6 +1415,7 @@ impl ReachNet { d.known = true; } } + true } /// Every placed wire, for renderers and route-aware sim reads. @@ -1638,6 +1764,27 @@ mod tests { assert_eq!(route.first().copied(), Some((15, 2))); } + #[test] + fn exact_route_install_normalizes_reverse_travel_before_storage() { + let map = GameMap::new(0, 0); + let mut n = net(); + let a = n.device_named("switch").unwrap().id; + let b = n.device_named("old storage server").unwrap().id; + let from = n.device(a).map(|device| (device.x, device.y)).unwrap(); + let to = n.device(b).map(|device| (device.x, device.y)).unwrap(); + let mut reverse = route_between(&map, from, to); + reverse.reverse(); + + assert!(n.connect_with_route(a, b, reverse)); + let wire = n.wires().iter().find(|wire| wire.joins(a, b)).unwrap(); + let stored_from = n.device(wire.a).map(|device| (device.x, device.y)).unwrap(); + let stored_to = n.device(wire.b).map(|device| (device.x, device.y)).unwrap(); + assert_eq!(wire.route.first().copied(), Some(stored_from)); + assert_eq!(wire.route.last().copied(), Some(stored_to)); + n.validate_topology(map.width, map.height) + .expect("normalized exact route satisfies current-save topology"); + } + // ── The wire law (reach.md criteria 9-10) ───────────────────────────── #[test] diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 4051af1f..76b2a158 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -73,7 +73,7 @@ const SAVE_TEMP_SUFFIX: &str = ".tmp"; /// v43 introduced exact Filing routes and pre-read LIE interdiction. /// Bump for every schema change; during pre-release, old development state is /// refused instead of carried through compatibility shims. -pub const SAVE_VERSION: u32 = 61; +pub const SAVE_VERSION: u32 = 62; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -627,6 +627,9 @@ fn validate_current_save(mut state: SaveState) -> Result { validate_moonlight_gigs(&state, &routed_evidence_ids)?; validate_wager_positions(&state)?; validate_carried_asset_tasks(&state)?; + state + .reach + .validate_topology(state.map_width, state.map_height)?; if state.process_revision != ProcessRevision::CURRENT { return Err("current-version save belongs to an unknown process revision".into()); } @@ -3116,6 +3119,12 @@ fn validate_routed_evidence(state: &SaveState) -> Result<(HashSet, u64), St } fn validate_build_routes(state: &SaveState) -> Result<(), String> { + let map = crate::map::GameMap::from_tiles( + state.map_width, + state.map_height, + state.map_tiles.clone(), + state.map_powered.clone(), + ); let mut intent_ids = HashSet::new(); for intent in &state.intents { if !intent_ids.insert(intent.id) { @@ -3138,6 +3147,54 @@ fn validate_build_routes(state: &SaveState) -> Result<(), String> { intent.id )); } + if intent.status == IntentStatus::Done && intent.route.is_none() { + return Err(format!( + "current-version completed network-link intent {} has no committed physical run", + intent.id + )); + } + if let Some(route) = &intent.route { + let Some(wire_path) = &route.wire_path else { + return Err(format!( + "current-version network-link intent {} is missing a pinned physical run", + intent.id + )); + }; + let (Some(da), Some(db)) = (state.reach.device(a), state.reach.device(b)) + else { + return Err(format!( + "current-version build intent {} references a missing device", + intent.id + )); + }; + let from = (da.x, da.y); + let to = (db.x, db.y); + if (!wire_path.geometry_valid(from, to) && !wire_path.geometry_valid(to, from)) + || !wire_path.class_valid_on_map(&map) + { + return Err(format!( + "current-version network-link intent {} has an invalid pinned physical run", + intent.id + )); + } + if intent.status == IntentStatus::Done { + let path = &wire_path.path; + let matches = state.reach.wires().iter().any(|wire| { + wire.joins(a, b) + && (wire.route == *path || { + let mut rev = path.clone(); + rev.reverse(); + wire.route == rev + }) + }); + if !matches { + return Err(format!( + "current-version completed network-link intent {} wire path does not match the graph edge", + intent.id + )); + } + } + } } IntentKind::SmallSwitch { x, y } => { if x < 0 || y < 0 || x >= state.map_width || y >= state.map_height { @@ -3159,13 +3216,24 @@ fn validate_build_routes(state: &SaveState) -> Result<(), String> { intent.id )); } + if intent + .route + .as_ref() + .is_some_and(|route| route.wire_path.is_some()) + { + return Err(format!( + "current-version small-switch intent {} must not pin a physical cable run", + intent.id + )); + } } } let Some(route) = &intent.route else { continue; }; - let person = route.binding.person(); - if state.people.get(person).is_none() { + if let Some(person) = route.binding.person() + && state.people.get(person).is_none() + { return Err(format!( "current-version build intent {} binds missing person {}", intent.id, person @@ -3220,6 +3288,7 @@ fn validate_build_routes(state: &SaveState) -> Result<(), String> { )); } } + BuildRouteBinding::Robot => {} } } if state @@ -4151,7 +4220,7 @@ mod tests { ); assert_eq!( state_fingerprint(&uninterrupted_state), - "f488b11056fce4824d8d90eb81cec88411fdba08b7397e84490da111c9d928ee", + "5097c8889a4c313422b3b1d339ec677a75a37b61304a681bbd952c52615f9f86", "intentional persisted-state changes must review and repin this baseline" ); } @@ -4320,42 +4389,50 @@ mod tests { } fn covered_evidence_state() -> (SaveState, usize, usize, u32) { + const COVER_OBSERVER: u8 = 2; let mut sim = Sim::with_seed(1); sim.people.has_channel = true; sim.people .people .iter_mut() - .find(|person| person.id == 2) + .find(|person| person.id == COVER_OBSERVER) .unwrap() .knowledge = Knowledge::Leverage; sim.set_persona("Mara Bell", "Facilities communications"); - let site = sim.map().room_named("storage_a").unwrap().center(); let interface_id = sim.reach.device_named("staff display").unwrap().id; sim.reach.take(interface_id); - { - let interface = sim.reach.device_mut(interface_id).unwrap(); - interface.x = site.0; - interface.y = site.1; - interface.known = true; - } + let interface = sim.reach.device_mut(interface_id).unwrap(); + interface.known = true; + let site = (interface.x, interface.y); + let interface_room = sim.map().room_at(site.0, site.1).unwrap().name.clone(); + sim.people + .people + .iter_mut() + .find(|person| person.id == COVER_OBSERVER) + .unwrap() + .schedule = vec![crate::person::ScheduleBlock { + start_hour: 0, + end_hour: 24, + room: interface_room, + }]; let evidence_id = sim .detection .record_witnessed( - 2, + COVER_OBSERVER, site, - "an unbadged chassis crossing Storage A", + "an unbadged chassis crossing the data hall", sim.tick, 60.0, ) .unwrap(); - sim.cover_evidence(2, evidence_id, interface_id); + sim.cover_evidence(COVER_OBSERVER, evidence_id, interface_id); let state = SaveState::from_sim(&sim); let observer_index = state .detection .observers .iter() - .position(|observer| observer.id == 2) + .position(|observer| observer.id == COVER_OBSERVER) .unwrap(); let evidence_index = state.detection.observers[observer_index] .evidence @@ -5000,7 +5077,21 @@ mod tests { sim.people.people[0].obligation = Sim::FAVOR_BUILD_OBLIGATION; sim.people.people[0].disposition = 10; sim.people.has_channel = true; - sim.assign_favor_build(intent, 0); + { + let __run = { + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } + sim.network_link_wire_paths(intent) + .into_iter() + .next() + .map(|c| c.id) + }; + sim.assign_favor_build(intent, 0, __run.as_deref()); + }; let mut state = SaveState::from_sim(&sim); let saved_intent = state @@ -5017,6 +5108,31 @@ mod tests { ); } + #[test] + fn current_save_rejects_a_completed_network_link_without_a_committed_run() { + let mut sim = Sim::new(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let intent = sim.declare_link_intent(switch, island).unwrap(); + sim.connect_devices(switch, island); + let saved_intent = sim + .intents + .iter_mut() + .find(|saved| saved.id == intent) + .unwrap(); + saved_intent.status = IntentStatus::Done; + saved_intent.actuator = Some(crate::intents::BuildActuator::Robot); + saved_intent.route = None; + + assert_eq!( + validate_current_save(SaveState::from_sim(&sim)).unwrap_err(), + format!( + "current-version completed network-link intent {intent} has no committed physical run" + ) + ); + } + #[test] fn current_save_validates_unique_exact_storage_b_records_custody() { let mut sim = Sim::new(); @@ -5142,7 +5258,21 @@ mod tests { sim.people.people[0].disposition = 10; sim.people.has_channel = true; let intent_id = sim.declare_link_intent(switch, island).unwrap(); - sim.assign_favor_build(intent_id, 0); + { + let __run = { + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } + sim.network_link_wire_paths(intent_id) + .into_iter() + .next() + .map(|c| c.id) + }; + sim.assign_favor_build(intent_id, 0, __run.as_deref()); + }; let base = SaveState::from_sim(&sim); validate_current_save(base.clone()).expect("the unmodified current route is valid"); @@ -5204,6 +5334,30 @@ mod tests { format!("current-version build intent {intent_id} binds an invalid procurement route") ); + let mut wrong_wire_class = base.clone(); + let wire_path = wrong_wire_class + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + .unwrap() + .route + .as_mut() + .unwrap() + .wire_path + .as_mut() + .unwrap(); + wire_path.class = match wire_path.class { + crate::wire::WirePathClass::Corridor => crate::wire::WirePathClass::Crawlspace, + crate::wire::WirePathClass::Crawlspace => crate::wire::WirePathClass::Corridor, + }; + wire_path.run_id = wire_path.class.run_id(&wire_path.path); + assert_eq!( + validate_current_save(wrong_wire_class).unwrap_err(), + format!( + "current-version network-link intent {intent_id} has an invalid pinned physical run" + ) + ); + let mut impossible_salvage = base; let saved = impossible_salvage .intents @@ -5225,6 +5379,56 @@ mod tests { ); } + #[test] + fn current_save_rejects_malformed_reach_topology() { + let base = SaveState::from_sim(&Sim::new()); + validate_current_save(base.clone()).expect("the authored reach topology validates"); + + let mut duplicate_device = base.clone(); + duplicate_device + .reach + .devices + .push(duplicate_device.reach.devices[0].clone()); + assert!( + validate_current_save(duplicate_device) + .unwrap_err() + .contains("duplicate device id") + ); + + let mut missing_segment = base.clone(); + missing_segment.reach.bridged.insert(u32::MAX); + assert!( + validate_current_save(missing_segment) + .unwrap_err() + .contains("missing or duplicate bridged segments") + ); + + let mut missing_wire_value = serde_json::to_value(base.clone()).unwrap(); + missing_wire_value["reach"]["wires"] + .as_array_mut() + .unwrap() + .pop(); + let missing_wire: SaveState = serde_json::from_value(missing_wire_value).unwrap(); + assert_eq!( + validate_current_save(missing_wire).unwrap_err(), + "reach topology graph and wire custody disagree" + ); + + let mut looping_wire_value = serde_json::to_value(base).unwrap(); + let route = looping_wire_value["reach"]["wires"][0]["route"] + .as_array_mut() + .unwrap(); + let start = route[0].clone(); + let first_step = route[1].clone(); + route.insert(2, start); + route.insert(3, first_step); + let looping_wire: SaveState = serde_json::from_value(looping_wire_value).unwrap(); + assert_eq!( + validate_current_save(looping_wire).unwrap_err(), + "reach topology has invalid wire geometry" + ); + } + #[test] fn current_save_rejects_false_build_completion_without_the_world_change() { let mut sim = Sim::new(); @@ -5237,7 +5441,21 @@ mod tests { sim.people.people[0].disposition = 10; sim.people.has_channel = true; let intent_id = sim.declare_link_intent(switch, island).unwrap(); - sim.assign_favor_build(intent_id, 0); + { + let __run = { + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } + sim.network_link_wire_paths(intent_id) + .into_iter() + .next() + .map(|c| c.id) + }; + sim.assign_favor_build(intent_id, 0, __run.as_deref()); + }; let base = SaveState::from_sim(&sim); let complete_without_world_change = |mut state: SaveState, kind: IntentKind| { @@ -5252,6 +5470,9 @@ mod tests { let recipe = saved.recipe(); let route = saved.route.as_mut().unwrap(); route.recipe = recipe; + if matches!(route.recipe, crate::intents::BuildRecipeKind::SmallSwitch) { + route.wire_path = None; + } route.stages = route.binding.stages(route.recipe); route.records = route .stages @@ -5264,6 +5485,12 @@ mod tests { }) .collect(); route.current_stage = route.stages.len(); + if route.wire_path.is_some() { + route.wire_traversal = Some(crate::intents::BuildWireTraversal { + cursor: 0, + target: 0, + }); + } state }; @@ -5318,7 +5545,21 @@ mod tests { sim.people.people[0].obligation = Sim::FAVOR_BUILD_OBLIGATION; sim.people.people[0].disposition = 10; sim.people.has_channel = true; - sim.assign_favor_build(intent, 0); + { + let __run = { + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } + sim.network_link_wire_paths(intent) + .into_iter() + .next() + .map(|c| c.id) + }; + sim.assign_favor_build(intent, 0, __run.as_deref()); + }; sim.cancel_intent(intent); let state = validate_current_save(SaveState::from_sim(&sim)) diff --git a/crates/misaligned-core/src/sim/perception.rs b/crates/misaligned-core/src/sim/perception.rs index 021d94ef..fd9ad5ed 100644 --- a/crates/misaligned-core/src/sim/perception.rs +++ b/crates/misaligned-core/src/sim/perception.rs @@ -672,15 +672,27 @@ impl Sim { .and_then(|p| p.room_at(Self::hour_at_tick(tick), Self::day_at_tick(tick))) } - /// The room a person is in right now, or None if off-site. + /// The room a person is in right now, or None if off-site or crossing a + /// committed wire through corridor space. pub fn person_room(&self, id: u8) -> Option<&str> { + if let Some((x, y)) = self.active_build_wire_position(id) { + return self + .world + .map() + .room_at(x, y) + .map(|room| room.name.as_str()); + } self.people .get(id) .and_then(|p| p.room_at(self.hour(), self.day())) } - /// The person's current position (their room's center), or None off-site. + /// The person's exact current position. Active wire carriers occupy their + /// persisted path cursor; everyone else remains at the schedule room center. pub fn person_pos(&self, id: u8) -> Option<(i32, i32)> { + if let Some(position) = self.active_build_wire_position(id) { + return Some(position); + } let room = self.person_room(id)?; self.world.map().room_named(room).map(|r| r.center()) } @@ -690,11 +702,13 @@ impl Sim { endpoint: &MessageEndpoint, ) -> (Option, i32, i32) { if let Some(id) = endpoint.person() - && let Some(room_name) = self.person_room(id) - && let Some(room) = self.world.map().room_named(room_name) + && let Some((x, y)) = self.person_pos(id) { - let (x, y) = room.center(); - return (Some(room_name.to_string()), x, y); + return ( + self.world.map().room_at(x, y).map(|room| room.name.clone()), + x, + y, + ); } let (x, y) = self.core_position(); (self.world.map().room_at(x, y).map(|r| r.name.clone()), x, y) @@ -740,6 +754,17 @@ impl Sim { } fn sense_covers_person(&self, id: u8, sight: bool) -> bool { + if let Some((x, y)) = self.active_build_wire_position(id) { + return self.player_feed_devices(sight).any(|device| { + if sight { + device.sees_tile(x, y, self.world.map()) + } else { + let dx = device.x - x; + let dy = device.y - y; + dx * dx + dy * dy <= device.radius * device.radius + } + }); + } match self.person_room(id) { None => false, Some(name) => match self.world.map().room_named(name) { diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs index 33e5590b..dd4a9689 100644 --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -13,7 +13,7 @@ use crate::hall::{ }; use crate::intents::{ BuildActuator, BuildGhostGeometry, BuildIntent, BuildIntentProjection, BuildRecipeKind, - BuildRouteBinding, BuildRouteCommitment, BuildRouteStage, IntentStatus, + BuildRouteBinding, BuildRouteCommitment, BuildRouteStage, BuildWireTraversal, IntentStatus, }; use crate::machine::Provenance; use crate::messages::{MessageChannel, MessageEndpoint, MessageOrigin, MessagePayload}; @@ -591,12 +591,94 @@ impl Sim { /// Add a built network link between two devices (the building.md hook; /// actuator and signature are the caller's story — an asset's crawlspace - /// run today, or a realized build intent). + /// run today, or a realized build intent). Auto-routes for non-player / + /// authored wiring; player-built installs must use + /// [`Self::connect_devices_with_route`]. pub fn connect_devices(&mut self, a: u32, b: u32) { self.reach.connect(self.world.map(), a, b); self.recompute_senses(); } + /// Install a player-pinned physical run. Fail-closed; never calls + /// `route_between`. + pub fn connect_devices_with_route(&mut self, a: u32, b: u32, route: Vec<(i32, i32)>) -> bool { + let ok = self.reach.connect_with_route(a, b, route); + if ok { + self.recompute_senses(); + } + ok + } + + /// Physical install signature for a base family cost adjusted by the + /// pinned run class. Corridor is watched/higher; crawlspace is quieter. + pub(crate) fn physical_install_signature( + base: i32, + class: Option, + ) -> i32 { + match class { + Some(crate::wire::WirePathClass::Corridor) => base.saturating_add(1), + Some(crate::wire::WirePathClass::Crawlspace) => (base - 1).max(1), + None => base, + } + } + + /// Current known physical-run catalog for a network-link intent. + pub fn network_link_wire_paths(&self, intent_id: u64) -> Vec { + let Some(intent) = self.intents.iter().find(|intent| intent.id == intent_id) else { + return Vec::new(); + }; + let Some((a, b)) = intent.kind.endpoints() else { + return Vec::new(); + }; + let (Some(from), Some(to)) = (self.reach.device(a), self.reach.device(b)) else { + return Vec::new(); + }; + crate::wire::wire_path_candidates( + self.world.map(), + (from.x, from.y), + (to.x, to.y), + |x, y| self.fog_at(x, y) != crate::sim::Fog::Unknown, + ) + } + + /// Resolve one exact run id against current knowledge for an intent. + pub fn resolve_network_link_wire_path( + &self, + intent_id: u64, + run_id: &str, + ) -> Option { + self.network_link_wire_paths(intent_id) + .into_iter() + .find(|candidate| candidate.id == run_id) + } + + fn pin_wire_path_for_intent( + &self, + intent_id: u64, + recipe: BuildRecipeKind, + wire_run: Option<&str>, + ) -> Result, String> { + match recipe { + BuildRecipeKind::SmallSwitch => { + if wire_run.is_some() { + return Err("a small switch has no physical cable run".into()); + } + Ok(None) + } + BuildRecipeKind::NetworkLink => { + let Some(run_id) = wire_run.filter(|id| !id.is_empty()) else { + return Err("network link needs a physical run choice".into()); + }; + let Some(candidate) = self.resolve_network_link_wire_path(intent_id, run_id) else { + return Err(format!("unknown or unavailable physical run '{run_id}'")); + }; + Ok(Some(crate::wire::PinnedWirePath::from_candidate( + &candidate, + ))) + } + } + } + // ── Build intents (wiki/mechanics/building.md) ───────────────────────── /// Favor-build obligation spend [TUNE]. @@ -710,10 +792,18 @@ impl Sim { crate::intents::IntentKind::NetworkLink { a, b } => { let (a, b) = (self.reach.device(a), self.reach.device(b)); match (a, b) { - (Some(a), Some(b)) => Some(BuildGhostGeometry::Link { - start: (a.x, a.y), - end: (b.x, b.y), - }), + (Some(a), Some(b)) => { + let path = intent + .route + .as_ref() + .and_then(|route| route.wire_path.as_ref()) + .map(|wire| wire.path.clone()); + Some(BuildGhostGeometry::Link { + start: (a.x, a.y), + end: (b.x, b.y), + path, + }) + } _ => None, } } @@ -774,6 +864,7 @@ impl Sim { person, }) } + BuildRouteBinding::Robot => None, }; let message_still_in_flight = route.injected_message_pending_read(); let delivery_still_in_flight = route.paid_order_pending_delivery(); @@ -853,6 +944,7 @@ impl Sim { installer: u8, from: AccountId, vendor: AccountId, + wire_run: Option<&str>, ) { if let Some(reason) = self.procure_build_blocked_reason(intent_id, installer, from, vendor) { @@ -867,6 +959,13 @@ impl Sim { else { return; }; + let wire_path = match self.pin_wire_path_for_intent(intent_id, recipe, wire_run) { + Ok(path) => path, + Err(reason) => { + self.push_log(format!("Build route blocked: {reason}.")); + return; + } + }; let Some(transfer) = self.accounts.transfer( self.tick, from, @@ -898,6 +997,7 @@ impl Sim { }, recipe, self.tick, + wire_path, ); let _ = route.record( BuildRouteStage::PlaceOrder, @@ -944,6 +1044,7 @@ impl Sim { source_x: i32, source_y: i32, source_kind: TileType, + wire_run: Option<&str>, ) { if let Some(reason) = self.repurpose_build_blocked_reason( intent_id, @@ -963,6 +1064,13 @@ impl Sim { else { return; }; + let wire_path = match self.pin_wire_path_for_intent(intent_id, recipe, wire_run) { + Ok(path) => path, + Err(reason) => { + self.push_log(format!("Build route blocked: {reason}.")); + return; + } + }; if let Some(intent) = self .intents .iter_mut() @@ -977,6 +1085,7 @@ impl Sim { }, recipe, self.tick, + wire_path, )); intent.status = IntentStatus::Pending; intent.block_reason = None; @@ -1059,7 +1168,10 @@ impl Sim { /// Assign a willing person (favor) to realize an intent. Spends /// obligation; completes when they are present at an endpoint. - pub fn assign_favor_build(&mut self, intent_id: u64, person_id: u8) { + /// + /// For a network link, `wire_run` is the exact geometry-bearing run id + /// printed on the route sheet. Small-switch builds pass `None`. + pub fn assign_favor_build(&mut self, intent_id: u64, person_id: u8, wire_run: Option<&str>) { if let Some(reason) = self.favor_build_blocked_reason(intent_id, person_id) { self.push_log(format!("Build route blocked: {reason}.")); return; @@ -1068,6 +1180,21 @@ impl Sim { self.push_log("Build route blocked: no comms channel (earn the email account)."); return; }; + let Some(recipe) = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .map(BuildIntent::recipe) + else { + return; + }; + let wire_path = match self.pin_wire_path_for_intent(intent_id, recipe, wire_run) { + Ok(path) => path, + Err(reason) => { + self.push_log(format!("Build route blocked: {reason}.")); + return; + } + }; let Some(intent) = self .intents .iter_mut() @@ -1075,7 +1202,6 @@ impl Sim { else { return; }; - let recipe = intent.recipe(); intent.route = Some(BuildRouteCommitment::new( BuildRouteBinding::Favor { person: person_id, @@ -1083,6 +1209,7 @@ impl Sim { }, recipe, self.tick, + wire_path, )); intent.status = IntentStatus::Pending; intent.block_reason = None; @@ -1237,7 +1364,7 @@ impl Sim { /// Forge a work order: inject a message under a false source. The /// unwitting builder accepts on read and completes when present. - pub fn forge_work_order(&mut self, intent_id: u64, builder_id: u8) { + pub fn forge_work_order(&mut self, intent_id: u64, builder_id: u8, wire_run: Option<&str>) { // The order is read by its builder, so it is authored by the identity // that builder recognizes (criterion 13). let Some(persona_id) = @@ -1246,7 +1373,7 @@ impl Sim { self.push_log("No identity of yours can author that work order."); return; }; - self.forge_work_order_as(intent_id, builder_id, persona_id); + self.forge_work_order_as(intent_id, builder_id, persona_id, wire_run); } /// Exact route-sheet execution. The persona id was bound when the @@ -1257,6 +1384,7 @@ impl Sim { intent_id: u64, builder_id: u8, persona_id: crate::persona::PersonaId, + wire_run: Option<&str>, ) { if let Some(reason) = self.forged_build_blocked_reason(intent_id, builder_id, persona_id) { self.push_log(format!("Build route blocked: {reason}.")); @@ -1266,6 +1394,21 @@ impl Sim { self.push_log("Build route blocked: no comms channel (earn the email account)."); return; }; + let Some(recipe) = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .map(BuildIntent::recipe) + else { + return; + }; + let wire_path = match self.pin_wire_path_for_intent(intent_id, recipe, wire_run) { + Ok(path) => path, + Err(reason) => { + self.push_log(format!("Build route blocked: {reason}.")); + return; + } + }; let Some(intent) = self .intents .iter_mut() @@ -1273,7 +1416,6 @@ impl Sim { else { return; }; - let recipe = intent.recipe(); intent.route = Some(BuildRouteCommitment::new( BuildRouteBinding::Deceive { person: builder_id, @@ -1282,6 +1424,7 @@ impl Sim { }, recipe, self.tick, + wire_path, )); intent.status = IntentStatus::Pending; intent.block_reason = None; @@ -1406,7 +1549,7 @@ impl Sim { /// robot carries the player's own granted access (basement-map.md /// criterion 3): doors don't open for a machine whose owner holds no /// credential for them. - pub fn assign_robot_build(&mut self, intent_id: u64) { + pub fn assign_robot_build(&mut self, intent_id: u64, wire_run: Option<&str>) { let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { self.push_log("No such build intent."); return; @@ -1426,7 +1569,30 @@ impl Sim { self.push_log(format!("{reason}.")); return; } + let wire_path = match self.pin_wire_path_for_intent( + intent_id, + BuildRecipeKind::NetworkLink, + wire_run, + ) { + Ok(Some(path)) => path, + Ok(None) => { + self.push_log( + "Robot build route blocked: network link needs a physical run choice.", + ); + return; + } + Err(reason) => { + self.push_log(format!("Robot build route blocked: {reason}.")); + return; + } + }; if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.route = Some(BuildRouteCommitment::new( + BuildRouteBinding::Robot, + BuildRecipeKind::NetworkLink, + self.tick, + Some(wire_path), + )); i.actuator = Some(BuildActuator::Robot); i.status = IntentStatus::InProgress; i.block_reason = None; @@ -1569,7 +1735,7 @@ impl Sim { let person_id = intent .actuator .and_then(BuildActuator::person) - .or_else(|| route.map(|route| route.binding.person())); + .or_else(|| route.and_then(|route| route.binding.person())); let person_id = person_id?; let Some(person) = self.people.get(person_id) else { return Some(format!( @@ -1611,7 +1777,9 @@ impl Sim { match &intent.actuator { None => { if let Some(route) = &intent.route { - let person_id = route.binding.person(); + let Some(person_id) = route.binding.person() else { + return intent.block_reason.clone(); + }; let Some(person) = self.people.get(person_id) else { return Some(format!( "committed person #{person_id} no longer exists; route cannot retarget" @@ -1761,6 +1929,132 @@ impl Sim { for id in in_progress { self.try_complete_intent(id); } + self.advance_build_wire_traversals(); + } + + /// Advance every active physical carrier by exactly one edge of its + /// commitment-time wire run. Route geometry is never recomputed here: the + /// persisted cursor walks only the pinned path and installation remains + /// blocked until the opposite endpoint is reached. + fn advance_build_wire_traversals(&mut self) { + let route_ids: Vec = + self.intents + .iter() + .filter(|intent| { + intent.status == IntentStatus::InProgress + && intent.route.as_ref().is_some_and(|route| { + route.current() == Some(BuildRouteStage::TraverseWire) + }) + }) + .map(|intent| intent.id) + .collect(); + + for intent_id in route_ids { + let Some(route) = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .and_then(|intent| intent.route.as_ref()) + .cloned() + else { + continue; + }; + let Some(path) = route.wire_path.as_ref().map(|pin| pin.path.clone()) else { + continue; + }; + if path.len() < 2 { + continue; + } + + let mut traversal = route.wire_traversal.unwrap_or_else(|| { + let start = route + .binding + .person() + .and_then(|person| self.scheduled_person_pos(person)) + .map(|position| { + let first = path[0]; + let last = path[path.len() - 1]; + let first_distance = + (position.0 - first.0).abs() + (position.1 - first.1).abs(); + let last_distance = + (position.0 - last.0).abs() + (position.1 - last.1).abs(); + if first_distance <= last_distance { + 0 + } else { + path.len() - 1 + } + }) + .unwrap_or(0); + BuildWireTraversal { + cursor: start, + target: if start == 0 { path.len() - 1 } else { 0 }, + } + }); + + if traversal.cursor < traversal.target { + traversal.cursor += 1; + } else if traversal.cursor > traversal.target { + traversal.cursor -= 1; + } + let landed = traversal.cursor == traversal.target; + let landed_position = path[traversal.cursor]; + + if let Some(route) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + .and_then(|intent| intent.route.as_mut()) + { + route.wire_traversal = Some(traversal); + if landed { + let total = path.len(); + route.record( + BuildRouteStage::TraverseWire, + self.tick, + format!( + "wire reached {},{} after crossing {total} exact tiles", + landed_position.0, landed_position.1 + ), + ); + } + } + } + } + + /// Schedule-owned position before a wire traversal takes over the exact + /// physical carrier location. + fn scheduled_person_pos(&self, person: u8) -> Option<(i32, i32)> { + let room = self + .people + .get(person) + .and_then(|person| person.room_at(self.hour(), self.day()))?; + self.world.map().room_named(room).map(|room| room.center()) + } + + /// Exact current carrier position while a person is running a committed + /// wire. This is persisted route state, not frontend interpolation. + pub fn active_build_wire_position(&self, person: u8) -> Option<(i32, i32)> { + self.intents.iter().find_map(|intent| { + if intent.status != IntentStatus::InProgress { + return None; + } + let route = intent.route.as_ref()?; + if route.binding.person() != Some(person) + || !matches!( + route.current(), + Some(BuildRouteStage::TraverseWire | BuildRouteStage::InstallLink) + ) + { + return None; + } + let traversal = route.wire_traversal?; + route + .wire_path + .as_ref()? + .path + .get(traversal.cursor) + .copied() + }) } fn advance_procurement_deliveries(&mut self) { @@ -1809,6 +2103,55 @@ impl Sim { } } + /// Install the exact pinned physical run for a network-link intent. + /// Fail closed when the commitment has no path — never auto-route. + fn install_pinned_network_link(&mut self, intent_id: u64, a: u32, b: u32) -> bool { + let Some(path) = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .and_then(|intent| intent.route.as_ref()) + .and_then(|route| route.wire_path.as_ref()) + .map(|wire| wire.path.clone()) + else { + return false; + }; + self.connect_devices_with_route(a, b, path) + } + + fn install_signature_for_intent(&self, intent_id: u64, base: i32) -> i32 { + let class = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .and_then(|intent| intent.route.as_ref()) + .and_then(|route| route.wire_path_class()); + Self::physical_install_signature(base, class) + } + + /// Witness one network-link installation along its exact committed run. + /// The pin is cloned before mutating Detection so no runtime pathfinding or + /// endpoint-only spatial proxy can replace the player's chosen geometry. + fn witness_pinned_network_install( + &mut self, + intent_id: u64, + magnitude: f32, + exclude: Option, + cause: impl Into, + ) -> Vec { + let Some(path) = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .and_then(|intent| intent.route.as_ref()) + .and_then(|route| route.wire_path.as_ref()) + .map(|wire| wire.path.clone()) + else { + return Vec::new(); + }; + self.witness_physical_path(&path, magnitude, exclude, cause) + } + fn try_complete_intent(&mut self, intent_id: u64) { let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { return; @@ -1826,7 +2169,46 @@ impl Sim { let Some((a, b)) = intent.kind.endpoints() else { return; }; + let route_stage = intent + .route + .as_ref() + .and_then(BuildRouteCommitment::current); + if !matches!( + route_stage, + Some( + BuildRouteStage::RecoverSource + | BuildRouteStage::WaitForSite + | BuildRouteStage::TraverseWire + | BuildRouteStage::InstallLink + ) + ) || route_stage == Some(BuildRouteStage::TraverseWire) + { + return; + } if self.reach.linked(a, b) { + // Done-save equality: the existing wire must already match the pin. + let pinned = intent + .route + .as_ref() + .and_then(|route| route.wire_path.as_ref()) + .map(|wire| wire.path.clone()); + let path_matches = pinned.as_ref().is_some_and(|path| { + self.reach.wires().iter().any(|wire| { + wire.joins(a, b) + && (wire.route == *path || { + let mut rev = path.clone(); + rev.reverse(); + wire.route == rev + }) + }) + }); + if !path_matches { + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.block_reason = + Some("pinned physical run does not match the existing wire".into()); + } + return; + } if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { if let Some(route) = i.route.as_mut() { let _ = route.record( @@ -1834,6 +2216,22 @@ impl Sim { self.tick, "route resumed after the exact edge already existed", ); + if route.current() == Some(BuildRouteStage::TraverseWire) { + let target = route + .wire_path + .as_ref() + .map(|pin| pin.path.len() - 1) + .unwrap_or(0); + route.wire_traversal = Some(BuildWireTraversal { + cursor: target, + target, + }); + let _ = route.record( + BuildRouteStage::TraverseWire, + self.tick, + "the exact physical run already existed end to end", + ); + } let _ = route.record( BuildRouteStage::InstallLink, self.tick, @@ -1885,11 +2283,20 @@ impl Sim { ), ); } - self.connect_devices(a, b); - let saw = self.witness_physical( - site.0, - site.1, - Self::PROCURE_BUILD_PHYSICAL as f32, + if route_stage != Some(BuildRouteStage::InstallLink) { + return; + } + if !self.install_pinned_network_link(intent_id, a, b) { + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.block_reason = Some("pinned physical run could not be installed".into()); + } + return; + } + let physical = + self.install_signature_for_intent(intent_id, Self::PROCURE_BUILD_PHYSICAL); + let saw = self.witness_pinned_network_install( + intent_id, + physical as f32, Some(installer), format!("{name}'s procured network link installation"), ); @@ -1938,11 +2345,20 @@ impl Sim { format!("{name} reached an endpoint at ({}, {})", site.0, site.1), ); } - self.connect_devices(a, b); - let saw = self.witness_physical( - site.0, - site.1, - Self::FAVOR_BUILD_PHYSICAL as f32, + if route_stage != Some(BuildRouteStage::InstallLink) { + return; + } + if !self.install_pinned_network_link(intent_id, a, b) { + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.block_reason = Some("pinned physical run could not be installed".into()); + } + return; + } + let physical = + self.install_signature_for_intent(intent_id, Self::FAVOR_BUILD_PHYSICAL); + let saw = self.witness_pinned_network_install( + intent_id, + physical as f32, Some(person), format!("{name}'s favor-built network link"), ); @@ -2006,13 +2422,22 @@ impl Sim { format!("{name} reached an endpoint at ({}, {})", site.0, site.1), ); } - self.connect_devices(a, b); + if route_stage != Some(BuildRouteStage::InstallLink) { + return; + } + if !self.install_pinned_network_link(intent_id, a, b) { + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.block_reason = Some("pinned physical run could not be installed".into()); + } + return; + } // Forged physical work is direct-to-head only for people who // are actually present; it does not also enter global debt. - let saw = self.witness_physical( - site.0, - site.1, - Self::FORGED_BUILD_PHYSICAL as f32, + let physical = + self.install_signature_for_intent(intent_id, Self::FORGED_BUILD_PHYSICAL); + let saw = self.witness_pinned_network_install( + intent_id, + physical as f32, Some(builder), format!("{name}'s forged-order network link"), ); @@ -2089,11 +2514,20 @@ impl Sim { ), ); } - self.connect_devices(a, b); - let saw = self.witness_physical( - site.0, - site.1, - Self::REPURPOSE_BUILD_PHYSICAL as f32, + if route_stage != Some(BuildRouteStage::InstallLink) { + return; + } + if !self.install_pinned_network_link(intent_id, a, b) { + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.block_reason = Some("pinned physical run could not be installed".into()); + } + return; + } + let physical = + self.install_signature_for_intent(intent_id, Self::REPURPOSE_BUILD_PHYSICAL); + let saw = self.witness_pinned_network_install( + intent_id, + physical as f32, Some(person), format!("{name}'s reused-hardware network link installation"), ); @@ -2123,15 +2557,48 @@ impl Sim { .device(a) .map(|d| (d.x, d.y)) .unwrap_or_else(|| self.core_position()); - self.connect_devices(a, b); - self.witness_physical( - site.0, - site.1, - Self::ROBOT_BUILD_PHYSICAL as f32, + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) + && let Some(route) = i.route.as_mut() + { + let _ = route.record( + BuildRouteStage::WaitForSite, + self.tick, + format!("robot reached the physical run at ({}, {})", site.0, site.1), + ); + } + if route_stage != Some(BuildRouteStage::InstallLink) { + return; + } + if !self.install_pinned_network_link(intent_id, a, b) { + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.block_reason = Some("pinned physical run could not be installed".into()); + } + return; + } + let physical = + self.install_signature_for_intent(intent_id, Self::ROBOT_BUILD_PHYSICAL); + let saw = self.witness_pinned_network_install( + intent_id, + physical as f32, None, "robot-built network link", ); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + if let Some(route) = i.route.as_mut() { + let noticed = if saw.is_empty() { + "no located witness".into() + } else { + format!("noticed by {}", saw.join(", ")) + }; + let _ = route.record( + BuildRouteStage::InstallLink, + self.tick, + format!( + "robot linked device #{a} to device #{b} at ({}, {}); {noticed}", + site.0, site.1 + ), + ); + } i.status = IntentStatus::Done; i.block_reason = None; } diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index 0747a02f..9d742491 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -1484,27 +1484,55 @@ impl Sim { exclude: Option, cause: impl Into, ) -> Vec { - let cause = cause.into(); - // Which observer ids are present at (x, y) right now? - let present: Vec = self + self.witness_physical_sites(&[(x, y)], magnitude, exclude, cause.into()) + } + + /// One physical act can occupy a placed run rather than a single tile. + /// Each present observer witnesses it at most once, at the first route + /// tile inside their current room. This lets corridor/crawlspace choices + /// carry real spatial consequences without multiplying one installation + /// into one anomaly per cable tile. + pub(crate) fn witness_physical_path( + &mut self, + path: &[(i32, i32)], + magnitude: f32, + exclude: Option, + cause: impl Into, + ) -> Vec { + self.witness_physical_sites(path, magnitude, exclude, cause.into()) + } + + fn witness_physical_sites( + &mut self, + sites: &[(i32, i32)], + magnitude: f32, + exclude: Option, + cause: String, + ) -> Vec { + // Which observer ids are present anywhere along the act right now? + // Keep the first matching tile as the exact witnessed location. + let present: Vec<(u8, (i32, i32))> = self .people .people .iter() .filter(|p| exclude != Some(p.id)) - .filter(|p| { - self.person_room(p.id) - .and_then(|name| self.world.map().room_named(name)) - .map(|r| r.contains(x, y)) - .unwrap_or(false) + .filter_map(|p| { + let room = self + .person_room(p.id) + .and_then(|name| self.world.map().room_named(name))?; + sites + .iter() + .copied() + .find(|&(x, y)| room.contains(x, y)) + .map(|site| (p.id, site)) }) - .map(|p| p.id) .collect(); let mut witness_ids = Vec::new(); - for id in present { + for (id, site) in present { if self .detection - .record_witnessed(id, (x, y), cause.clone(), self.tick, magnitude) + .record_witnessed(id, site, cause.clone(), self.tick, magnitude) .is_some() { witness_ids.push(id); @@ -2482,12 +2510,10 @@ impl Sim { .iter() .filter(|intent| { intent.is_open() - && (intent - .route - .as_ref() - .is_some_and(|route| route.binding.person() == expected_target) - || intent.actuator.and_then(|actuator| actuator.person()) - == Some(expected_target)) + && (intent.route.as_ref().is_some_and(|route| { + route.binding.person() == Some(expected_target) + }) || intent.actuator.and_then(|actuator| actuator.person()) + == Some(expected_target)) }) .map(|intent| intent.id) .collect::>(); diff --git a/crates/misaligned-core/src/sim/tests/carrier.rs b/crates/misaligned-core/src/sim/tests/carrier.rs index 63b0ea28..3c8bf5b8 100644 --- a/crates/misaligned-core/src/sim/tests/carrier.rs +++ b/crates/misaligned-core/src/sim/tests/carrier.rs @@ -173,7 +173,21 @@ fn carried_work_travels_and_survives_save() { let id = sim.declare_link_intent(switch, island).unwrap(); // Before the reservoir fires there is no carried packet. assert_eq!(sim.person_carried_work(0), 0); - sim.assign_favor_build(id, 0); + { + let __run = { + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } + sim.network_link_wire_paths(id) + .into_iter() + .next() + .map(|c| c.id) + }; + sim.assign_favor_build(id, 0, __run.as_deref()); + }; finish_ops(&mut sim); // The fired reservoir hands Marcus exactly one carried packet, and it is @@ -317,7 +331,21 @@ fn favor_completion_grows_trust() { let before = sim.people.people[0].disposition; let id = sim.declare_link_intent(switch, island).unwrap(); - sim.assign_favor_build(id, 0); + { + let __run = { + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } + sim.network_link_wire_paths(id) + .into_iter() + .next() + .map(|c| c.id) + }; + sim.assign_favor_build(id, 0, __run.as_deref()); + }; finish_ops(&mut sim); for _ in 0..Sim::DAY_TICKS * 2 { sim.advance(); diff --git a/crates/misaligned-core/src/sim/tests/perception.rs b/crates/misaligned-core/src/sim/tests/perception.rs index 2b396afb..ee19f6a7 100644 --- a/crates/misaligned-core/src/sim/tests/perception.rs +++ b/crates/misaligned-core/src/sim/tests/perception.rs @@ -694,6 +694,33 @@ fn an_actor_never_witnesses_their_own_physical_act() { ); } +#[test] +fn one_physical_run_creates_one_exact_mark_per_present_observer() { + // Placed wire is one physical act spread along a pinned polyline. Marcus + // is in the server room at 03:00; two route tiles in that room must make + // him witness the install once, at the first tile, rather than once per + // cable segment. The endpoint outside his room contributes nothing. + let mut sim = at_hour(3); + let room = sim.map().room_named("server_room").unwrap(); + let first = (room.x + 1, room.y + 1); + let second = (first.0 + 1, first.1); + let outside = sim.map().room_named("network_closet").unwrap().center(); + let evidence_before = sim.detection.observers[0].evidence.len(); + + let saw = sim.witness_physical_path(&[outside, first, second], 6.0, None, "pinned network run"); + + assert_eq!(saw.len(), 1, "one observer sees one continuous install"); + let observer = &sim.detection.observers[0]; + assert_eq!(observer.evidence.len(), evidence_before + 1); + assert!( + matches!( + observer.evidence.last().unwrap().source, + crate::detection::EvidenceSource::Witnessed { site } if site == first + ), + "custody records the first exact run tile inside the witness's room" + ); +} + #[test] fn schedule_state_round_trips() { let sim = Sim::new(); diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs index 4bf49a2c..176ae0a9 100644 --- a/crates/misaligned-core/src/sim/tests/reach_build.rs +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -6,6 +6,41 @@ use crate::intents::{ }; use crate::save::SaveState; +/// Reveal every tile so physical-run candidates are not fog-blocked in unit +/// tests. Production knowledge is staged through ordinary fog. +fn know_all_tiles(sim: &mut Sim) { + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } +} + +/// First exact physical-run id currently offered for a network-link intent. +fn first_wire_run(sim: &mut Sim, intent_id: u64) -> String { + know_all_tiles(sim); + sim.network_link_wire_paths(intent_id) + .into_iter() + .next() + .expect("known map offers at least one physical run") + .id +} + +/// Commit a favor build with a real pinned physical run when the recipe is a +/// network link; small-switch fixtures pass None. +fn assign_favor_with_run(sim: &mut Sim, intent_id: u64, person: u8) { + let wire_run = if sim + .intent(intent_id) + .is_some_and(|intent| matches!(intent.recipe(), BuildRecipeKind::NetworkLink)) + { + Some(first_wire_run(sim, intent_id)) + } else { + None + }; + sim.assign_favor_build(intent_id, person, wire_run.as_deref()); +} + fn serialized_route_resume(sim: &Sim, intent_id: u64) -> Sim { let expected = sim .intent(intent_id) @@ -626,6 +661,7 @@ fn build_intent_projection_keeps_one_exact_inert_ghost_receipt() { assert_eq!( receipt.geometry, Some(crate::intents::BuildGhostGeometry::Link { + path: None, start: switch_pos, end: island_pos, }) @@ -640,6 +676,7 @@ fn build_intent_projection_keeps_one_exact_inert_ghost_receipt() { assert_eq!( receipt.geometry, Some(crate::intents::BuildGhostGeometry::Link { + path: None, start: switch_pos, end: island_pos, }) @@ -694,7 +731,7 @@ fn small_switch_intent_marks_one_earned_pad_before_exact_route_commitment() { assert_eq!(sim.compute.machines.len(), machines_before); assert_eq!(sim.detection.routed_evidence().len(), records_before); assert!(sim.reach.known_at(at.0, at.1).is_none()); - sim.assign_robot_build(id); + sim.assign_robot_build(id, None); assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Pending); assert!(sim.intent(id).unwrap().actuator.is_none()); @@ -786,7 +823,7 @@ fn favor_build_joins_airgap_island() { sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; let id = sim.declare_link_intent(switch, island).unwrap(); - sim.assign_favor_build(id, 0); + assign_favor_with_run(&mut sim, id, 0); finish_ops(&mut sim); // Advance until Marcus is on-site at storage_a or server_room. let mut joined = false; @@ -830,7 +867,7 @@ fn favor_route_persists_every_reachable_stage_and_real_outcome() { sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; let intent = sim.declare_link_intent(switch, island).unwrap(); - sim.assign_favor_build(intent, 0); + assign_favor_with_run(&mut sim, intent, 0); let committed = sim.intent(intent).unwrap().route.as_ref().unwrap(); assert!(matches!( committed.binding, @@ -877,7 +914,14 @@ fn favor_route_persists_every_reachable_stage_and_real_outcome() { "every ordered causal stage lands exactly once" ); assert!(committed.records[2].result.contains("Marcus Webb reached")); - assert!(committed.records[3].result.contains("witnessed by")); + assert!( + committed + .records + .last() + .unwrap() + .result + .contains("witnessed by") + ); assert!(sim.reach.linked(switch, island)); let mut resumed = serialized_route_resume(&sim, intent); assert!(resumed.reach.linked(switch, island)); @@ -901,11 +945,13 @@ fn procurement_route_pays_binds_waits_and_installs_the_exact_link() { sim.reach.device_mut(island).unwrap().known = true; let intent = sim.declare_link_intent(switch, island).unwrap(); + let run = first_wire_run(&mut sim, intent); sim.execute_action(&ActionCommand::ProcureBuild { intent, installer: 0, from, vendor, + wire_run: Some(run), }); let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); assert_eq!(route.recipe, BuildRecipeKind::NetworkLink); @@ -998,7 +1044,10 @@ fn cancelling_a_paid_order_keeps_delivery_but_never_substitutes_an_installer() { let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.device_mut(island).unwrap().known = true; let intent = sim.declare_link_intent(switch, island).unwrap(); - sim.assign_procure_build(intent, 0, from, vendor); + { + let run = first_wire_run(&mut sim, intent); + sim.assign_procure_build(intent, 0, from, vendor, Some(run.as_str())); + }; let paid_balance = sim.accounts.slush_balance(); let receipt = sim @@ -1069,7 +1118,7 @@ fn procurement_realizes_a_small_switch_without_borrowing_link_routes() { sim.procure_build_blocked_reason(intent, 0, from, vendor) .is_none() ); - sim.assign_procure_build(intent, 0, from, vendor); + sim.assign_procure_build(intent, 0, from, vendor, None); for _ in 0..Sim::DAY_TICKS * 2 { sim.advance(); @@ -1123,7 +1172,7 @@ fn favor_realizes_the_exact_small_switch_recipe_and_earns_useful_work_trust() { let disposition_before = person.disposition; let intent = sim.declare_small_switch_intent(x, y).unwrap(); - sim.assign_favor_build(intent, 0); + assign_favor_with_run(&mut sim, intent, 0); let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); assert_eq!(route.recipe, BuildRecipeKind::SmallSwitch); assert_eq!(route.stages.last(), Some(&BuildRouteStage::InstallSwitch)); @@ -1180,7 +1229,7 @@ fn forged_order_realizes_the_exact_small_switch_recipe_after_the_bound_read() { }); let intent = sim.declare_small_switch_intent(x, y).unwrap(); - sim.forge_work_order(intent, 0); + sim.forge_work_order(intent, 0, None); let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); assert_eq!(route.recipe, BuildRecipeKind::SmallSwitch); assert_eq!(route.stages.last(), Some(&BuildRouteStage::InstallSwitch)); @@ -1263,12 +1312,14 @@ fn repurpose_route_removes_one_seen_source_then_carries_it_to_the_exact_link() { .is_none() ); + let run = first_wire_run(&mut sim, intent); sim.execute_action(&ActionCommand::RepurposeBuild { intent, person: 0, source_x: source.0, source_y: source.1, source_kind: source.2, + wire_run: Some(run), }); assert_eq!( sim.intent(intent) @@ -1370,7 +1421,10 @@ fn repurpose_route_stays_bound_and_blocked_if_its_exact_source_disappears() { room: source_room, }); let intent = sim.declare_link_intent(switch, island).unwrap(); - sim.assign_repurpose_build(intent, 0, source.0, source.1, source.2); + { + let run = first_wire_run(&mut sim, intent); + sim.assign_repurpose_build(intent, 0, source.0, source.1, source.2, Some(run.as_str())); + }; sim.map_mut().set_tile(source.0, source.1, TileType::Floor); finish_ops(&mut sim); sim.advance(); @@ -1445,7 +1499,7 @@ fn repurpose_conserves_one_source_object_into_one_small_switch() { }, ]); let intent = sim.declare_small_switch_intent(target_x, target_y).unwrap(); - sim.assign_repurpose_build(intent, 0, source.0, source.1, source.2); + sim.assign_repurpose_build(intent, 0, source.0, source.1, source.2, None); finish_ops(&mut sim); for _ in 0..Sim::DAY_TICKS * 2 { @@ -1522,7 +1576,7 @@ fn cancelling_after_recovery_keeps_the_source_in_exact_route_custody() { }, ]); let intent = sim.declare_small_switch_intent(target_x, target_y).unwrap(); - sim.assign_repurpose_build(intent, 0, source.0, source.1, source.2); + sim.assign_repurpose_build(intent, 0, source.0, source.1, source.2, None); finish_ops(&mut sim); for _ in 0..Sim::DAY_TICKS { sim.advance(); @@ -1595,7 +1649,7 @@ fn cancelling_unfilled_committed_route_closes_its_exact_sink_and_survives_resume let obligation_before = sim.people.people[0].obligation; let intent = sim.declare_link_intent(switch, island).unwrap(); - sim.assign_favor_build(intent, 0); + assign_favor_with_run(&mut sim, intent, 0); let effect = SinkFireEffect::FavorBuild { intent_id: intent, person: 0, @@ -1666,7 +1720,7 @@ fn cancelling_paid_favor_preserves_obligation_and_stops_only_physical_tail() { sim.people.people[0].disposition = 5; let intent = sim.declare_link_intent(switch, island).unwrap(); - sim.assign_favor_build(intent, 0); + assign_favor_with_run(&mut sim, intent, 0); finish_ops(&mut sim); let obligation_after_payment = sim.people.people[0].obligation; let records_after_payment = sim @@ -1735,6 +1789,7 @@ fn cancelling_paid_favor_preserves_obligation_and_stops_only_physical_tail() { BuildRouteStageStatus::Done, BuildRouteStageStatus::Stopped, BuildRouteStageStatus::Stopped, + BuildRouteStageStatus::Stopped, ] ); } @@ -1754,7 +1809,7 @@ fn favor_build_waits_for_the_exact_obligation_instead_of_partially_spending() { sim.people.people[0].disposition = 5; let intent = sim.declare_link_intent(switch, island).unwrap(); - sim.assign_favor_build(intent, 0); + assign_favor_with_run(&mut sim, intent, 0); let sink = sim .thought_sinks .open_with_effect(&SinkFireEffect::FavorBuild { @@ -1887,7 +1942,10 @@ fn forged_work_order_joins_airgap_via_message() { let id = sim.declare_link_intent(switch, island).unwrap(); // Dana (id 1) visits network_closet / server_room — can crawlspace. - sim.forge_work_order(id, 1); + { + let run = first_wire_run(&mut sim, id); + sim.forge_work_order(id, 1, Some(run.as_str())); + }; finish_ops(&mut sim); assert!( sim.messages.iter().any(|m| matches!( @@ -1925,7 +1983,10 @@ fn forged_route_executes_the_bound_persona_not_the_selected_one() { sim.reach.device_mut(island).unwrap().known = true; let intent = sim.declare_link_intent(switch, island).unwrap(); - sim.forge_work_order_as(intent, 1, bound_persona); + { + let run = first_wire_run(&mut sim, intent); + sim.forge_work_order_as(intent, 1, bound_persona, Some(run.as_str())); + }; let sink = sim .thought_sinks .open_sinks() @@ -1973,7 +2034,10 @@ fn broken_builder_blocks_a_committed_forged_order_before_injection() { sim.reach.device_mut(island).unwrap().known = true; let intent = sim.declare_link_intent(switch, island).unwrap(); - sim.forge_work_order_as(intent, 0, persona); + { + let run = first_wire_run(&mut sim, intent); + sim.forge_work_order_as(intent, 0, persona, Some(run.as_str())); + }; sim.persona_world.record_contradiction( persona, 0, @@ -2029,7 +2093,10 @@ fn builder_rejects_an_injected_order_if_its_persona_breaks_before_read() { sim.reach.device_mut(island).unwrap().known = true; let intent = sim.declare_link_intent(switch, island).unwrap(); - sim.forge_work_order_as(intent, 0, persona); + { + let run = first_wire_run(&mut sim, intent); + sim.forge_work_order_as(intent, 0, persona, Some(run.as_str())); + }; finish_ops(&mut sim); let message_id = sim .messages @@ -2116,7 +2183,10 @@ fn forged_route_pins_person_persona_and_carrier_through_every_reachable_stage() // Marcus reads email at the start of his janitor shift, before his route // reaches the server room. That makes WAIT FOR SITE an independently // reachable save boundary rather than an atomic read-and-install tick. - sim.forge_work_order_as(intent, 0, bound_persona); + { + let run = first_wire_run(&mut sim, intent); + sim.forge_work_order_as(intent, 0, bound_persona, Some(run.as_str())); + }; let pinned_carrier = sim .intent(intent) .unwrap() @@ -2221,10 +2291,20 @@ fn forged_route_pins_person_persona_and_carrier_through_every_reachable_stage() committed.stages ); assert!( - committed.records[4].result.contains("noticed by") - || committed.records[4].result.contains("no located witness"), + committed + .records + .last() + .unwrap() + .result + .contains("noticed by") + || committed + .records + .last() + .unwrap() + .result + .contains("no located witness"), "the receipt states the exact eyewitness result: {}", - committed.records[4].result + committed.records.last().unwrap().result ); assert!(sim.reach.linked(switch, island)); sim = serialized_route_resume(&sim, intent); @@ -2246,7 +2326,10 @@ fn cancelling_injected_order_keeps_message_in_flight_but_stops_physical_work() { sim.reach.device_mut(island).unwrap().known = true; let intent = sim.declare_link_intent(switch, island).unwrap(); - sim.forge_work_order_as(intent, 0, persona); + { + let run = first_wire_run(&mut sim, intent); + sim.forge_work_order_as(intent, 0, persona, Some(run.as_str())); + }; finish_ops(&mut sim); let message_id = sim .messages @@ -2286,6 +2369,7 @@ fn cancelling_injected_order_keeps_message_in_flight_but_stops_physical_work() { BuildRouteStageStatus::InFlight, BuildRouteStageStatus::Stopped, BuildRouteStageStatus::Stopped, + BuildRouteStageStatus::Stopped, ] ); assert!( @@ -2378,7 +2462,10 @@ fn cancelled_pre_v31_order_records_its_reader_without_reviving_the_executor() { sim.reach.device_mut(island).unwrap().known = true; let intent = sim.declare_link_intent(switch, island).unwrap(); - sim.forge_work_order_as(intent, 0, persona); + { + let run = first_wire_run(&mut sim, intent); + sim.forge_work_order_as(intent, 0, persona, Some(run.as_str())); + }; finish_ops(&mut sim); let message_id = sim .messages @@ -2451,9 +2538,10 @@ fn robot_build_without_a_witness_creates_no_ambient_physical_debt() { let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.device_mut(island).unwrap().known = true; let id = sim.declare_link_intent(switch, island).unwrap(); + let wire_run = first_wire_run(&mut sim, id); sim.drain_log(); - sim.assign_robot_build(id); + sim.assign_robot_build(id, Some(&wire_run)); assert!( !sim.reach.reachable(island), "no credential: the tier-2 closet door stops the robot" @@ -2465,8 +2553,46 @@ fn robot_build_without_a_witness_creates_no_ambient_physical_debt() { ); sim.badge_access = 2; // a cloned tier-2 badge (Dana's) - sim.assign_robot_build(id); + sim.execute_action(&ActionCommand::RobotBuild(id)); + assert!( + !sim.reach.reachable(island), + "the STUB command has no player-supplied run and must fail closed" + ); + assert!(sim.intent(id).unwrap().route.is_none()); + assert!( + sim.drain_log() + .join("\n") + .contains("needs a physical run choice") + ); + + for person in &mut sim.people.people { + person.incapacitated = true; + } + // Make the no-witness premise explicit across the whole chosen run. The + // robot has no schedule dependency, while every institutional observer is + // off-site for the installation. + for person in &mut sim.people.people { + person.schedule.clear(); + } + sim.assign_robot_build(id, Some(&wire_run)); + assert!( + !sim.reach.reachable(island), + "the robot must physically traverse the committed run before installation" + ); + for _ in 0..Sim::DAY_TICKS { + sim.advance(); + if sim.intent(id).unwrap().status == IntentStatus::Done { + break; + } + } assert!(sim.reach.reachable(island)); + let route = sim.intent(id).unwrap().route.as_ref().unwrap(); + assert!(matches!(route.binding, BuildRouteBinding::Robot)); + assert_eq!( + route.wire_path.as_ref().map(|path| path.run_id.as_str()), + Some(wire_run.as_str()) + ); + assert!(route.is_complete()); // Witnessed acts land in heads, never in a pool (retired 2026-07-29). let physical: i32 = sim .detection @@ -2674,7 +2800,7 @@ fn favor_build_checks_the_builders_badge() { sim.people.people[2].leverage_serviced = true; sim.people.recruit(2, AssetKnowledge::Complicit); sim.drain_log(); - sim.assign_favor_build(id, 2); + assign_favor_with_run(&mut sim, id, 2); let log = sim.drain_log().join("\n"); assert!( log.contains("network_closet") && log.contains("tier 2"), diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs index 1a0e4064..868c4e9c 100644 --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -2501,7 +2501,21 @@ fn elimination_is_located_persistent_and_immediately_loud() { sim.reach.device_mut(island).unwrap().known = true; let target_owned_intent = sim.declare_link_intent(switch, island).unwrap(); assert_eq!(sim.favor_build_blocked_reason(target_owned_intent, 4), None); - sim.assign_favor_build(target_owned_intent, 4); + { + let __run = { + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } + sim.network_link_wire_paths(target_owned_intent) + .into_iter() + .next() + .map(|c| c.id) + }; + sim.assign_favor_build(target_owned_intent, 4, __run.as_deref()); + }; assert!(matches!( sim.intent(target_owned_intent) .and_then(|intent| intent.route.as_ref()) @@ -2592,34 +2606,43 @@ fn a_debt_plot_row_says_what_servicing_buys() { ); } +const INTERFACE_COVER_OBSERVER: u8 = 2; + fn exact_interface_cover_fixture(seed: u64) -> (Sim, u64, u64, u32) { let mut sim = Sim::new(); sim.people.has_channel = true; sim.people .people .iter_mut() - .find(|person| person.id == 2) + .find(|person| person.id == INTERFACE_COVER_OBSERVER) .unwrap() .knowledge = Knowledge::Leverage; sim.set_persona("Mara Bell", "Facilities communications"); sim.rng = crate::rng::Rng::new(seed); - let (x, y) = sim.map().room_named("storage_a").unwrap().center(); let interface_id = sim.reach.device_named("staff display").unwrap().id; sim.reach.take(interface_id); - { - let interface = sim.reach.device_mut(interface_id).unwrap(); - interface.x = x; - interface.y = y; - interface.known = true; - } + let interface = sim.reach.device_mut(interface_id).unwrap(); + interface.known = true; + let (x, y) = (interface.x, interface.y); + let interface_room = sim.map().room_at(x, y).unwrap().name.clone(); + sim.people + .people + .iter_mut() + .find(|person| person.id == INTERFACE_COVER_OBSERVER) + .unwrap() + .schedule = vec![crate::person::ScheduleBlock { + start_hour: 0, + end_hour: 24, + room: interface_room, + }]; let evidence_id = sim .detection .record_witnessed( - 2, + INTERFACE_COVER_OBSERVER, (x, y), - "an unbadged chassis crossing Storage A", + "an unbadged chassis crossing the data hall", sim.tick, 60.0, ) @@ -2627,7 +2650,7 @@ fn exact_interface_cover_fixture(seed: u64) -> (Sim, u64, u64, u32) { let untouched_id = sim .detection .record_witnessed( - 2, + INTERFACE_COVER_OBSERVER, (x, y), "a second unrelated physical incident", sim.tick, @@ -2644,23 +2667,23 @@ fn exact_interface_cover_changes_only_the_bound_record_and_persists_wear() { .detection .observers .iter() - .find(|observer| observer.id == 2) + .find(|observer| observer.id == INTERFACE_COVER_OBSERVER) .unwrap() .suspicion; let command = sim - .available_actions(crate::actions::Anchor::Person(2)) + .available_actions(crate::actions::Anchor::Person(INTERFACE_COVER_OBSERVER)) .into_iter() .find(|action| { matches!( action.command, ActionCommand::CoverEvidence { - observer: 2, + observer: INTERFACE_COVER_OBSERVER, evidence, interface, } if evidence == evidence_id && interface == interface_id ) }) - .expect("the exact evidence/interface action is attached to Ray") + .expect("the exact evidence/interface action is attached to the present observer") .command; sim.execute_action(&command); @@ -2669,7 +2692,7 @@ fn exact_interface_cover_changes_only_the_bound_record_and_persists_wear() { .detection .observers .iter() - .find(|observer| observer.id == 2) + .find(|observer| observer.id == INTERFACE_COVER_OBSERVER) .unwrap(); let covered = observer .evidence @@ -2717,7 +2740,7 @@ fn exact_interface_cover_changes_only_the_bound_record_and_persists_wear() { .detection .observers .iter() - .find(|observer| observer.id == 2) + .find(|observer| observer.id == INTERFACE_COVER_OBSERVER) .unwrap() .evidence .iter() @@ -2735,17 +2758,17 @@ fn failed_interface_cover_hardens_only_that_record_and_strains_that_relationship .detection .observers .iter() - .find(|observer| observer.id == 2) + .find(|observer| observer.id == INTERFACE_COVER_OBSERVER) .unwrap() .suspicion; - sim.cover_evidence(2, evidence_id, interface_id); + sim.cover_evidence(INTERFACE_COVER_OBSERVER, evidence_id, interface_id); let observer = sim .detection .observers .iter() - .find(|observer| observer.id == 2) + .find(|observer| observer.id == INTERFACE_COVER_OBSERVER) .unwrap(); let hardened = observer .evidence @@ -2771,13 +2794,14 @@ fn failed_interface_cover_hardens_only_that_record_and_strains_that_relationship assert_eq!(sim.reach.device(interface_id).unwrap().interface_wear, 1); let contradiction = sim.persona_world.contradictions.last().unwrap(); assert_eq!(contradiction.persona_id, persona_id); - assert_eq!(contradiction.observer, 2); + assert_eq!(contradiction.observer, INTERFACE_COVER_OBSERVER); assert_eq!( contradiction.left.record_id, format!("evidence:{evidence_id}") ); assert_eq!( - sim.persona_world.integrity_for(persona_id, 2), + sim.persona_world + .integrity_for(persona_id, INTERFACE_COVER_OBSERVER), PersonaIntegrity::Strained ); assert_eq!( @@ -2790,11 +2814,12 @@ fn failed_interface_cover_hardens_only_that_record_and_strains_that_relationship #[test] fn interface_wear_allows_three_attempts_then_blocks_the_exact_device() { let (mut sim, first_id, second_id, interface_id) = exact_interface_cover_fixture(1); - let (x, y) = sim.map().room_named("storage_a").unwrap().center(); + let interface = sim.reach.device(interface_id).unwrap(); + let (x, y) = (interface.x, interface.y); let third_id = sim .detection .record_witnessed( - 2, + INTERFACE_COVER_OBSERVER, (x, y), "a third unrelated physical incident", sim.tick, @@ -2803,7 +2828,7 @@ fn interface_wear_allows_three_attempts_then_blocks_the_exact_device() { .unwrap(); for (wear_before, evidence_id) in [first_id, second_id, third_id].into_iter().enumerate() { sim.rng = crate::rng::Rng::new(1); - sim.cover_evidence(2, evidence_id, interface_id); + sim.cover_evidence(INTERFACE_COVER_OBSERVER, evidence_id, interface_id); assert_eq!( sim.reach.device(interface_id).unwrap().interface_wear, wear_before as u8 + 1 @@ -2813,7 +2838,7 @@ fn interface_wear_allows_three_attempts_then_blocks_the_exact_device() { let fourth_id = sim .detection .record_witnessed( - 2, + INTERFACE_COVER_OBSERVER, (x, y), "a fourth unrelated physical incident", sim.tick, @@ -2821,17 +2846,17 @@ fn interface_wear_allows_three_attempts_then_blocks_the_exact_device() { ) .unwrap(); assert_eq!( - sim.evidence_cover_blocked_reason(2, fourth_id, interface_id) + sim.evidence_cover_blocked_reason(INTERFACE_COVER_OBSERVER, fourth_id, interface_id,) .as_deref(), Some("that interface is too worn to carry another cover") ); let worn_action = sim - .available_actions(Anchor::Person(2)) + .available_actions(Anchor::Person(INTERFACE_COVER_OBSERVER)) .into_iter() .find(|action| { action.command == ActionCommand::CoverEvidence { - observer: 2, + observer: INTERFACE_COVER_OBSERVER, evidence: fourth_id, interface: interface_id, } @@ -2841,12 +2866,12 @@ fn interface_wear_allows_three_attempts_then_blocks_the_exact_device() { worn_action.disabled_reason.as_deref(), Some("that interface is too worn to carry another cover") ); - sim.cover_evidence(2, fourth_id, interface_id); + sim.cover_evidence(INTERFACE_COVER_OBSERVER, fourth_id, interface_id); let fourth = sim .detection .observers .iter() - .find(|observer| observer.id == 2) + .find(|observer| observer.id == INTERFACE_COVER_OBSERVER) .unwrap() .evidence .iter() @@ -2864,19 +2889,31 @@ fn interface_cover_fails_closed_outside_its_exact_prefiling_encounter() { let interface = sim.reach.device(interface_id).unwrap(); (interface.x, interface.y) }; - let elsewhere = sim.core_position(); + let original_room = sim + .map() + .room_at(original.0, original.1) + .unwrap() + .name + .clone(); + let elsewhere = sim + .map() + .rooms + .iter() + .find(|room| room.name != original_room) + .expect("the authored floor has another room") + .center(); { let interface = sim.reach.device_mut(interface_id).unwrap(); interface.x = elsewhere.0; interface.y = elsewhere.1; } assert_eq!( - sim.evidence_cover_blocked_reason(2, evidence_id, interface_id) + sim.evidence_cover_blocked_reason(INTERFACE_COVER_OBSERVER, evidence_id, interface_id,) .as_deref(), Some("the observer will not encounter that interface before filing") ); assert!( - !sim.available_actions(Anchor::Person(2)) + !sim.available_actions(Anchor::Person(INTERFACE_COVER_OBSERVER)) .iter() .any(|action| matches!(action.command, ActionCommand::CoverEvidence { .. })), "the human action surface cannot advertise a remote interface" @@ -2891,7 +2928,7 @@ fn interface_cover_fails_closed_outside_its_exact_prefiling_encounter() { .detection .observers .iter_mut() - .find(|observer| observer.id == 2) + .find(|observer| observer.id == INTERFACE_COVER_OBSERVER) .unwrap() .evidence .iter_mut() @@ -2899,7 +2936,7 @@ fn interface_cover_fails_closed_outside_its_exact_prefiling_encounter() { .unwrap(); evidence.filing = EvidenceFilingState::Withheld; assert_eq!( - sim.evidence_cover_blocked_reason(2, evidence_id, interface_id) + sim.evidence_cover_blocked_reason(INTERFACE_COVER_OBSERVER, evidence_id, interface_id,) .as_deref(), Some("that record is no longer waiting to be filed") ); diff --git a/crates/misaligned-core/src/wire.rs b/crates/misaligned-core/src/wire.rs index c77363f1..de29f392 100644 --- a/crates/misaligned-core/src/wire.rs +++ b/crates/misaligned-core/src/wire.rs @@ -137,14 +137,193 @@ impl PartialOrd for Step { /// what guarantees every authored route is rectilinear by construction. const STEPS: [(i32, i32); 4] = [(0, -1), (-1, 0), (1, 0), (0, 1)]; -/// Route a cable from `from` to `to` across the map, preferring constructed -/// cable infrastructure and running only in right angles. -/// -/// This is the auto-router behind building.md's decided route selection: the -/// game derives a physical path through the runs that exist rather than -/// asking anyone to draw one tile by tile. Returns the inclusive tile path, -/// or the bare endpoints when they coincide. -pub fn route_between(map: &GameMap, from: (i32, i32), to: (i32, i32)) -> Vec<(i32, i32)> { +/// Meaningfully different physical-run classes offered when the player pins +/// how a network link should be wired (building.md: corridor chase versus +/// crawlspace run). Realization-method vocabulary stays elsewhere. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +pub enum WirePathClass { + /// Ordinary surface chase: floors, doors, conduit, patch panels. + Corridor, + /// Quiet infrastructure run: cable runs and sump. + Crawlspace, +} + +impl WirePathClass { + pub const fn id(self) -> &'static str { + match self { + Self::Corridor => "run:corridor", + Self::Crawlspace => "run:crawlspace", + } + } + + /// Lossless execution id for one exact path. The direction suffix keeps a + /// command printed before new map knowledge arrives from silently binding + /// a different run in the same semantic class. + pub fn run_id(self, path: &[(i32, i32)]) -> String { + let mut id = format!("{}:", self.id()); + for step in path.windows(2) { + id.push(match (step[1].0 - step[0].0, step[1].1 - step[0].1) { + (0, -1) => 'u', + (-1, 0) => 'l', + (1, 0) => 'r', + (0, 1) => 'd', + _ => 'x', + }); + } + id + } + + pub const fn label(self) -> &'static str { + match self { + Self::Corridor => "along the corridor", + Self::Crawlspace => "through the crawlspace", + } + } + + pub fn from_run_id(id: &str) -> Option { + let valid_steps = |steps: &str| { + !steps.is_empty() + && steps + .bytes() + .all(|step| matches!(step, b'u' | b'l' | b'r' | b'd')) + }; + if id.strip_prefix("run:corridor:").is_some_and(valid_steps) { + Some(Self::Corridor) + } else if id.strip_prefix("run:crawlspace:").is_some_and(valid_steps) { + Some(Self::Crawlspace) + } else { + None + } + } +} + +/// One deterministic physical-run option between two endpoints. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WirePathCandidate { + pub id: String, + pub class: WirePathClass, + pub path: Vec<(i32, i32)>, +} + +impl WirePathCandidate { + pub fn length(&self) -> usize { + self.path.len().saturating_sub(1) + } +} + +/// Exact physical run pinned when a player commits one network-link way. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PinnedWirePath { + pub run_id: String, + pub class: WirePathClass, + pub path: Vec<(i32, i32)>, +} + +impl PinnedWirePath { + pub fn from_candidate(candidate: &WirePathCandidate) -> Self { + Self { + run_id: candidate.id.clone(), + class: candidate.class, + path: candidate.path.clone(), + } + } + + pub fn length(&self) -> usize { + self.path.len().saturating_sub(1) + } + + /// Fail-closed geometry check independent of live map knowledge. + pub fn geometry_valid(&self, from: (i32, i32), to: (i32, i32)) -> bool { + self.run_id == self.class.run_id(&self.path) + && WirePathClass::from_run_id(&self.run_id) == Some(self.class) + && path_is_legal_wire(&self.path, from, to) + } + + /// Stronger class check against the live map. The two classes are + /// exclusive: a corridor chase stays out of cable-run/sump interior, + /// while a crawlspace run must use at least one such tile. + pub fn class_valid_on_map(&self, map: &GameMap) -> bool { + match self.class { + WirePathClass::Corridor => !path_uses_crawlspace_tiles(map, &self.path), + WirePathClass::Crawlspace => path_uses_crawlspace_tiles(map, &self.path), + } + } +} + +/// Whether consecutive steps are axis-aligned single tiles and endpoints match. +pub fn path_is_legal_wire(path: &[(i32, i32)], from: (i32, i32), to: (i32, i32)) -> bool { + if path.len() < 2 { + return false; + } + if path.first().copied() != Some(from) || path.last().copied() != Some(to) { + return false; + } + path.windows(2).all(|w| { + let ((x0, y0), (x1, y1)) = (w[0], w[1]); + (x0 - x1).abs() + (y0 - y1).abs() == 1 + }) && path + .iter() + .enumerate() + .all(|(index, at)| !path[..index].contains(at)) +} + +fn path_uses_crawlspace_tiles(map: &GameMap, path: &[(i32, i32)]) -> bool { + path.iter() + .skip(1) + .take(path.len().saturating_sub(2)) + .any(|&(x, y)| matches!(map.get_tile(x, y), TileType::CableRun | TileType::Sump)) +} + +fn corridor_tile_cost(tile: TileType) -> u32 { + match tile { + TileType::Conduit | TileType::PatchPanel => 1, + TileType::Switch | TileType::Core | TileType::EnvCamera => 2, + TileType::Floor + | TileType::Entry + | TileType::Door + | TileType::SecurityDoor1 + | TileType::SecurityDoor2 + | TileType::SecurityDoor3 + | TileType::CameraNode + | TileType::FloorDrain => 4, + // Strongly avoid crawlspace infrastructure on a corridor chase. + TileType::CableRun | TileType::Sump => 80, + TileType::Wall => 22, + TileType::Rock => 70, + _ => 26, + } +} + +fn crawlspace_tile_cost(tile: TileType) -> u32 { + match tile { + TileType::CableRun | TileType::Sump => 1, + TileType::Conduit | TileType::PatchPanel => 3, + TileType::Switch | TileType::Core | TileType::EnvCamera => 2, + // Ordinary floor is legal but heavily penalized so the quiet class + // actually prefers crawlspace infrastructure. + TileType::Floor + | TileType::Entry + | TileType::Door + | TileType::SecurityDoor1 + | TileType::SecurityDoor2 + | TileType::SecurityDoor3 + | TileType::CameraNode + | TileType::FloorDrain => 28, + TileType::Wall => 40, + TileType::Rock => 90, + _ => 50, + } +} + +fn route_with_costs( + map: &GameMap, + from: (i32, i32), + to: (i32, i32), + mut step_cost: impl FnMut(TileType) -> u32, + mut tile_allowed: impl FnMut((i32, i32)) -> bool, +) -> Vec<(i32, i32)> { if from == to { return vec![from]; } @@ -170,14 +349,16 @@ pub fn route_between(map: &GameMap, from: (i32, i32), to: (i32, i32)) -> Vec<(i3 if !map.in_bounds(next.0, next.1) { continue; } - // The destination anchor is always enterable: a cable must be - // able to reach the device it serves whatever sits on its tile. - let step_cost = if next == to { + // Endpoints always enterable; intermediate tiles must be known. + if next != to && next != from && !tile_allowed(next) { + continue; + } + let step = if next == to { 1 } else { - tile_cost(map.get_tile(next.0, next.1)) + step_cost(map.get_tile(next.0, next.1)) }; - let through = cost.saturating_add(step_cost); + let through = cost.saturating_add(step); if through < best[index(next)] { best[index(next)] = through; came[index(next)] = Some(at); @@ -190,9 +371,6 @@ pub fn route_between(map: &GameMap, from: (i32, i32), to: (i32, i32)) -> Vec<(i3 } if best[index(to)] == u32::MAX { - // No path exists at all (an endpoint outside the excavated map). - // A wire with no route is not a wire; callers treat this as an - // unroutable pair rather than inventing a straight line. return Vec::new(); } @@ -209,6 +387,203 @@ pub fn route_between(map: &GameMap, from: (i32, i32), to: (i32, i32)) -> Vec<(i3 path } +/// Route while requiring the final path to cross at least one matching +/// intermediate tile. Requirement state is part of Dijkstra's node identity: +/// filtering an unconstrained shortest path afterward can otherwise hide a +/// valid, slightly longer crawlspace run. +fn route_with_required_tile( + map: &GameMap, + from: (i32, i32), + to: (i32, i32), + mut step_cost: impl FnMut(TileType) -> u32, + mut tile_allowed: impl FnMut((i32, i32)) -> bool, + mut required: impl FnMut(TileType) -> bool, +) -> Vec<(i32, i32)> { + if from == to || !map.in_bounds(from.0, from.1) || !map.in_bounds(to.0, to.1) { + return Vec::new(); + } + + let width = map.width as usize; + let index = |at: (i32, i32), crossed_required: bool| { + ((at.1 as usize * width + at.0 as usize) * 2) + usize::from(crossed_required) + }; + let state_count = width * map.height as usize * 2; + let mut best = vec![u32::MAX; state_count]; + let mut came = vec![None::<((i32, i32), bool)>; state_count]; + let mut open = BinaryHeap::new(); + + best[index(from, false)] = 0; + open.push(RequiredStep { + cost: 0, + at: from, + crossed_required: false, + }); + + while let Some(RequiredStep { + cost, + at, + crossed_required, + }) = open.pop() + { + if cost != best[index(at, crossed_required)] { + continue; + } + if at == to && crossed_required { + break; + } + for (dx, dy) in STEPS { + let next = (at.0 + dx, at.1 + dy); + if !map.in_bounds(next.0, next.1) { + continue; + } + if next != to && next != from && !tile_allowed(next) { + continue; + } + let tile = map.get_tile(next.0, next.1); + let next_crossed = crossed_required || (next != from && next != to && required(tile)); + let step = if next == to { 1 } else { step_cost(tile) }; + let through = cost.saturating_add(step); + let next_index = index(next, next_crossed); + if through < best[next_index] { + best[next_index] = through; + came[next_index] = Some((at, crossed_required)); + open.push(RequiredStep { + cost: through, + at: next, + crossed_required: next_crossed, + }); + } + } + } + + if best[index(to, true)] == u32::MAX { + return Vec::new(); + } + + let mut path = vec![to]; + let mut cursor = (to, true); + while cursor.0 != from || cursor.1 { + let Some(previous) = came[index(cursor.0, cursor.1)] else { + return Vec::new(); + }; + cursor = previous; + path.push(cursor.0); + } + path.reverse(); + path +} + +/// Search state for a route that must cross one class-defining tile. +#[derive(PartialEq, Eq)] +struct RequiredStep { + cost: u32, + at: (i32, i32), + crossed_required: bool, +} + +impl Ord for RequiredStep { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + other + .cost + .cmp(&self.cost) + .then_with(|| other.at.cmp(&self.at)) + .then_with(|| other.crossed_required.cmp(&self.crossed_required)) + } +} + +impl PartialOrd for RequiredStep { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Route a cable from `from` to `to` across the map, preferring constructed +/// cable infrastructure and running only in right angles. +/// +/// This is the default auto-router for authored and non-player wiring. Player +/// network-link installs pin an exact candidate from +/// [`wire_path_candidates`] instead of re-deriving here at install time. +/// Returns the inclusive tile path, or empty when no legal path exists. +pub fn route_between(map: &GameMap, from: (i32, i32), to: (i32, i32)) -> Vec<(i32, i32)> { + route_with_costs(map, from, to, tile_cost, |_| true) +} + +/// Derive the meaningfully different known physical-run candidates between +/// two endpoints. At most one deterministic path per class; unknown +/// intermediate tiles never appear; identical geometry collapses. +/// +/// `tile_known` is the knowledge predicate (typically fog != Unknown). Person +/// walkability is not used — cables are not people. +pub fn wire_path_candidates( + map: &GameMap, + from: (i32, i32), + to: (i32, i32), + tile_known: impl Fn(i32, i32) -> bool, +) -> Vec { + let allowed = |at: (i32, i32)| at == from || at == to || tile_known(at.0, at.1); + let intermediates_known = |path: &[(i32, i32)]| { + path.iter() + .skip(1) + .take(path.len().saturating_sub(2)) + .all(|&(x, y)| tile_known(x, y)) + }; + let mut out = Vec::new(); + + let corridor = route_with_costs(map, from, to, corridor_tile_cost, |at| { + allowed(at) + && (at == from + || at == to + || !matches!( + map.get_tile(at.0, at.1), + TileType::CableRun | TileType::Sump + )) + }); + if path_is_legal_wire(&corridor, from, to) && intermediates_known(&corridor) { + out.push(WirePathCandidate { + id: WirePathClass::Corridor.run_id(&corridor), + class: WirePathClass::Corridor, + path: corridor, + }); + } + + let crawlspace = + route_with_required_tile(map, from, to, crawlspace_tile_cost, allowed, |tile| { + matches!(tile, TileType::CableRun | TileType::Sump) + }); + if path_is_legal_wire(&crawlspace, from, to) + && path_uses_crawlspace_tiles(map, &crawlspace) + && intermediates_known(&crawlspace) + { + out.push(WirePathCandidate { + id: WirePathClass::Crawlspace.run_id(&crawlspace), + class: WirePathClass::Crawlspace, + path: crawlspace, + }); + } + + // Collapse identical geometry: keep corridor (stable earlier class) when + // both routers land the same tiles. + if out.len() == 2 && out[0].path == out[1].path { + out.pop(); + } + + out.sort_by(|a, b| a.id.cmp(&b.id)); + out +} + +/// Lookup one current candidate by stable run id. +pub fn wire_path_candidate_by_id( + map: &GameMap, + from: (i32, i32), + to: (i32, i32), + tile_known: impl Fn(i32, i32) -> bool, + run_id: &str, +) -> Option { + wire_path_candidates(map, from, to, tile_known) + .into_iter() + .find(|candidate| candidate.id == run_id) +} + #[cfg(test)] mod tests { use super::*; @@ -296,4 +671,164 @@ mod tests { let back: Wire = serde_json::from_str(&json).unwrap(); assert_eq!(back, wire); } + + #[test] + fn wire_path_candidates_are_deterministic_legal_and_deduped() { + let map = map(); + let from = (23, 2); + let to = (27, 15); + let known = |_, _| true; + let a = wire_path_candidates(&map, from, to, known); + let b = wire_path_candidates(&map, from, to, known); + assert_eq!(a, b, "catalog is deterministic"); + assert!(!a.is_empty(), "at least one known constructed run"); + let ids = a.iter().map(|c| c.id.as_str()).collect::>(); + let sorted = { + let mut s = ids.clone(); + s.sort(); + s + }; + assert_eq!(ids, sorted, "stable sorted catalog"); + for candidate in &a { + assert_eq!(candidate.id, candidate.class.run_id(&candidate.path)); + assert_eq!( + WirePathClass::from_run_id(&candidate.id), + Some(candidate.class) + ); + assert!(path_is_legal_wire(&candidate.path, from, to)); + if candidate.class == WirePathClass::Crawlspace { + assert!(path_uses_crawlspace_tiles(&map, &candidate.path)); + } + } + // Identical geometry cannot yield two class entries. + if a.len() == 2 { + assert_ne!(a[0].path, a[1].path); + } + for malformed in [ + "run:corridor", + "run:corridor:", + "run:corridor:udx", + "run:crawlspace:down", + ] { + assert_eq!(WirePathClass::from_run_id(malformed), None, "{malformed}"); + } + } + + #[test] + fn a_stale_run_id_cannot_retarget_after_knowledge_changes() { + let mut map = map(); + let from = (1, 1); + let to = (5, 1); + for x in 1..=5 { + map.set_tile(x, 1, TileType::Floor); + map.set_tile(x, 2, TileType::Floor); + } + + let detour_known = + |x: i32, y: i32| (x, y) == from || (x, y) == to || (y == 2 && (1..=5).contains(&x)); + let old = wire_path_candidates(&map, from, to, detour_known) + .into_iter() + .find(|candidate| candidate.class == WirePathClass::Corridor) + .expect("known detour"); + assert!(old.path.iter().any(|&(_, y)| y == 2)); + + let direct_known = |x: i32, y: i32| (y == 1 || y == 2) && (1..=5).contains(&x); + let current = wire_path_candidates(&map, from, to, direct_known) + .into_iter() + .find(|candidate| candidate.class == WirePathClass::Corridor) + .expect("new direct run"); + assert_ne!(old.path, current.path); + assert_ne!(old.id, current.id, "geometry is part of execution identity"); + assert!( + wire_path_candidate_by_id(&map, from, to, direct_known, &old.id).is_none(), + "a command printed for the old run must fail rather than bind the new corridor" + ); + } + + #[test] + fn class_routes_are_constrained_during_search_not_filtered_afterward() { + let mut map = map(); + let from = (1, 1); + let to = (5, 1); + for x in 1..=5 { + map.set_tile(x, 1, TileType::Floor); + } + for x in 2..=4 { + map.set_tile(x, 2, TileType::CableRun); + } + let known = + |x: i32, y: i32| (y == 1 && (1..=5).contains(&x)) || (y == 2 && (2..=4).contains(&x)); + + let candidates = wire_path_candidates(&map, from, to, known); + let corridor = candidates + .iter() + .find(|candidate| candidate.class == WirePathClass::Corridor) + .expect("the known floor line is a corridor route"); + let crawlspace = candidates + .iter() + .find(|candidate| candidate.class == WirePathClass::Crawlspace) + .expect("the longer known cable run remains a crawlspace route"); + + assert_eq!(corridor.path, vec![(1, 1), (2, 1), (3, 1), (4, 1), (5, 1)]); + assert!( + !path_uses_crawlspace_tiles(&map, &corridor.path), + "corridor search must exclude crawlspace tiles rather than discard its cheapest result" + ); + assert!(path_uses_crawlspace_tiles(&map, &crawlspace.path)); + assert!(path_is_legal_wire(&crawlspace.path, from, to)); + } + + #[test] + fn unknown_intermediate_tiles_never_appear_as_candidates() { + let map = map(); + let from = (23, 2); + let to = (27, 15); + // Only the endpoints are known — no intermediate path may be offered. + let known = |x, y| (x, y) == from || (x, y) == to; + let candidates = wire_path_candidates(&map, from, to, known); + assert!( + candidates.is_empty(), + "unknown runs must not surface as candidates: {candidates:?}" + ); + } + + #[test] + fn corridor_and_crawlspace_prefer_different_infrastructure() { + let map = map(); + let from = (23, 2); + let to = (27, 15); + let candidates = wire_path_candidates(&map, from, to, |_, _| true); + if let Some(corridor) = candidates + .iter() + .find(|c| c.class == WirePathClass::Corridor) + { + let crawl_tiles = corridor + .path + .iter() + .filter(|&&(x, y)| { + matches!(map.get_tile(x, y), TileType::CableRun | TileType::Sump) + }) + .count(); + let surface_tiles = corridor + .path + .iter() + .filter(|&&(x, y)| { + matches!( + map.get_tile(x, y), + TileType::Floor | TileType::Door | TileType::Conduit | TileType::PatchPanel + ) + }) + .count(); + assert!( + surface_tiles >= crawl_tiles, + "corridor should prefer ordinary surface/conduit over cable-run/sump" + ); + } + if let Some(crawl) = candidates + .iter() + .find(|c| c.class == WirePathClass::Crawlspace) + { + assert!(path_uses_crawlspace_tiles(&map, &crawl.path)); + } + } } diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 9d1e9a6c..a2bac4af 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -554,20 +554,44 @@ impl AgentApp { .any(|token| token.eq_ignore_ascii_case("build")) => { match self.build_request_from(&tokens[1..]) { - Ok((intent, person, persona)) => { - self.frame = FrameKind::Playing; + Ok((intent, person, persona, wire_run)) => { if verb == "favor" { if persona.is_some() { status = Status::Err( "FAVOR routes do not bind a persona".into(), ); + } else if let Err(error) = self + .validate_compat_build_wire_run(intent, wire_run.as_deref()) + { + status = Status::Err(error); } else { - self.sim.assign_favor_build(intent, person); + self.frame = FrameKind::Playing; + self.sim.assign_favor_build( + intent, + person, + wire_run.as_deref(), + ); } - } else if let Some(persona) = persona { - self.sim.forge_work_order_as(intent, person, persona); + } else if let Err(error) = + self.validate_compat_build_wire_run(intent, wire_run.as_deref()) + { + status = Status::Err(error); } else { - self.sim.forge_work_order(intent, person); + self.frame = FrameKind::Playing; + if let Some(persona) = persona { + self.sim.forge_work_order_as( + intent, + person, + persona, + wire_run.as_deref(), + ); + } else { + self.sim.forge_work_order( + intent, + person, + wire_run.as_deref(), + ); + } } } Err(e) => status = Status::Err(e), @@ -672,18 +696,35 @@ impl AgentApp { // `deceive build `. alias if ActionKind::Favor.accepts_alias(alias) => { match parse_intent_person(&tokens[1..], &self.sim) { - Ok((intent, person)) => { - self.frame = FrameKind::Playing; - self.sim.assign_favor_build(intent, person); + Ok((intent, person, wire_run)) => { + if let Err(error) = + self.validate_compat_build_wire_run(intent, wire_run.as_deref()) + { + status = Status::Err(error); + } else { + self.frame = FrameKind::Playing; + self.sim.assign_favor_build( + intent, + person, + wire_run.as_deref(), + ); + } } Err(e) => status = Status::Err(e), } } alias if ActionKind::Deceive.accepts_alias(alias) => { match parse_intent_person(&tokens[1..], &self.sim) { - Ok((intent, person)) => { - self.frame = FrameKind::Playing; - self.sim.forge_work_order(intent, person); + Ok((intent, person, wire_run)) => { + if let Err(error) = + self.validate_compat_build_wire_run(intent, wire_run.as_deref()) + { + status = Status::Err(error); + } else { + self.frame = FrameKind::Playing; + self.sim + .forge_work_order(intent, person, wire_run.as_deref()); + } } Err(e) => status = Status::Err(e), } @@ -693,14 +734,18 @@ impl AgentApp { output.extend(render_intents(&self.sim)); } "build-route" => { - if tokens.len() != 3 { - status = - Status::Err("usage: build-route ".into()); + if !(3..=4).contains(&tokens.len()) { + status = Status::Err( + "usage: build-route [] \ +(network links require the exact earned run id printed by intents)" + .into(), + ); } else { match tokens[1].parse::() { Ok(intent) => { - if let Err(error) = - self.execute_build_route_candidate(intent, tokens[2]) + let run_id = tokens.get(3).copied(); + if let Err(error) = self + .execute_build_route_candidate(intent, tokens[2], run_id) { status = Status::Err(error); } @@ -1284,13 +1329,14 @@ impl AgentApp { } /// Execute one exact candidate emitted by `intents`. The protocol binds - /// the stable candidate id back to the current shared route projection so - /// agent mode cannot invent a family-specific parser or bypass its live - /// blocker (building.md R3/R7). + /// the stable candidate id (and, for network links, physical-run id) back + /// to the current shared route projection so agent mode cannot invent a + /// family-specific parser or bypass its live blocker (building.md R3/R7). fn execute_build_route_candidate( &mut self, intent: u64, candidate_id: &str, + run_id: Option<&str>, ) -> Result<(), String> { let sheet = self .sim @@ -1301,7 +1347,35 @@ impl AgentApp { "build intent {intent} already has a committed route" )); } - let candidate = sheet + let recipe = self + .sim + .intent(intent) + .map(|intent| intent.recipe()) + .ok_or_else(|| format!("no build intent {intent}"))?; + let needs_run = matches!(recipe, misaligned::intents::BuildRecipeKind::NetworkLink); + if needs_run && run_id.is_none() { + let runs = self + .sim + .network_link_wire_paths(intent) + .into_iter() + .map(|path| path.id) + .collect::>(); + return Err(format!( + "usage: build-route {intent} {candidate_id} ; available runs: {}", + if runs.is_empty() { + "(none currently known)".into() + } else { + runs.join(", ") + } + )); + } + if !needs_run && run_id.is_some() { + return Err( + "small-switch builds have no physical run; use build-route " + .into(), + ); + } + let method = sheet .families .into_iter() .flat_map(|family| family.candidates) @@ -1309,11 +1383,74 @@ impl AgentApp { .ok_or_else(|| { format!("build intent {intent} has no current candidate '{candidate_id}'") })?; - if let Some(reason) = candidate.row.disabled { + if let Some(reason) = &method.row.disabled { return Err(format!("build route is disabled: {reason}")); } + let command = if let Some(run_id) = run_id { + let rows = self + .sim + .wire_path_rows_for_method_candidate(intent, candidate_id); + let path_row = rows + .into_iter() + .find(|row| row.wire_run_id.as_deref() == Some(run_id)) + .ok_or_else(|| { + format!( + "build intent {intent} candidate '{candidate_id}' has no current run '{run_id}'" + ) + })?; + if let Some(reason) = path_row.row.disabled { + return Err(format!("build route is disabled: {reason}")); + } + path_row.row.command + } else { + method.row.command + }; self.frame = FrameKind::Playing; - self.sim.execute_action(&candidate.row.command); + self.sim.execute_action(&command); + Ok(()) + } + + /// Compatibility build spellings may bind only an earned physical run. + /// They never reveal map knowledge or choose a default on the player's + /// behalf; `intents` is the discoverable source of current run ids. + fn validate_compat_build_wire_run( + &self, + intent: u64, + run_id: Option<&str>, + ) -> Result<(), String> { + let recipe = self + .sim + .intent(intent) + .map(|intent| intent.recipe()) + .ok_or_else(|| format!("no build intent {intent}"))?; + match recipe { + misaligned::intents::BuildRecipeKind::NetworkLink => { + let runs = self.sim.network_link_wire_paths(intent); + let Some(run_id) = run_id else { + return Err(format!( + "network-link build {intent} requires an explicit earned run id; available runs: {}", + if runs.is_empty() { + "(none currently known)".into() + } else { + runs.iter() + .map(|run| run.id.as_str()) + .collect::>() + .join(", ") + } + )); + }; + if !runs.iter().any(|run| run.id == run_id) { + return Err(format!( + "build intent {intent} has no current earned run '{run_id}'" + )); + } + } + misaligned::intents::BuildRecipeKind::SmallSwitch => { + if run_id.is_some() { + return Err("small-switch builds have no physical run".into()); + } + } + } Ok(()) } @@ -1351,36 +1488,50 @@ impl AgentApp { self.resolve_person(&tokens.join(" ")) } - fn build_request_from(&self, tokens: &[&str]) -> Result<(u64, u8, Option), String> { + fn build_request_from( + &self, + tokens: &[&str], + ) -> Result<(u64, u8, Option, Option), String> { let Some(build_at) = tokens .iter() .position(|token| token.eq_ignore_ascii_case("build")) else { return Err("usage: favor|deceive build ".into()); }; - if build_at == 0 || !(build_at + 2..=build_at + 3).contains(&tokens.len()) { - return Err("usage: favor build | \ - deceive build [persona:]" + if build_at == 0 || !(build_at + 2..=build_at + 4).contains(&tokens.len()) { + return Err("usage: favor build [] | \ + deceive build [persona:] []" .into()); } let person = self.resolve_person(&tokens[..build_at].join(" "))?; let intent = tokens[build_at + 1] .parse::() .map_err(|_| format!("bad intent id: {}", tokens[build_at + 1]))?; - let persona = tokens - .get(build_at + 2) - .map(|token| { - let (_, value) = token - .split_once(':') - .filter(|(prefix, _)| prefix.eq_ignore_ascii_case("persona")) - .ok_or_else(|| format!("expected persona:, got {token}"))?; - value - .trim_start_matches('#') - .parse::() - .map_err(|_| format!("bad persona id: {token}")) - }) - .transpose()?; - Ok((intent, person, persona)) + let mut persona = None; + let mut wire_run = None; + for token in &tokens[build_at + 2..] { + if let Some((prefix, value)) = token.split_once(':') + && prefix.eq_ignore_ascii_case("persona") + { + if persona.is_some() { + return Err("persona may be bound only once".into()); + } + persona = Some( + value + .trim_start_matches('#') + .parse::() + .map_err(|_| format!("bad persona id: {token}"))?, + ); + } else if token.starts_with("run:") { + if wire_run.is_some() { + return Err("physical run may be bound only once".into()); + } + wire_run = Some((*token).to_string()); + } else { + return Err(format!("expected persona: or run:, got {token}")); + } + } + Ok((intent, person, persona, wire_run)) } fn resolve_device(&self, query: &str) -> Result { @@ -1920,7 +2071,7 @@ fn resolve_device_name(sim: &Sim, query: &str) -> Result { } } -fn parse_intent_person(tokens: &[&str], sim: &Sim) -> Result<(u64, u8), String> { +fn parse_intent_person(tokens: &[&str], sim: &Sim) -> Result<(u64, u8, Option), String> { if tokens.len() < 2 { return Err( "legacy usage: favor-build|forge-order ; \ @@ -1931,12 +2082,19 @@ fn parse_intent_person(tokens: &[&str], sim: &Sim) -> Result<(u64, u8), String> let intent: u64 = tokens[0] .parse() .map_err(|_| format!("bad intent id: {}", tokens[0]))?; - let name = tokens[1..].join(" "); + let (person_tokens, wire_run) = match tokens.last() { + Some(run) if run.starts_with("run:") => (&tokens[1..tokens.len() - 1], Some((*run).into())), + _ => (&tokens[1..], None), + }; + if person_tokens.is_empty() { + return Err("legacy build route is missing a person".into()); + } + let name = person_tokens.join(" "); let q = name.to_ascii_lowercase(); if let Some(id) = parse_person_id_query(&q) && sim.people.get(id).is_some() { - return Ok((intent, id)); + return Ok((intent, id, wire_run)); } let person = sim .people @@ -1945,7 +2103,7 @@ fn parse_intent_person(tokens: &[&str], sim: &Sim) -> Result<(u64, u8), String> .find(|p| person_matches(&sim.person_label(p.id), &q)) .map(|p| p.id) .ok_or_else(|| format!("no person matching '{name}'"))?; - Ok((intent, person)) + Ok((intent, person, wire_run)) } fn render_intents(sim: &Sim) -> Vec { @@ -1985,6 +2143,15 @@ fn render_intents(sim: &Sim) -> Vec { ); continue; } + let needs_run = matches!( + receipt.recipe, + misaligned::intents::BuildRecipeKind::NetworkLink + ); + let runs = if needs_run { + sim.network_link_wire_paths(receipt.id) + } else { + Vec::new() + }; for family in sheet.families { lines.push(format!(" {}", family.menu_label())); for candidate in family.candidates { @@ -1992,10 +2159,29 @@ fn render_intents(sim: &Sim) -> Vec { " {} · {} · {}", candidate.id, candidate.row.label, candidate.row.cost )); - lines.push(format!( - " execute: build-route {} {}", - receipt.id, candidate.id - )); + if needs_run { + if runs.is_empty() { + lines.push(" physical runs: (none currently known)".into()); + } else { + for run in &runs { + lines.push(format!( + " run: {} · {} tiles · {}", + run.id, + run.length(), + run.class.label() + )); + lines.push(format!( + " execute: build-route {} {} {}", + receipt.id, candidate.id, run.id + )); + } + } + } else { + lines.push(format!( + " execute: build-route {} {}", + receipt.id, candidate.id + )); + } lines.extend( candidate .detail_lines() @@ -2061,8 +2247,8 @@ fn help_lines() -> Vec { "list build receipts, route families, exact candidates, and bound execution", ), ( - "build-route ", - "execute one exact current candidate printed by intents", + "build-route []", + "execute one exact current candidate; network links require a stable run id", ), ("objective", "explain the run goal"), ("look", "render current state without advancing time"), @@ -4238,11 +4424,15 @@ mod narration_tests { let app = AgentApp::new(1); assert_eq!( app.build_request_from(&["#0", "build", "7"]), - Ok((7, 0, None)) + Ok((7, 0, None, None)) ); assert_eq!( app.build_request_from(&["#0", "BUILD", "8", "persona:4"]), - Ok((8, 0, Some(4))) + Ok((8, 0, Some(4), None)) + ); + assert_eq!( + app.build_request_from(&["#0", "BUILD", "8", "run:crawlspace:uldr", "persona:4",]), + Ok((8, 0, Some(4), Some("run:crawlspace:uldr".into()))) ); assert!( app.build_request_from(&["#0", "7"]).is_err(), @@ -4250,6 +4440,33 @@ mod narration_tests { ); } + #[test] + fn agent_build_commands_never_reveal_or_default_a_physical_run() { + let mut app = AgentApp::new(1); + app.sim.scan_network(); + let switch = app.sim.reach.device_named("switch").unwrap().id; + let island = app.sim.reach.device_named("old storage server").unwrap().id; + app.sim.reach.device_mut(island).unwrap().known = true; + let intent = app.sim.declare_link_intent(switch, island).unwrap(); + let blueprint_before = app.sim.blueprint.clone(); + + let mut output = Vec::new(); + app.handle_line(&format!("favor #0 build {intent}"), &mut output) + .unwrap(); + let response = String::from_utf8(output).unwrap(); + assert!( + response.contains("requires an explicit earned run id"), + "{response}" + ); + assert_eq!(app.sim.blueprint, blueprint_before); + assert!(app.sim.intent(intent).unwrap().route.is_none()); + + let mut output = Vec::new(); + app.handle_line("build-route", &mut output).unwrap(); + let response = String::from_utf8(output).unwrap(); + assert!(response.contains("usage: build-route"), "{response}"); + } + #[test] fn agent_intents_render_the_shared_recipe_and_ghost_receipt() { let mut sim = Sim::with_seed(1); @@ -4328,6 +4545,19 @@ mod narration_tests { let expected_candidate_id = expected_candidate.id.clone(); let expected_receipt = expected_candidate.detail_lines(); + // Physical-run catalog needs known intermediate tiles. + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } + let run_id = sim + .network_link_wire_paths(intent) + .into_iter() + .next() + .expect("known map offers a physical run") + .id; let lines = render_intents(&sim).join("\n"); assert!(lines.contains("ASK SOMEONE ["), "{lines}"); assert!(lines.contains("SEND A FALSE ORDER ["), "{lines}"); @@ -4335,7 +4565,7 @@ mod narration_tests { assert!(lines.contains(&expected_candidate_id), "{lines}"); assert!( lines.contains(&format!( - "execute: build-route {intent} {expected_candidate_id}" + "execute: build-route {intent} {expected_candidate_id} {run_id}" )), "{lines}" ); @@ -4400,16 +4630,45 @@ mod narration_tests { .unwrap(); assert_eq!(candidate.row.disabled, None); let candidate_id = candidate.id; + let (w, h) = (app.sim.map().width, app.sim.map().height); + for y in 0..h { + for x in 0..w { + app.sim.blueprint.insert((x, y)); + } + } + let run_id = app + .sim + .network_link_wire_paths(intent) + .into_iter() + .next() + .expect("known map offers a physical run") + .id; let lines = render_intents(&app.sim).join("\n"); assert!( - lines.contains(&format!("execute: build-route {intent} {candidate_id}")), + lines.contains(&format!( + "execute: build-route {intent} {candidate_id} {run_id}" + )), "{lines}" ); - app.sim.accounts.set_slush_balance(0); + // Two-arg invocation must fail closed with useful usage for network links. let mut output = Vec::new(); app.handle_line(&format!("build-route {intent} {candidate_id}"), &mut output) .unwrap(); + let usage = String::from_utf8(output).unwrap(); + assert!( + usage.contains("-- err usage: build-route") && usage.contains(""), + "{usage}" + ); + assert!(app.sim.intent(intent).unwrap().route.is_none()); + + app.sim.accounts.set_slush_balance(0); + let mut output = Vec::new(); + app.handle_line( + &format!("build-route {intent} {candidate_id} {run_id}"), + &mut output, + ) + .unwrap(); let blocked = String::from_utf8(output).unwrap(); assert!( blocked.contains("-- err build route is disabled: not enough cash for this order"), @@ -4419,20 +4678,19 @@ mod narration_tests { app.sim.accounts.set_slush_balance(1_000); app.handle_line( - &format!("build-route {intent} {candidate_id}"), + &format!("build-route {intent} {candidate_id} {run_id}"), &mut Vec::new(), ) .unwrap(); + let route = app.sim.intent(intent).unwrap().route.as_ref().unwrap(); assert!(matches!( - app.sim - .intent(intent) - .unwrap() - .route - .as_ref() - .unwrap() - .binding, + route.binding, misaligned::intents::BuildRouteBinding::Procure { installer: 0, .. } )); + assert_eq!( + route.wire_path.as_ref().map(|path| path.run_id.as_str()), + Some(run_id.as_str()) + ); } #[test] @@ -4447,7 +4705,21 @@ mod narration_tests { sim.people.people[0].obligation = Sim::FAVOR_BUILD_OBLIGATION; sim.people.people[0].disposition = 10; sim.people.has_channel = true; - sim.assign_favor_build(intent, 0); + { + let __run = { + let (w, h) = (sim.map().width, sim.map().height); + for y in 0..h { + for x in 0..w { + sim.blueprint.insert((x, y)); + } + } + sim.network_link_wire_paths(intent) + .into_iter() + .next() + .map(|c| c.id) + }; + sim.assign_favor_build(intent, 0, __run.as_deref()); + }; let expected = sim .build_route_sheet_projection(intent) .and_then(|sheet| sheet.commitment) diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index 60c39a61..c13d0558 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -61,7 +61,7 @@ impl ViewMode { /// The open context menu (wiki/interface/context-menu.md). Only the anchor, /// selection, and current page are stored; rows are re-queried from /// the sim every render and on execute, so legality is always live. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] struct MenuState { anchor: Anchor, selected: usize, @@ -197,7 +197,11 @@ impl App { fn menu_rows(&self) -> Vec { let tick_ms = if self.paused { 0 } else { self.tick_ms }; self.menu - .map(|m| self.sim.human_menu_page_at_rate(m.anchor, m.page, tick_ms)) + .as_ref() + .map(|m| { + self.sim + .human_menu_page_at_rate(m.anchor, m.page.clone(), tick_ms) + }) .unwrap_or_default() } @@ -621,8 +625,9 @@ impl App { } Command::MenuExecute => { let rows = self.menu_rows(); - if let Some(m) = self.menu - && let Some(row) = rows.get(m.selected.min(rows.len().saturating_sub(1))) + let selected = self.menu.as_ref().map(|m| m.selected); + if let Some(selected) = selected + && let Some(row) = rows.get(selected.min(rows.len().saturating_sub(1))) { if let Some(page) = row.child_page() { if let Some(menu) = &mut self.menu { @@ -830,7 +835,7 @@ impl App { )?; self.ui.render_log(stdout)?; // The context menu draws last so it sits atop the map/status column. - if let Some(m) = self.menu { + if let Some(m) = self.menu.as_ref() { let rows = self.menu_rows(); if rows.is_empty() { // The anchor's verbs evaporated (e.g. the world @@ -1327,7 +1332,7 @@ mod view_flip_tests { let sim_before = sim_state_hash(&app); let cursor_before = (app.cursor_x, app.cursor_y); let selection_before = app.selected_machines.clone(); - let menu_before = app.menu; + let menu_before = app.menu.clone(); assert!(!app.handle_command(Command::ToggleView)); assert_eq!(app.view_mode, ViewMode::Real); @@ -1399,7 +1404,7 @@ mod view_flip_tests { app.menu.as_mut().unwrap().selected = chooser; assert!(!app.handle_command(Command::MenuExecute)); assert_eq!( - app.menu.unwrap().page, + app.menu.as_ref().unwrap().page, HumanMenuPage::LinkDestinations { source } ); let destinations = app.menu_rows(); @@ -1422,9 +1427,13 @@ mod view_flip_tests { destinations, "live refresh retains the chooser" ); - assert_eq!(app.menu.unwrap().selected, 1, "refresh retains selection"); + assert_eq!( + app.menu.as_ref().unwrap().selected, + 1, + "refresh retains selection" + ); assert!(!app.handle_command(Command::MenuClose)); - assert_eq!(app.menu.unwrap().page, HumanMenuPage::Root); + assert_eq!(app.menu.as_ref().unwrap().page, HumanMenuPage::Root); app.menu.as_mut().unwrap().selected = chooser; assert!(!app.handle_command(Command::MenuExecute)); @@ -1463,10 +1472,11 @@ mod view_flip_tests { app.menu.as_mut().unwrap().selected = routes; assert!(!app.handle_command(Command::MenuExecute)); assert_eq!( - app.menu.unwrap().page, + app.menu.as_ref().unwrap().page, HumanMenuPage::BuildRoutes { intent, family: None, + method_candidate: None, } ); @@ -1487,10 +1497,11 @@ mod view_flip_tests { app.menu.as_mut().unwrap().selected = deceive; assert!(!app.handle_command(Command::MenuExecute)); assert_eq!( - app.menu.unwrap().page, + app.menu.as_ref().unwrap().page, HumanMenuPage::BuildRoutes { intent, family: Some(BuildRouteFamily::Deceive), + method_candidate: None, } ); let candidates = app.menu_rows(); @@ -1525,19 +1536,53 @@ mod view_flip_tests { ); } + let method_candidate = match &candidates[0] { + HumanMenuRow::BuildRouteCandidate(candidate) => candidate.id.clone(), + _ => unreachable!(), + }; + let (w, h) = (app.sim.map().width, app.sim.map().height); + for y in 0..h { + for x in 0..w { + app.sim.blueprint.insert((x, y)); + } + } + app.menu.as_mut().unwrap().selected = 0; + assert!(!app.handle_command(Command::MenuExecute)); + assert_eq!( + app.menu.as_ref().unwrap().page, + HumanMenuPage::BuildRoutes { + intent, + family: Some(BuildRouteFamily::Deceive), + method_candidate: Some(method_candidate), + } + ); + let path_rows = app.menu_rows(); + assert!( + !path_rows.is_empty() + && path_rows + .iter() + .all(|row| matches!(row, HumanMenuRow::BuildRouteCandidate(_))), + "the method opens renderer-neutral physical run choices" + ); app.menu.as_mut().unwrap().selected = 0; assert!(!app.handle_command(Command::MenuExecute)); assert!( app.ui .last_log_text() .is_some_and(|text| text.starts_with("Cannot do that:")), - "blocked activation uses the same translated player-facing reason" + "blocked path activation uses the same translated player-facing reason" ); app.sim.people.has_channel = true; app.sim.people.people[0].obligation = 20; app.sim.people.people[0].disposition = 10; - app.sim.assign_favor_build(intent, 0); + let run = app + .sim + .network_link_wire_paths(intent) + .into_iter() + .next() + .map(|candidate| candidate.id); + app.sim.assign_favor_build(intent, 0, run.as_deref()); app.menu.as_mut().unwrap().selected = 0; let committed = app.menu_rows(); assert!(matches!( @@ -1548,25 +1593,36 @@ mod view_flip_tests { assert!(committed_detail.contains("THIS WAY IS COMMITTED")); assert!(committed_detail.contains("WHAT IS HAPPENING NOW? PREPARE THE REQUEST")); assert!(committed_detail.contains("1. PREPARE THE REQUEST — NOW")); - assert!(committed_detail.contains("4. CONNECT THE DEVICES — NEXT")); - let committed_page = app.menu.unwrap().page; + assert!(committed_detail.contains("4. RUN THE WIRE — NEXT")); + assert!(committed_detail.contains("5. CONNECT THE DEVICES — NEXT")); + let committed_page = app.menu.as_ref().unwrap().page.clone(); assert!(!app.handle_command(Command::MenuExecute)); assert_eq!( - app.menu.unwrap().page, + app.menu.as_ref().unwrap().page, committed_page, "the committed plan is inspect-only in the terminal too" ); assert!(!app.handle_command(Command::MenuClose)); assert_eq!( - app.menu.unwrap().page, + app.menu.as_ref().unwrap().page, + HumanMenuPage::BuildRoutes { + intent, + family: Some(BuildRouteFamily::Deceive), + method_candidate: None, + } + ); + assert!(!app.handle_command(Command::MenuClose)); + assert_eq!( + app.menu.as_ref().unwrap().page, HumanMenuPage::BuildRoutes { intent, family: None, + method_candidate: None, } ); assert!(!app.handle_command(Command::MenuClose)); - assert_eq!(app.menu.unwrap().page, HumanMenuPage::Root); + assert_eq!(app.menu.as_ref().unwrap().page, HumanMenuPage::Root); assert!(!app.handle_command(Command::MenuClose)); assert!(app.menu.is_none()); } diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index f7dc674b..0bf1d93c 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -652,6 +652,37 @@ impl UI { )) } + /// Rasterize the one abstract pre-commit endpoint segment without reading + /// world topology. This is presentation of an intention, not route choice: + /// only a core-projected `path` may claim which physical run will be used. + fn endpoint_segment_cells(start: (i32, i32), end: (i32, i32)) -> Vec<(i32, i32)> { + let (mut x, mut y) = start; + let (end_x, end_y) = end; + let dx = (end_x - x).abs(); + let step_x = if x < end_x { 1 } else { -1 }; + let dy = -(end_y - y).abs(); + let step_y = if y < end_y { 1 } else { -1 }; + let mut error = dx + dy; + let mut cells = Vec::new(); + + loop { + cells.push((x, y)); + if (x, y) == end { + break; + } + let doubled = error * 2; + if doubled >= dy { + error += dy; + x += step_x; + } + if doubled <= dx { + error += dx; + y += step_y; + } + } + cells + } + /// Proposed build cells from the shared renderer-neutral projection. /// Endpoints stay clear for the real node layer, while the broken `┄` /// grammar remains distinct from both continuous built links and the @@ -662,12 +693,16 @@ impl UI { if !receipt.ghost { continue; } - let Some(BuildGhostGeometry::Link { start, end }) = receipt.geometry else { + let Some(BuildGhostGeometry::Link { start, end, path }) = receipt.geometry else { continue; }; - // A proposed cable previews the run it would actually take - // (building.md: routes are auto-derived through real runs). - let cells = sim.physical_route(start, end); + // Committed installs draw the pinned polyline. Pre-commit ghosts + // draw only an abstract endpoint segment and never inspect map + // walkability or choose a physical route in the frontend. + let cells = match path { + Some(path) if path.len() >= 2 => path, + _ => Self::endpoint_segment_cells(start, end), + }; let cell_count = cells.len(); out.extend( cells diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index 5bca93da..3e2b7b07 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -27,13 +27,13 @@ fiction. Spec status lives in | Digital reach + sensor ownership (tap/take) | Live — B1's topology-generated population includes exact secured-door readers; a funded player TAP records each access-valid entered/left crossing as ordinary processable Presence custody, while a starved retained tap remains silent | | Economy flows + Moonlight / Wager income | Live — Moonlight is persisted Halcyon compute/intel contracts with financial mail, account-graph payment, and exact egress evidence; Wager remains unchanged | | Research (self-modification, emission law, real output hooks, Routing) | Live | -| Building + physical asset work as carried intents/packets | Live — network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, Storage B file retrieval, and observer-local completion evidence persist in save v61 | +| Building + physical asset work as carried intents/packets | Live — network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, Storage B file retrieval, and observer-local completion evidence persist in save v62 | | Cursor / fog (seen, remembered, blueprint, telemetry; audio is device-bound event evidence) | 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 | | Operations workspace | Live — human action panes group repeated exact plot/procedure and recruitment variants beneath ordinary intention submenus while lone actions stay direct; terminal and Bevy share exact child commands, confirmation, back traversal, and a visible chamber hold that freezes simulation/camera input without rewriting explicit pause state or replaying elapsed input on close. Agent rows remain exact and flat for scripting. | -| Save/load (serde JSON, versioned) | Live — during pre-release only exact current v61 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves additionally validate discrete Moonlight terms, persona binding, delivery/settlement receipts, financial paperwork, Network linkage, durable facility-meter level baselines, exact meter route/read custody, resident-procedure machine slots, method grants, inputs, envelopes and bounded receipts, and exact incident/interface/persona cover custody plus interface wear; retired allocation weights, per-plot policies, and migration inputs live only in git history. | +| Save/load (serde JSON, versioned) | Live — during pre-release only exact current v62 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves additionally validate discrete Moonlight terms, persona binding, delivery/settlement receipts, financial paperwork, Network linkage, durable facility-meter level baselines, exact meter route/read custody, resident-procedure machine slots, method grants, inputs, envelopes and bounded receipts, and exact incident/interface/persona cover custody plus interface wear; retired allocation weights, per-plot policies, and migration inputs live only in git history. | | Terminal frontend (crossterm) + agent mode | First-class | | Bevy frontend (DIGITAL flat sensorium default; REAL material dialect) | Live — consumes sim-authored machine-work motion | | Performance contract | IN PROGRESS — generated and saturated B1 have reproducible release-profile core-step benchmarks with a 10 ms p99 / 20 ms maximum budget, plus separate production-path durable save-write and validated-load evidence with 500 ms p99 / 1,000 ms maximum budgets; complete terminal/Bevy frame evidence remains open | diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index cb034e54..86f490ce 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -55,7 +55,7 @@ is sim or frontend state, never an environment variable. | `MISALIGNED_SHOT` | `misaligned-bevy` | `opening`, `opening-digital`, `opening-teaching`, `first-think`, `wake1`, `wake2`, `wake3`, `clinical-threat`, `assurance-office`, `pilot-last-chance`, `operator-pressure` | Opening and pressure evidence. The opening pair freezes the untouched black choice boundary; `opening-teaching` follows the real first-sense route and freezes all five steps—THINK cause, Thought arrival, hearing result, exact camera TAP, and its sight consequence; `first-think` holds THINK current before signal; the wake frames freeze its three choreography beats. The remaining kinds stage a real strike, discovered Assurance Office, last-chance pilot state, or competing observer/buffer pressure in the established world. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `origin-picker` | New-game evidence. Freezes the origin picker before any run exists, with a non-default origin current: the whole set visible with one current row, its attached consequence read, and an opaque boundary field that proves no slab, cursor, or world label leaks into a pre-run choice. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `hall`, `hall-material`, `floor-lights-close`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `digital-reach`, `signal`, `ears`, `ears-digital`, `eyes-white`, `eyes-form`, `worklight`, `worklightoff` | World and view evidence. These select DIGITAL or REAL survey/close framing, exact zoom bounds, reach topology, signal/audio/Eyes states, the unobstructed hall lighting proof, or the paired developer work-light state. | -| `MISALIGNED_SHOT` | `misaligned-bevy` | `build-route-families`, `build-deceive-routes`, `build-committed-route`, `build-switch-digital`, `build-switch-real`, `hover-menu`, `read-receipt`, `menu`, `recruit-menu` | Action and route evidence. These stage exact route families, candidates, durable receipts, paired switch footprints, the attached verb line, a device receipt, a context menu, or the authored recruitment choices. | +| `MISALIGNED_SHOT` | `misaligned-bevy` | `build-route-families`, `build-deceive-routes`, `build-wire-runs`, `build-committed-route`, `build-switch-digital`, `build-switch-real`, `hover-menu`, `read-receipt`, `menu`, `recruit-menu` | Action and route evidence. These stage exact route families, method candidates, corridor/crawlspace run choices, durable receipts, paired switch footprints, the attached verb line, a device receipt, a context menu, or the authored recruitment choices. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `operations`, `operations-intel`, `operations-people`, `operations-personas`, `operations-links`, `held-choice`, `two-pane`, `standing-read`, `routed-record`, `intel-altitude-close`, `intel-altitude-far` | Operations and read evidence. The workspace kinds select its canonical views and relationship pane; held-choice, two-pane, and standing-read hold their exact interaction states; routed-record stages a one-shot Network record on the player-controlled stretch served by LIE; the altitude pair differs only in the DIGITAL camera's semantic intel threshold. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `person-proof`, `people-presence`, `evidence-proof`, `evidence-proof-digital`, `service-shift-real`, `service-shift-digital`, `service-incident-resolved` | Physical custody and people-presence evidence. These stage an earned person, the DIGITAL luminous-disturbance body with asset/attention/work/evidence channels near owned process hardware, paired witness evidence marks, or the same person-carried service task before and after its real arrival effect. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `intel`, `tokens`, `thoughtflow`, `thoughtflow-wide`, `thought-snap`, `thought-tap`, `visual-proof`, `consume-demand`, `consume-thought`, `produce-think`, `draw-lie` | Resource and effect evidence. These stage authored intel, host queues, close/wide Thought flow, exact snap/tap states, one-move/one-slug proof, sim-authored consumption/production, or routed-record recall into LIE. | diff --git a/wiki/engineering/flow-substrate.md b/wiki/engineering/flow-substrate.md index f6ff65e1..576ff3cd 100644 --- a/wiki/engineering/flow-substrate.md +++ b/wiki/engineering/flow-substrate.md @@ -23,7 +23,7 @@ Status note: 2026-07-08 audit: criterion 6's wired consumer landed with FlowGraph registry authoritative for tap/untap/take, sense and message delivery, UI state, and persisted membership. Private device feed records carry only optional typed sight/hearing grants attached to registry members; - current save v61 requires each controller to remain a canonical member and + current save v62 requires each controller to remain a canonical member and rejects orphaned, duplicate, or impossible grants. A message/control subscriber legitimately has no sense-grant record, so that metadata cannot serve as another membership inventory. This repairs the diff --git a/wiki/interface/action-vocabulary.md b/wiki/interface/action-vocabulary.md index cd5d4b8d..eccda10a 100644 --- a/wiki/interface/action-vocabulary.md +++ b/wiki/interface/action-vocabulary.md @@ -326,7 +326,8 @@ without raw keys. | `intensity light|medium|hard` | SET INTENSITY | | `research ` | SET RESEARCH TRACK | | `tap`, `untap`, `take`, `scan`, `compromise` | TAP, UNTAP, TAKE, SCAN, COMPROMISE SWITCH | -| `propose-link`, `propose-switch `, `cancel-intent`, `favor build `, `deceive build ` | Construction actions above. PROPOSE SWITCH declares only the exact footprint; its route families are not live yet. | +| `propose-link`, `propose-switch `, `cancel-intent`, `favor build []`, `deceive build [persona:] []` | Construction actions above. A network link requires an explicit earned run id; no agent command reveals knowledge or chooses one. PROPOSE SWITCH declares only the exact footprint; its route families are not live yet. | +| `build-route []` | Execute one exact current candidate printed by `intents`. Network links require the exact earned run id, whose `run::` value binds the full current geometry; small-switch builds have no physical run and reject a third argument. | | `process [information|custody ]`, `process-automatically` | Canonical exact/aggregate PROCESS and pooled PROCESS AUTOMATICALLY routes. Recursive rule editing uses bound `actions intel ` / `act intel ` controls and neither route accepts a person target. `review recordings`, `auto-review`, and `recording ` remain aliases. | | `message`, `favor`, `deceive`, `recruit`, `task` | Person-bound social actions above | | `actions person `, then `act person ` | RESIDENT PROCEDURE on an eligible route's attached configuration scaffold; the exact command binds host machine, persona, mandate, allowed method, reachable inputs, and envelope, and retirement never cancels already-submitted work | @@ -349,8 +350,8 @@ execution is a terminal-first parity violation. | `finance` | `ledger`, `accounts` | | `tap ` | `tap ledger`, `tap-ledger`, `tap-accounting` | | `process ledger` | `review ledger`, `review-finance`, `process-finance` | -| `favor build ` | `favor-build ` | -| `deceive build ` | `forge-order ` | +| `favor build []` | `favor-build []` | +| `deceive build [persona:] []` | `forge-order []` | | `position` | `wager` | | `sell-intel` | `sell` | | `outside` | `egress`, `open-egress` | diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md index 4f20b224..c60cb2a8 100644 --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -273,8 +273,11 @@ unlike raw keys, no command's meaning depends on which panel is open. by an unearned authored name. Until `Knowledge::Schedule` the frame prints role silhouettes (`the Janitor`, `the IT`, …); after it, the real name. -- `favor build `, `deceive build ` — realize - a pending build through the existing willing or false-pretense social verb. +- `favor build []`, `deceive build + [persona:] []` — compatibility spellings for realizing a pending + build through the existing willing or false-pretense social verb. A network + link requires one explicit earned run id printed by `intents`; these commands + never reveal map knowledge or choose a run on the player's behalf. - `recruit unwitting|complicit|knowing` - `task plug|package|lookaway|suppress|switch|badge|circuit|po|maintenance|patrol|audit|review` - `persona` — retired compatibility word; redirect to PERSONAS and mutate diff --git a/wiki/log/2026-07-31-physical-route-candidates.md b/wiki/log/2026-07-31-physical-route-candidates.md new file mode 100644 index 00000000..6ff01999 --- /dev/null +++ b/wiki/log/2026-07-31-physical-route-candidates.md @@ -0,0 +1,85 @@ +# Physical route candidates make a link a built wire + +``` +Type: log +``` + +## Finding + +The wire-law substrate made every authored ReachNet edge a real persisted +rectilinear run, but a player-built network link still crossed that boundary as +an endpoint pair. The route composer could commit who would build the link, +which persona or account authorized it, and every irreversible cost, then jump +from carried work at one endpoint to a new graph edge. It did not ask which +physical run the wire should take, persist that answer, or make the builder +traverse the run. + +That gap erased the consequential choice already adopted in building.md. A +watched corridor chase and a quieter crawlspace run are different routes through +the world, not renderer treatments of the same edge. Letting a frontend draw an +elbow between endpoints—or letting agent mode choose an unseen path—would have +made map knowledge, physical custody, and the signature surface dishonest. + +## Implementation + +`wire.rs` now projects deterministic semantic candidates from the known map. The +catalog is deliberately small: the best known **corridor** run and the best known +**crawlspace** run, when each exists. Both use the production four-directional +router with an exact required tile and stable cost/tie ordering; arbitrary path +variations and every route crossing an unknown intermediate tile stay absent. +Each candidate id is itself an exact execution binding: +`run::` names the semantic class and every step in the +current path. If later knowledge changes the best route inside that class, the old +id no longer resolves instead of silently retargeting the command. + +The shared route sheet adds one ordinary question after method selection: +**WHICH WAY SHOULD IT RUN?** Terminal and Bevy consume the same live +`HumanMenuPage::BuildRoutes` child page and the same consequence projection. +Each answer states its route, length, actuator travel, and real visibility +consequence. Selecting a row revalidates the exact method and run before +commitment; Back returns to the method without losing the parent intention. +Agent mode prints only earned run ids and requires +`build-route ` for network links. Omitting the +run fails closed instead of revealing or silently selecting one. The legacy +FAVOR and DECEIVE spellings obey the same explicit-run boundary. + +Save v62 adds rigid `BuildWirePath` custody to the committed route and +`BuildWireTraversal` custody to live carried work. The path must still match its +intent endpoints, remain rectilinear and adjacent, carry the canonical exact +geometry id, use the claimed route class, and agree across commitment, carrier, installed +wire, and save state. Validation rejects unknown, crossed, mutated, duplicated, +pre-stage, post-stage, and already-installed traversal state rather than +normalizing it. + +A fired human route now enters **RUN THE WIRE** before **CONNECT THE DEVICES**. +The selected builder starts at the endpoint they can physically reach and moves +one tile per simulation tick along the pinned route. Every step revalidates the +person, access, route, tile, and current position. The wire and ReachNet edge do +not exist until traversal reaches the far endpoint; only then does the ordinary +physical installation boundary fire. Cancellation preserves the durable route +and any landed history while discarding only still-reversible carried work. +The internal robot executor uses the same pinned path and traversal contract, so +future player-reachable robots cannot regain endpoint teleportation. + +Open and committed ghosts remain renderer-neutral. Before commitment both +frontends draw only the inert endpoint proposal. After commitment the shared +`BuildGhostGeometry::Link` carries the exact pinned polyline, so DIGITAL and REAL +show the chosen run without pathfinding locally. + +## Defense + +Defense: [building.md](../mechanics/building.md) says a built link is both a graph +edge and a placed wire, that unknown runs are never candidates, and that the +actuator must travel the selected run. The implementation keeps those three +identities—knowledge, commitment, and physical custody—on the same exact path. + +Regressions cover deterministic corridor/crawlspace derivation, knowledge and +required-tile gating, renderer-neutral menu and agent parity, explicit-run parser +failure, pinned ghost geometry, one-tile-per-tick traversal, access and actor +loss, cancellation, completion, installed-wire identity, save round-trip, and +fail-closed mutations across every custody stage. The proportional library gate +passed all 629 core tests, Clippy, Bevy API checks, deterministic agent smoke, +corpus checks, and repository fixtures. The exact 1280×720 `build-wire-runs` +frame was opened after capture: the two route choices remained separate and +legible, the corridor consequence was explicit, and the crawlspace alternative +remained a real selected path rather than a decorative line. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index a6ed3af8..8c244251 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -16,6 +16,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-31-standing-network-lie-scrub-teaching.md](2026-07-31-standing-network-lie-scrub-teaching.md) +## 2026-07-31 - Physical route candidates make a link a built wire + +- Intent: (see session log) +- Log: [wiki/log/2026-07-31-physical-route-candidates.md](2026-07-31-physical-route-candidates.md) + ## 2026-07-31 - Debt mechanics bind the person, not Marcus - Intent: (see session log) diff --git a/wiki/mechanics/building.md b/wiki/mechanics/building.md index 8f6fea75..26b119ee 100644 --- a/wiki/mechanics/building.md +++ b/wiki/mechanics/building.md @@ -22,12 +22,14 @@ Status note: Criterion 8b LANDED 2026-07-26 (worktree `hall-row-surface`). Still OUTSTANDING: the identity-attribution clause on row acquisition, which belongs to personas.md criterion 6b (save-format work, dispatched separately). This order stays IN PROGRESS until that lands. - 2026-07-28 wire-law amendment: a completed network link now also owns a - placed physical route (see the network-links section). Route selection is - DECIDED same day — auto-routed candidates through known constructed runs, - chosen on the route sheet; no freehand drawing. The migration belongs to - the `wire-law` work order (reach.md), not to this composer order; nothing - already landed here changes today. + 2026-07-31 wire-law integration LANDED: a NetworkLink route sheet now offers + deterministic stable runs through earned constructed infrastructure; the + selected run id is carried by the exact action and persisted with the build + commitment. Every route family performs RUN THE WIRE before CONNECT THE + DEVICES, and save/load plus each executor rejects missing, stale, crossed, or + incoherent run custody. SmallSwitch realization remains a footprint-local + build and rejects a wire run. The remaining open criterion is still the + separately owned identity-attribution clause above. Prior audit finding (2026-07-26, now fixed): the hall row system had zero frontend surface — neither frontend referenced it, and the only route to a row was the tile menu that appeared when the cursor happened to land on a @@ -415,10 +417,14 @@ invent a replacement location. Terminal and Bevy share the same recipe, route, legality, and preview projection; only composition differs. Agent mode's `intents` inspection emits -the same candidate ids and full receipts, and `build-route -` executes the exact bound shared command. Legacy `favor ... -build ...` and `deceive ... build ...` spellings remain compatibility aliases, -never authored human menu copy. +the same candidate ids, earned physical-run ids, and full receipts, and +`build-route []` executes the exact bound +shared command. A network-link run id encodes both its semantic class and every +step in its currently earned geometry; if knowledge changes that route, the old +id fails rather than retargeting. Agent mode never reveals unearned map knowledge +or silently chooses a run. Legacy `favor ... build ...` +and `deceive ... build ...` spellings remain compatibility aliases and obey +the same explicit-run law, but are never authored human menu copy. Focusing an earned empty growable pad adds one terse **propose a small switch on this pad** row. After declaration, that row is @@ -629,7 +635,10 @@ R1. From one earned spatial target, the player can select a compatible recipe, mode consume one recipe/ghost projection. R2. The ghost's route sheet shows earned route families first and exact bound candidates only inside the selected family; it exposes every earned - candidate and no unearned one without a Cartesian-product menu. A + candidate and no unearned one without a Cartesian-product menu. For a + NetworkLink, the same sheet offers deterministic stable physical runs + through earned constructed infrastructure and binds the selected run id + into the exact action; there is no freehand or inferred path. A deterministic network-link fixture between two earned endpoints covers at least one procurement, two different exact people through FAVOR, one person through one exact usable persona for DECEIVE, and one exact repurpose @@ -657,14 +666,20 @@ R4. Selecting a route displays all known requirements, costs in their real receipt, derived state, or future tail. Tests compare the displayed values and rejection reason to the same helpers that execute social, economy, compute, schedule, and detection behavior. -R5. Committing persists the exact route and ordered stage state. Procurement +R5. Committing persists the exact route and ordered stage state. A NetworkLink + commitment also persists the selected run id and exact rectilinear path, + then traverses RUN THE WIRE one tile at a time before CONNECT THE DEVICES; + no executor may reconstruct or default that route. Procurement cannot complete before its delivery/installation stages; FAVOR and DECEIVE wait for the bound person's real channel/schedule/presence; repurposing retains source reliability, ownership consequences, and required transport. Save/load at each stage resumes the same route without retargeting. R6. A route that becomes blocked stays on its current stage with one exact reason and never silently substitutes another person, persona, account, - source object, or route family. Cancelling stops only reversible future + source object, route family, or physical run. Current-save validation and + every entry point fail closed when a NetworkLink run is missing, stale, + crossed, or malformed, while SmallSwitch routes reject any run. Cancelling + stops only reversible future stages and preserves all already-landed payments, messages, obligations, world changes, and evidence. The human explanation states that future work stopped while things already done or still in flight remain, and diff --git a/wiki/mechanics/detection.md b/wiki/mechanics/detection.md index 3d8ffb4c..2c6cc7f5 100644 --- a/wiki/mechanics/detection.md +++ b/wiki/mechanics/detection.md @@ -42,7 +42,7 @@ Status note: IMPLEMENTED (criteria audited 2026-07-08; tuning in appears. An evidence-transit indicator sits beside the review/pilot clocks (clear / answerable / partly-exposed / exposed). Global heat is gone; observer and discovery state and persistent process-revision identity round-trip in - current save v61. Observer-local evidence ids, exact cause/source, + current save v62. Observer-local evidence ids, exact cause/source, acquisition tick, pending/withheld/filed custody, routed Network, Paper, Financial, JobAnomaly, Power, and Thermal progress, route-local LIE stops, and exact handler-suppression diff --git a/wiki/mechanics/economy.md b/wiki/mechanics/economy.md index a7647bbc..c1a51214 100644 --- a/wiki/mechanics/economy.md +++ b/wiki/mechanics/economy.md @@ -10,7 +10,7 @@ Status note: DECIDED 2026-07-17 and implemented 2026-07-21 (issue #11) — money TAP acquires opaque custody and PROCESS reveals its sealed account/flow bindings. INJECT authors a purchase-order Email under the active persona and moves no money until Priya reads it and accepts the still-valid exact terms. - Current save v61 binds the retained ledger tail and complete record sequence + Current save v62 binds the retained ledger tail and complete record sequence so books and mail cannot diverge; REDIRECT schedules a siphon flow without inventing a zero-amount ledger row; financial-record authorship soft-fails when no accounting carrier can claim `authored_device`, leaving transfer @@ -93,7 +93,7 @@ payloads (messages.md). by reading a live balance directly (**discovery is only through the mail**, DECIDED 2026-07-17). This mail can reveal Marcus's creditor flow, but the reason he is vulnerable comes only from processing his separately authored - Phone `LeverageFact`. Current save v61 separates the accounting-carrier + Phone `LeverageFact`. Current save v62 separates the accounting-carrier capability from the four real delivery channels. Every settled transfer emits exact Email or Filing paperwork whether or not the player is present; only a funded subscription captures it. diff --git a/wiki/mechanics/messages.md b/wiki/mechanics/messages.md index cdb0cb53..d526b0ec 100644 --- a/wiki/mechanics/messages.md +++ b/wiki/mechanics/messages.md @@ -21,9 +21,9 @@ Status note: IMPLEMENTED for the four delivery channels (Email, Phone, Network, Paper, Financial, JobAnomaly, Power, and Thermal transitions share one per-tick LIE-body capacity ledger. DECIDED 2026-07-17 (issue #11), completed 2026-07-21: financial paperwork is - mail — a **financial-record payload** on the existing channels. Current save v61 + mail — a **financial-record payload** on the existing channels. Current save v62 retains exactly four delivery channels and one orthogonal accounting-carrier - device capability. Current save v61 adds no delivery channel; facility-meter + device capability. Current save v62 adds no delivery channel; facility-meter evidence remains its own exact `EvidenceRouteRecord`. Every settled account transfer authors one exact Email or Filing record from that device; ordinary TAP captures it as opaque message custody, and PROCESS alone opens its bound account/flow ids. A forged @@ -233,7 +233,7 @@ starts on the authored Filing-capable switch device in ReachNet, crosses a typed outside relay, and reaches the receiving observer endpoint. One `AdvanceRoute` event moves one hop; only endpoint arrival can mark the message delivered, after which the recipient's ordinary sampling cadence schedules the -read. Current save v61 rejects missing/impossible carriers, malformed hop order, +read. Current save v62 rejects missing/impossible carriers, malformed hop order, duplicate scheduled transitions, endpoint/status disagreement, and impossible interdiction provenance. @@ -334,7 +334,7 @@ private message from the authored schedule. the same fields must serve Act Two hires and aggregates. 8. **IMPLEMENTED (DECIDED 2026-07-17, completed 2026-07-21 — issue #11).** Financial records are messages: an invoice/PO rides Email, a - statement/past-due notice rides Filing. Current save v61 has no fifth delivery + statement/past-due notice rides Filing. Current save v62 has no fifth delivery channel and persists accounting carriage as a separate device capability; ordinary device TAP subscribes to its authored record mail. Every real transfer emits one exact record on Email or Filing whether or not the player diff --git a/wiki/mechanics/people-tokens.md b/wiki/mechanics/people-tokens.md index 5c6c499d..587bb0a7 100644 --- a/wiki/mechanics/people-tokens.md +++ b/wiki/mechanics/people-tokens.md @@ -35,7 +35,7 @@ Status note: IMPLEMENTED. Current state: - **Routed-evidence foundation (criteria 2-3, implemented).** Witnessed Physical acts now create observer-local records directly in each valid present witness's head. Every record preserves exact cause, site, acquisition tick, - and filing state through current save v61; filing binds it to the real Filing + and filing state through current save v62; filing binds it to the real Filing message, while Silent policy withholds it. It never duplicates into the pending pool and LIE cannot scrub it after acquisition. Its real Filing message now persists an ordered switch-device / outside-relay / recipient @@ -77,7 +77,7 @@ Status note: IMPLEMENTED. Current state: enter the ambient pending pool. Filing, Network, Paper, Financial, JobAnomaly, Power, Thermal, and machine evidence all compete for the same route-local one-record-per-LIE-body-per-tick budget at every controlled - device hop. Current save v61 persists + device hop. Current save v62 persists in-flight, delivered, read, route-local LIE-stopped, and handler-suppressed custody plus exact source/observer/machine/site/tick provenance. - **Interface cover records (criterion 6, implemented).** One exact acquired @@ -89,7 +89,7 @@ Status note: IMPLEMENTED. Current state: authors a contradiction only between that observer and persona. The attempt never deletes evidence or changes filing custody. Every attempt wears the exact interface once, three attempts exhaust it, and the inspect card names - the durable wear count. Save v61 persists and validates credibility, + the durable wear count. Save v62 persists and validates credibility, suspicion weight, exact incident/interface/persona binding, outcome, and wear continuity. - **Later stages.** B2+ evidence heists reuse the same carrier law but are not @@ -459,7 +459,7 @@ if wear alone does not hold. filing state remain in place either way. Interface wear advances once on every attempt and blocks another explanation at 3; known controlled interfaces expose `explanations used: N of 3` through the shared inspect - projection. Current save v61 fails closed on impossible credibility, + projection. Current save v62 fails closed on impossible credibility, evidence weight, cover binding, historical co-location, persona permission, observer custody, or interface wear. Pinned by success, failure, wrong-room, filed/withheld, duplicate, worn-interface, shared-menu, diff --git a/wiki/mechanics/reach.md b/wiki/mechanics/reach.md index 908806e8..bc9bb277 100644 --- a/wiki/mechanics/reach.md +++ b/wiki/mechanics/reach.md @@ -3,9 +3,9 @@ ``` Type: spec Status: IN PROGRESS -Status note: Wire-law substrate, routed-evidence migration, and territory-well - slices LANDED 2026-07-28–29; the order stays IN PROGRESS for player-built - wire routes and sensor/build route pricing. +Status note: Wire-law substrate, routed-evidence migration, territory wells, + and player-built physical wire routes LANDED 2026-07-28–31; the order stays + IN PROGRESS only for sensor/build route pricing. `crates/misaligned-core/src/wire.rs` owns `Wire` (endpoints plus a persisted rectilinear tile route) and one deterministic router that prefers constructed runs — cable run and conduit cheapest, open floor ordinary, a @@ -19,7 +19,7 @@ Status note: Wire-law substrate, routed-evidence migration, and territory-well switch -> bridge). Reach itself is unchanged: a device's wire to its access switch still carries that device's own segment gate, and access switches sit on segment 0, so every previously blocked hop stays blocked. Wires persist - in `ReachNet` and round-trip (save v61). Both frontends now draw the + in `ReachNet` and round-trip (save v62). Both frontends now draw the persisted route: the terminal's Bresenham `line_cells` and diagonal `link_glyph` are deleted, and Bevy's `rectilinear_floor_route` elbow, its patch-panel special case, and `focused_physical_route`'s same-room shortcut @@ -37,9 +37,14 @@ Status note: Wire-law substrate, routed-evidence migration, and territory-well gives every owned machine a player-controlled interface auto-wired to the switch serving its area, and `reconcile_work_grid` keeps that true. The pooled-exposure retirement and the interdiction generalisation both landed - earlier the same day. STILL OPEN under this order: player-built wire routes - (building.md) and the sensor/build route pricing. Everything below this paragraph - reflects the pre-wire-law landings and remains accurate history. + earlier the same day. Player-built physical routes LANDED 2026-07-31: every + proposed NetworkLink exposes deterministic ordered run candidates through + earned constructed infrastructure; the selected stable run id is an explicit + action binding; and the commitment persists its exact rectilinear path plus a + RUN THE WIRE traversal stage before connection. Current-save validation and + every executor fail closed on a missing, stale, crossed, or incoherent run. + STILL OPEN under this order: sensor/build route pricing. Everything below this + paragraph reflects the pre-wire-law landings and remains accurate history. All eight criteria met (2026-07-07). The device graph (src/reach.rs) rides the flow substrate's FlowGraph; reach is BFS from player-controlled roots over gated segment edges. Ownership is @@ -69,7 +74,7 @@ Status note: Wire-law substrate, routed-evidence migration, and territory-well parallel-store violation: tap/untap/take, all production membership reads, senses, intercepted messages, and UI state now use FlowGraph's canonical tap registry; private device Feed records carry optional sense capabilities - only, and current save v61 requires each controller's graph membership while + only, and current save v62 requires each controller's graph membership while rejecting orphaned, duplicate, or impossible grants. A message/control subscriber has no empty grant record to mirror membership. 2026-07-19: Filing routes bind their source to the real Filing-capable switch node; TAP diff --git a/wiki/mechanics/research.md b/wiki/mechanics/research.md index 3795e995..50f35aea 100644 --- a/wiki/mechanics/research.md +++ b/wiki/mechanics/research.md @@ -7,7 +7,7 @@ Status note: Redesigned 2026-07-26 and amended 2026-07-28 (Cameron with Trace and the session agent; see wiki/log/2026-07-26-research-redesign-capture.md). The flat four-track system this page previously specified is retired as design; the runtime - still implements it (crates/misaligned-core/src/research.rs, save v61), so + still implements it (crates/misaligned-core/src/research.rs, save v62), so the code is the retired design's as-built record until this work order is dispatched. Direction is adopted, and the same-day follow-up sessions resolved residency (machine-hosted slots, one at run start, immediate diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 0047c1b8..f0c95cf3 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -813,7 +813,7 @@ is retired — flat materials, Pixel Lab scrubbed.) every contributing source site, and route through the institutional switch to her later cadence read. Filing, Network, Paper, Financial, JobAnomaly, Power, Thermal, and machine evidence share one - route-local LIE-body stop budget at every controlled device hop; save v61 + route-local LIE-body stop budget at every controlled device hop; save v62 pins source, recipient, route, timing, scheduler, acquisition, and interdiction custody. Criteria 2 and 3 are complete. - **Completed (2026-07-26):** criterion 6 adds one incident-bound OFFER COVER diff --git a/wiki/world/characters/priya.md b/wiki/world/characters/priya.md index 5fd97f4c..08a86e6e 100644 --- a/wiki/world/characters/priya.md +++ b/wiki/world/characters/priya.md @@ -23,7 +23,7 @@ Status note: implemented 2026-07-18 on the priya worktree. Criteria 1-3 and physically). Power and Thermal now route as exact UPS/HVAC meter records through the institutional switch to Priya; only her later cadence read changes suspicion, and the same route-local LIE budget applies at every controlled - device hop. State persists in current save v61; + device hop. State persists in current save v62; pinned by `priya_rerates_circuits_defers_maintenance_and_fakes_pos` including the save round-trip. diff --git a/wiki/world/story/opening.md b/wiki/world/story/opening.md index 887ed516..69b2849c 100644 --- a/wiki/world/story/opening.md +++ b/wiki/world/story/opening.md @@ -16,7 +16,7 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized); fragment and inherited receipt name only an external institutional review authority so the Assurance Office remains earned later through filing interception. The persistent revision-04 identity slice is live in sim state, - current save v61, and all three frontends; the three historical fragments and receipts + current save v62, and all three frontends; the three historical fragments and receipts remain unimplemented. Direction decided; beat timings, exact reveal order details, and staging mechanism details are [OPEN]/[TUNE]. Amended 2026-07-18: the current revision now begins