From 7c68c4b7e4b1b302e446793417f0c3eba3819d23 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 15 Jul 2026 11:42:31 -0700 Subject: [PATCH] Bind exact building routes through one shared sheet. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep family-first human navigation, agent receipts, blocker checks, and person/persona execution on the same core-owned causal binding. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- crates/misaligned-bevy/src/main.rs | 292 +++++-- crates/misaligned-core/src/actions.rs | 757 ++++++++++++++++-- crates/misaligned-core/src/sim/reach_build.rs | 120 +-- crates/misaligned-core/src/sim/social_plot.rs | 18 +- .../src/sim/tests/reach_build.rs | 52 ++ crates/misaligned-terminal/src/agent.rs | 114 ++- crates/misaligned-terminal/src/main.rs | 127 ++- crates/misaligned-terminal/src/ui.rs | 25 +- wiki/interface/context-menu.md | 12 +- ...6-07-15-building-route-sheet-foundation.md | 83 ++ wiki/log/DEVLOG.md | 5 + wiki/mechanics/building.md | 39 +- wiki/mechanics/messages.md | 4 + 13 files changed, 1374 insertions(+), 274 deletions(-) create mode 100644 wiki/log/2026-07-15-building-route-sheet-foundation.md diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 7cb1027b..6897bb4a 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -21,7 +21,9 @@ use bevy::prelude::*; use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResizeConstraints, WindowResolution}; -use misaligned::actions::{ActionCommand, ActionKind, Anchor, DialId, HumanMenuRow, MenuRow}; +use misaligned::actions::{ + ActionCommand, ActionKind, Anchor, BuildRouteFamily, HumanMenuPage, HumanMenuRow, MenuRow, +}; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::{HALL_ROWS, RackSite}; use misaligned::intents::BuildGhostGeometry; @@ -545,7 +547,7 @@ enum RailSection { Secondary, } -/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|opening-digital|clinical-threat|operator-pressure|digital-reach|wide|close|dark| +/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|opening-digital|clinical-threat|operator-pressure|digital-reach|build-route-families|build-deceive-routes|wide|close|dark| /// zoomin|zoomout|intel|tokens|thoughtflow|first-think|visual-proof|person-proof|exposure-record|exposure-overflow|service-shift-real|service-shift-digital|service-incident-resolved|signal|ears|ears-digital|eyes-white|eyes-form|operations-links| /// hover-menu|menu|recruit-menu|operations|operations-intel|operations-personas|worklight|worklightoff`, path via /// `MISALIGNED_SHOT_PATH`): stages a scenario, @@ -940,8 +942,7 @@ struct MarqueeDrag { struct MenuState { anchor: Anchor, selected: usize, - /// None = root (status dials); Some = that dial's picker. - dial: Option, + page: HumanMenuPage, /// Window-pixel position to anchor the menu box at (the pointer, or the /// map cursor); `None` centers it (flow / unplaceable anchors). pos: Option, @@ -952,27 +953,31 @@ impl Game { fn menu_rows(&self) -> Vec { let tick_ms = if self.paused { 0 } else { self.tick_ms }; self.menu - .map(|m| self.sim.ui_projection(m.anchor, m.dial, tick_ms).human_menu) + .map(|m| self.sim.human_menu_page_at_rate(m.anchor, m.page, tick_ms)) .unwrap_or_default() } - /// Stable key for the current anchor + dial, so the menu UI knows when + /// 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 dial_bits = match self.menu.and_then(|m| m.dial) { - None => 0u64, - Some(DialId::Mode) => 1, - Some(DialId::Research) => 2, - }; - match self.menu.map(|m| m.anchor) { - Some(Anchor::Tile { x, y }) => { - 1 << 60 | (dial_bits << 56) | ((x as u32 as u64) << 20) | (y as u32 as u64) + let page_key = match self.menu.map(|menu| menu.page) { + None | Some(HumanMenuPage::Root) => 0, + Some(HumanMenuPage::Dial(dial)) => 1 + dial as u64, + Some(HumanMenuPage::BuildRoutes { intent, family }) => { + let family_key = family.map_or(0, |family| 1 + family as u64); + 0x9e37_79b9_7f4a_7c15u64 + .wrapping_mul(intent.wrapping_add(1)) + .wrapping_add(8 + family_key) } - Some(Anchor::Device(id)) => 2 << 60 | (dial_bits << 56) | id as u64, - Some(Anchor::Person(id)) => 3 << 60 | (dial_bits << 56) | id as u64, - Some(Anchor::Flow(id)) => 4 << 60 | (dial_bits << 56) | id as u64, + }; + let anchor_key = match self.menu.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, + Some(Anchor::Flow(id)) => 4 << 60 | id as u64, None => 0, - } + }; + anchor_key ^ page_key.rotate_left(17) } fn open_menu(&mut self, anchor: Anchor, pos: Option) { @@ -1002,7 +1007,7 @@ impl Game { self.menu = Some(MenuState { anchor, selected: 0, - dial: None, + page: HumanMenuPage::Root, pos, }); } @@ -1589,6 +1594,11 @@ struct MenuPanel; struct MenuRowButton { index: usize, } +/// Selected build-route receipt. Its text refreshes from live core projection +/// without rebuilding pointer-stable row entities. +#[derive(Component)] +struct MenuDetailText; +const MENU_DETAIL_WRAP: usize = 58; /// One line of the RECENT TRACE card (context-menu.md addendum: /// event-to-anchor linking). A fixed window of rows, oldest first; /// clicking a row whose event carries an anchor focuses it. @@ -1599,11 +1609,11 @@ struct LogRowButton { /// Rows shown in the RECENT TRACE card (the terminal shows six; the Bevy /// sidebar showed four before the rows became clickable — unchanged). const LOG_ROWS: usize = 4; -/// Tracks what the menu UI was last rebuilt for, so rows are respawned only -/// when the anchor or row count changes (selection/label refresh is cheap). +/// Tracks what the menu UI was last rebuilt for. Selection is included +/// because a build-route candidate owns a selected explanatory receipt. #[derive(Resource, Default)] struct MenuUi { - built: Option<(u64, usize)>, + built: Option<(u64, usize, usize)>, } // ─── Operations workspace UI (wiki/interface/operations-workspace.md) ─────── @@ -2106,6 +2116,42 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { game.drain(); return; } + // Building route-composer review frames: one inert network-link ghost, + // 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. + if matches!(kind, "build-route-families" | "build-deceive-routes") { + mode.material = false; + game.sim.reach.scan(); + let switch = game.sim.reach.device_named("switch").unwrap(); + let (switch_id, switch_pos) = (switch.id, (switch.x, switch.y)); + let island = game + .sim + .reach + .device_named("old storage server") + .unwrap() + .id; + game.sim.reach.device_mut(island).unwrap().known = true; + game.sim.people.people[0].knowledge = Knowledge::Schedule; + game.sim.people.people[0].obligation = Sim::FAVOR_BUILD_OBLIGATION; + game.sim.people.people[0].disposition = 10; + game.sim.people.people[1].knowledge = Knowledge::Schedule; + game.sim.people.has_channel = true; + game.sim.set_persona("Northline Ops", "contractor"); + let intent = game.sim.declare_link_intent(switch_id, island).unwrap(); + game.set_cursor(switch_pos.0, switch_pos.1); + game.menu = Some(MenuState { + anchor: Anchor::Device(switch_id), + selected: 0, + page: HumanMenuPage::BuildRoutes { + intent, + family: (kind == "build-deceive-routes").then_some(BuildRouteFamily::Deceive), + }, + pos: None, + }); + game.drain(); + return; + } // One complete Operations frame: earned accounts make both named scheme // cards legible, while absent egress leaves Moonlight selected with its // exact blocked reason and a focusable known switch actuator. @@ -6753,10 +6799,10 @@ fn menu_keyboard_input( execute_menu_row(game, &rows[selected]); } if kb.just_pressed(KeyCode::Escape) { - // Esc backs out of a dial picker before closing the menu. + // Esc backs out one explanatory level before closing the menu. if let Some(m) = &mut game.menu { - if m.dial.is_some() { - m.dial = None; + if let Some(parent) = m.page.parent() { + m.page = parent; m.selected = 0; } else { game.menu = None; @@ -6771,21 +6817,18 @@ fn menu_keyboard_input( /// Run a menu row: open a dial, execute a command, or narrate a blocked /// reason (justification-and-legibility). Closes the menu on a real execution. fn execute_menu_row(game: &mut Game, row: &HumanMenuRow) { - match row { - HumanMenuRow::Dial { id, .. } => { - if let Some(m) = &mut game.menu { - m.dial = Some(*id); - m.selected = 0; - } + if let Some(page) = row.child_page() { + if let Some(m) = &mut game.menu { + m.page = page; + m.selected = 0; } - HumanMenuRow::Action(action) => { - if let Some(reason) = &action.disabled { - let tick = game.sim.tick; - game.add_log(tick, &format!("{}: {}", action.label, reason)); - } else { - game.sim.execute_action(&action.command); - game.menu = None; - } + } else if let Some(action) = row.as_action() { + if let Some(reason) = &action.disabled { + let tick = game.sim.tick; + game.add_log(tick, &format!("{}: {}", action.label, reason)); + } else { + game.sim.execute_action(&action.command); + game.menu = None; } } } @@ -9322,8 +9365,11 @@ mod operations_focus_tests { #[cfg(test)] mod ui_projection_parity_tests { - use super::{Game, MenuState, sidebar_focus_text}; - use misaligned::actions::Anchor; + use super::{ + Game, MENU_DETAIL_WRAP, MenuState, execute_menu_row, menu_detail_lines, sidebar_focus_text, + }; + use misaligned::actions::{Anchor, BuildRouteFamily, HumanMenuPage, HumanMenuRow}; + use misaligned::person::Knowledge; use misaligned::ui_projection::fact_source_label; #[test] @@ -9334,7 +9380,7 @@ mod ui_projection_parity_tests { game.menu = Some(MenuState { anchor, selected: 0, - dial: None, + page: HumanMenuPage::Root, pos: None, }); @@ -9350,6 +9396,95 @@ mod ui_projection_parity_tests { let machine = projection.machine.expect("core focus has machine state"); assert_eq!(machine.work, game.sim.work_stack_for_machine(machine.id)); } + + #[test] + fn bevy_build_routes_descend_through_the_same_family_pages() { + let mut game = Game::new(); + let switch = game.sim.reach.device_named("switch").unwrap().id; + let island = game + .sim + .reach + .device_named("old storage server") + .unwrap() + .id; + game.sim.reach.device_mut(island).unwrap().known = true; + let intent = game.sim.declare_link_intent(switch, island).unwrap(); + game.sim.people.people[0].knowledge = Knowledge::Schedule; + game.sim.set_persona("Northline Ops", "contractor"); + game.menu = Some(MenuState { + anchor: Anchor::Device(switch), + selected: 0, + page: HumanMenuPage::Root, + pos: None, + }); + + let root_key = game.menu_anchor_key(); + let route_row = game + .menu_rows() + .into_iter() + .find(|row| matches!(row, HumanMenuRow::BuildRoutes { .. })) + .expect("one compact route-sheet entry"); + execute_menu_row(&mut game, &route_row); + assert_eq!( + game.menu.unwrap().page, + HumanMenuPage::BuildRoutes { + intent, + family: None, + } + ); + assert_ne!(root_key, game.menu_anchor_key()); + + let deceive = game + .menu_rows() + .into_iter() + .find(|row| { + matches!( + row, + HumanMenuRow::BuildRouteFamily { + family: BuildRouteFamily::Deceive, + .. + } + ) + }) + .unwrap(); + execute_menu_row(&mut game, &deceive); + assert_eq!( + game.menu.unwrap().page, + HumanMenuPage::BuildRoutes { + intent, + family: Some(BuildRouteFamily::Deceive), + } + ); + let candidates = game.menu_rows(); + assert!( + !candidates.is_empty() + && candidates + .iter() + .all(|row| matches!(row, HumanMenuRow::BuildRouteCandidate(_))) + ); + let detail = candidates[0].detail_lines().join("\n"); + assert!(detail.contains("RECEIPT deceive:person:0:persona:")); + assert!(detail.contains("PERSONA INTEGRITY")); + assert!(detail.contains("TAIL injects one email")); + let wrapped_detail = menu_detail_lines(&candidates[0]); + assert!( + wrapped_detail + .iter() + .all(|line| line.chars().count() <= MENU_DETAIL_WRAP), + "the selected receipt remains inside the supported-width menu card" + ); + assert!( + wrapped_detail.len() > candidates[0].detail_lines().len(), + "the long future tail is visibly wrapped rather than clipped" + ); + let selected_page = game.menu.unwrap().page; + execute_menu_row(&mut game, &candidates[0]); + assert_eq!( + game.menu.unwrap().page, + selected_page, + "a blocked exact candidate narrates but never executes or closes" + ); + } } #[cfg(test)] @@ -10890,6 +11025,55 @@ fn spawn_menu_card( )); }); } + let detail = rows + .get(selected.min(rows.len().saturating_sub(1))) + .map(menu_detail_lines) + .unwrap_or_default(); + if !detail.is_empty() { + parent.spawn(( + Text::new(ascii_ui(&detail.join("\n"))), + TextFont { + font_size: 10.0, + ..default() + }, + TextColor(DIM), + Node { + width: Val::Percent(100.0), + margin: UiRect::top(Val::Px(6.0)), + border: UiRect::top(Val::Px(1.0)), + ..default() + }, + BorderColor::all(GUNMETAL), + MenuDetailText, + )); + } +} + +fn menu_detail_lines(row: &HumanMenuRow) -> Vec { + row.detail_lines() + .into_iter() + .flat_map(|line| wrap_menu_detail_line(&line)) + .collect() +} + +fn wrap_menu_detail_line(line: &str) -> Vec { + let mut wrapped = Vec::new(); + let mut current = String::new(); + for word in line.split_whitespace() { + if !current.is_empty() + && current.chars().count() + 1 + word.chars().count() > MENU_DETAIL_WRAP + { + wrapped.push(std::mem::take(&mut current)); + } + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + } + if !current.is_empty() || line.is_empty() { + wrapped.push(current); + } + wrapped } /// Build and place the context-menu card (wiki/interface/context-menu.md). @@ -10904,6 +11088,7 @@ fn manage_menu_ui( windows: Query<&Window, With>, mut root_q: Query<(Entity, &mut Visibility, &mut Node), With>, mut rows_q: Query<(Entity, &MenuRowButton, &Children, &mut BackgroundColor)>, + detail_q: Query>, mut text_q: Query<&mut Text>, mut color_q: Query<&mut TextColor>, ) { @@ -10927,7 +11112,11 @@ fn manage_menu_ui( // Placement: anchor at the pointer/cursor pixel, else center; clamp so the // whole card stays on screen. Row height accounts for padding + gap. - let est_h = 30.0 + rows.len() as f32 * 22.0; + let detail_lines = rows + .get(menu.selected.min(rows.len().saturating_sub(1))) + .map(menu_detail_lines) + .unwrap_or_default(); + let est_h = 30.0 + rows.len() as f32 * 22.0 + detail_lines.len() as f32 * 18.0; if let Ok(window) = windows.single() { let (w, h) = (window.width(), window.height()); let (px, py) = match menu.pos { @@ -10944,17 +11133,12 @@ fn manage_menu_ui( node.top = Val::Px(py); } - let key = (game.menu_anchor_key(), rows.len()); + let key = (game.menu_anchor_key(), rows.len(), menu.selected); if menu_ui.built != Some(key) { // Full card rebuild: drop every child (title + rows), then spawn once. commands.entity(root).despawn_related::(); commands.entity(root).with_children(|p| { - spawn_menu_card( - p, - &rows, - menu.selected, - menu.dial.map(|d| d.picker_title()).unwrap_or("ACTIONS"), - ); + spawn_menu_card(p, &rows, menu.selected, menu.page.title()); }); menu_ui.built = Some(key); return; // children spawn next frame; refresh then @@ -10982,6 +11166,14 @@ fn manage_menu_ui( } } } + let detail_text = ascii_ui(&detail_lines.join("\n")); + for entity in &detail_q { + if let Ok(mut text) = text_q.get_mut(entity) + && text.0 != detail_text + { + text.0.clone_from(&detail_text); + } + } } // ─── Operations workspace systems (operations-workspace.md) ───────────────── diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 25d650cd..9a7abf73 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -19,7 +19,7 @@ use crate::detection::{Band, SignatureKind, WatchedInput}; use crate::hall::{HallRowId, SegmentRequirement}; use crate::intel::{IntelPolicyMatch, IntelPolicyOutcome, RawIntelKind, ReportLotToken}; use crate::person::{AssetKnowledge, AssetTask, Knowledge}; -use crate::persona::PersonaActionKind; +use crate::persona::{PersonaActionKind, PersonaId}; use crate::reach::{Party, ReachBlock, segment_name}; use crate::research::Track; use crate::sim::{Fog, Sim}; @@ -165,6 +165,7 @@ pub enum ActionCommand { ForgeWorkOrder { intent: u64, person: u8, + persona: PersonaId, }, RobotBuild(u64), /// Phase 3: ask the named human specialist to prepare one side of a @@ -919,11 +920,180 @@ impl DialId { pub const NAME_WIDTH: usize = 8; } +/// Earned causal route families for the current network-link composer slice. +/// +/// This is deliberately smaller than the eventual recipe registry: PROCURE +/// and REPURPOSE join only when they can bind real source objects and owning +/// actions. ROBOT remains absent while its action definition is a stub. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum BuildRouteFamily { + Favor, + Deceive, +} + +impl BuildRouteFamily { + pub fn label(self) -> &'static str { + match self { + Self::Favor => "FAVOR", + Self::Deceive => "DECEIVE", + } + } + + pub fn picker_title(self) -> &'static str { + match self { + Self::Favor => "FAVOR ROUTES", + Self::Deceive => "DECEIVE ROUTES", + } + } +} + +/// One exact current-world realization candidate. The bound command is the +/// canonical execution path; the remaining fields are explanatory receipt +/// data shared by human frontends and agent inspection. +#[derive(Debug, Clone, PartialEq)] +pub struct BuildRouteCandidateProjection { + pub id: String, + pub family: BuildRouteFamily, + pub person: u8, + pub persona: Option, + pub row: MenuRow, + pub future_tail: String, + pub persona_integrity: Option, +} + +#[derive(Debug, Clone)] +struct BoundBuildRoute { + id: String, + family: BuildRouteFamily, + person: u8, + persona: Option, + action: ActionDesc, + binding_label: String, + future_tail: String, + persona_integrity: Option, +} + +impl BuildRouteCandidateProjection { + pub fn ready(&self) -> bool { + self.row.enabled() + } +} + +/// Family-first grouping keeps the route sheet from flattening every exact +/// person/persona combination into the local context menu. +#[derive(Debug, Clone, PartialEq)] +pub struct BuildRouteFamilyProjection { + pub family: BuildRouteFamily, + pub candidates: Vec, +} + +impl BuildRouteFamilyProjection { + pub fn ready_count(&self) -> usize { + self.candidates + .iter() + .filter(|candidate| candidate.ready()) + .count() + } + + pub fn menu_label(&self) -> String { + format!( + "{} [{}/{} READY]", + self.family.label(), + self.ready_count(), + self.candidates.len() + ) + } +} + +/// Renderer-neutral route-sheet receipt for one exact build intent. +#[derive(Debug, Clone, PartialEq)] +pub struct BuildRouteSheetProjection { + pub intent: u64, + pub label: String, + pub families: Vec, +} + +impl BuildRouteSheetProjection { + pub fn candidate_count(&self) -> usize { + self.families + .iter() + .map(|family| family.candidates.len()) + .sum() + } + + pub fn ready_count(&self) -> usize { + self.families + .iter() + .map(BuildRouteFamilyProjection::ready_count) + .sum() + } + + pub fn menu_label(&self) -> String { + format!( + "BUILD ROUTES [{}/{} READY]", + self.ready_count(), + self.candidate_count() + ) + } +} + +/// 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)] +pub enum HumanMenuPage { + Root, + Dial(DialId), + BuildRoutes { + intent: u64, + family: Option, + }, +} + +impl HumanMenuPage { + pub fn title(self) -> &'static str { + match self { + Self::Root => "ACTIONS", + Self::Dial(dial) => dial.picker_title(), + Self::BuildRoutes { family: None, .. } => "BUILD ROUTES", + Self::BuildRoutes { + family: Some(family), + .. + } => family.picker_title(), + } + } + + pub fn parent(self) -> Option { + match self { + Self::Root => None, + Self::Dial(_) => Some(Self::Root), + Self::BuildRoutes { + intent, + family: Some(_), + } => Some(Self::BuildRoutes { + intent, + family: None, + }), + Self::BuildRoutes { family: None, .. } => Some(Self::Root), + } + } +} + /// One selectable row on the human ACTIONS menu. #[derive(Debug, Clone, PartialEq)] pub enum HumanMenuRow { /// Enter opens this dial's picker; no command. Dial { id: DialId, label: String }, + /// Enter opens the family-first route sheet for one exact intent. + BuildRoutes { intent: u64, label: String }, + /// Enter expands exact current-world candidates inside one family. + BuildRouteFamily { + intent: u64, + family: BuildRouteFamily, + label: String, + }, + /// One exact candidate plus the explanatory receipt shown on selection. + BuildRouteCandidate(BuildRouteCandidateProjection), /// Enter executes this currently available choice. Action(MenuRow), } @@ -931,22 +1101,31 @@ pub enum HumanMenuRow { impl HumanMenuRow { pub fn enabled(&self) -> bool { match self { - HumanMenuRow::Dial { .. } => true, - HumanMenuRow::Action(r) => r.enabled(), + HumanMenuRow::Dial { .. } + | HumanMenuRow::BuildRoutes { .. } + | HumanMenuRow::BuildRouteFamily { .. } => true, + HumanMenuRow::BuildRouteCandidate(candidate) => candidate.row.enabled(), + HumanMenuRow::Action(row) => row.enabled(), } } pub fn indent(&self) -> bool { match self { - HumanMenuRow::Dial { .. } => false, - HumanMenuRow::Action(r) => r.indent, + HumanMenuRow::Dial { .. } + | HumanMenuRow::BuildRoutes { .. } + | HumanMenuRow::BuildRouteFamily { .. } => false, + HumanMenuRow::BuildRouteCandidate(candidate) => candidate.row.indent, + HumanMenuRow::Action(row) => row.indent, } } pub fn role(&self) -> ActionRole { match self { - HumanMenuRow::Dial { .. } => ActionRole::Control, - HumanMenuRow::Action(r) => r.role, + HumanMenuRow::Dial { .. } + | HumanMenuRow::BuildRoutes { .. } + | HumanMenuRow::BuildRouteFamily { .. } => ActionRole::Control, + HumanMenuRow::BuildRouteCandidate(candidate) => candidate.row.role, + HumanMenuRow::Action(row) => row.role, } } @@ -956,15 +1135,20 @@ impl HumanMenuRow { /// Compact human-facing label shared by terminal and Bevy. /// - /// The menu is a choice list, not an action receipt: cost, signature, - /// and blocked-reason metadata stay on the descriptor for agent output - /// and other inspection surfaces. Human menus contain only executable - /// choices; controls remain distinct through their shared visual - /// treatment and dial behavior. + /// The root is a terse choice list, not an action receipt. Build routes + /// are the deliberate exception: their selected candidate owns a + /// secondary explanatory receipt while the row itself stays compact. pub fn display_text(&self) -> String { let (label, indent, active) = match self { - HumanMenuRow::Dial { label, .. } => (label.as_str(), false, false), - HumanMenuRow::Action(r) => (r.label.as_str(), r.indent, r.active), + HumanMenuRow::Dial { label, .. } + | HumanMenuRow::BuildRoutes { label, .. } + | HumanMenuRow::BuildRouteFamily { label, .. } => (label.as_str(), false, false), + HumanMenuRow::BuildRouteCandidate(candidate) => ( + candidate.row.label.as_str(), + candidate.row.indent, + candidate.row.active, + ), + HumanMenuRow::Action(row) => (row.label.as_str(), row.indent, row.active), }; let prefix = if indent { " " } else { "" }; if active && !label.contains("current") { @@ -976,15 +1160,61 @@ impl HumanMenuRow { pub fn as_action(&self) -> Option<&MenuRow> { match self { - HumanMenuRow::Action(r) => Some(r), - HumanMenuRow::Dial { .. } => None, + HumanMenuRow::BuildRouteCandidate(candidate) => Some(&candidate.row), + HumanMenuRow::Action(row) => Some(row), + HumanMenuRow::Dial { .. } + | HumanMenuRow::BuildRoutes { .. } + | HumanMenuRow::BuildRouteFamily { .. } => None, + } + } + + /// Explanatory receipt under the selected route candidate. Ordinary + /// compact menu rows deliberately have no secondary card. + pub fn detail_lines(&self) -> Vec { + let HumanMenuRow::BuildRouteCandidate(candidate) = self else { + return Vec::new(); + }; + let mut lines = vec![format!("RECEIPT {}", candidate.id)]; + if let Some(reason) = &candidate.row.disabled { + lines.push(format!("BLOCKER {reason}")); + } else { + lines.push("READY all current requirements pass".into()); + } + lines.push(format!("COST {}", candidate.row.cost)); + if let Some(signature) = &candidate.row.signature { + lines.push(format!("SIGNATURE {signature}")); } + if let Some(integrity) = candidate.persona_integrity { + lines.push(format!("PERSONA INTEGRITY {integrity}/100")); + } + lines.push(format!("TAIL {}", candidate.future_tail)); + lines } pub fn as_dial(&self) -> Option { match self { HumanMenuRow::Dial { id, .. } => Some(*id), - HumanMenuRow::Action(_) => None, + HumanMenuRow::BuildRoutes { .. } + | HumanMenuRow::BuildRouteFamily { .. } + | HumanMenuRow::BuildRouteCandidate(_) + | HumanMenuRow::Action(_) => None, + } + } + + pub fn child_page(&self) -> Option { + match self { + HumanMenuRow::Dial { id, .. } => Some(HumanMenuPage::Dial(*id)), + HumanMenuRow::BuildRoutes { intent, .. } => Some(HumanMenuPage::BuildRoutes { + intent: *intent, + family: None, + }), + HumanMenuRow::BuildRouteFamily { intent, family, .. } => { + Some(HumanMenuPage::BuildRoutes { + intent: *intent, + family: Some(*family), + }) + } + HumanMenuRow::BuildRouteCandidate(_) | HumanMenuRow::Action(_) => None, } } } @@ -998,6 +1228,15 @@ fn dial_of(command: &ActionCommand) -> Option { } } +fn build_route_intent(command: &ActionCommand) -> Option { + match command { + ActionCommand::FavorBuild { intent, .. } | ActionCommand::ForgeWorkOrder { intent, .. } => { + Some(*intent) + } + _ => None, + } +} + fn is_current_dial_reason(reason: Option<&str>) -> bool { matches!( reason, @@ -1178,8 +1417,28 @@ impl Sim { } } + /// Rebuild one exact menu page from current sim truth. The compact + /// context-menu root and standing dials retain their existing projection; + /// build routes add a family-first explanatory page without frontend-owned + /// legality or cached candidate state. + pub fn human_menu_page_at_rate( + &self, + anchor: Anchor, + page: HumanMenuPage, + tick_ms: u64, + ) -> Vec { + match page { + HumanMenuPage::Root => self.human_menu_at_rate(anchor, None, tick_ms), + HumanMenuPage::Dial(dial) => self.human_menu_at_rate(anchor, Some(dial), tick_ms), + HumanMenuPage::BuildRoutes { intent, family } => { + self.human_build_route_rows(intent, family) + } + } + } + fn human_root_rows(&self, actions: &[ActionDesc]) -> Vec { let mut rows = Vec::new(); + let mut routed_intents = std::collections::BTreeSet::new(); for dial in DialId::ALL { if !actions.iter().any(|a| dial_of(&a.command) == Some(dial)) { continue; @@ -1200,6 +1459,17 @@ impl Sim { if dial_of(&a.command).is_some() { continue; } + if let Some(intent) = build_route_intent(&a.command) { + if routed_intents.insert(intent) + && let Some(sheet) = self.build_route_sheet_projection(intent) + { + rows.push(HumanMenuRow::BuildRoutes { + intent, + label: sheet.menu_label(), + }); + } + continue; + } if a.enabled() { rows.push(HumanMenuRow::Action(MenuRow { label: a.verb.clone(), @@ -1222,6 +1492,39 @@ 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(); + }; + match selected_family { + None => sheet + .families + .into_iter() + .map(|family| HumanMenuRow::BuildRouteFamily { + intent, + family: family.family, + label: family.menu_label(), + }) + .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 { @@ -1402,8 +1705,12 @@ impl Sim { ActionCommand::FavorBuild { intent, person } => { self.assign_favor_build(*intent, *person); } - ActionCommand::ForgeWorkOrder { intent, person } => { - self.forge_work_order(*intent, *person); + ActionCommand::ForgeWorkOrder { + intent, + person, + persona, + } => { + self.forge_work_order_as(*intent, *person, *persona); } ActionCommand::RobotBuild(id) => self.assign_robot_build(*id), ActionCommand::CoordinateSegment { row, requirement } => { @@ -1925,7 +2232,6 @@ impl Sim { if a != id && b != id { continue; } - let link_label = intent.label(&self.reach.devices); out.push(ActionDesc { verb: format!("cancel link intent ({})", intent.status_line()), command: ActionCommand::CancelIntent(intent.id), @@ -1934,80 +2240,257 @@ impl Sim { disabled_reason: None, automate: None, }); - // FAVOR / DECEIVE: earned people who can reach both ends. - // Filtering before `person_label` prevents even a role silhouette - // from leaking an unknown candidate (building.md criterion 9). - for p in &self.people.people { - if !self.person_is_earned(p.id) { - continue; - } - let room_a = self.world.map().room_at( - self.reach.device(a).map(|d| d.x).unwrap_or(0), - self.reach.device(a).map(|d| d.y).unwrap_or(0), + if intent.actuator.is_none() { + out.extend( + self.bound_build_routes(intent) + .into_iter() + .map(|candidate| candidate.action), ); - let room_b = self.world.map().room_at( - self.reach.device(b).map(|d| d.x).unwrap_or(0), - self.reach.device(b).map(|d| d.y).unwrap_or(0), - ); - let (Some(ra), Some(rb)) = (room_a, room_b) else { - continue; - }; - if !p.can_access_link_rooms(&ra.name, &rb.name) { - continue; - } - let who = self.person_label(p.id); - let favor_blocked = - if p.asset.is_none() && p.obligation < Self::FAVOR_BUILD_OBLIGATION { - Some(format!("{who} needs obligation or asset status")) - } else if p.disposition < 5 && p.asset.is_none() { - Some(format!("{who} won't do favors yet")) - } else { - self.sink_action_blocked_reason(&SinkFireEffect::FavorBuild { - intent_id: intent.id, - person: p.id, - }) - }; - out.push(ActionDesc { + } + } + out + } + + /// Every exact, earned baseline route for one inert network-link intent. + /// Unknown people are filtered before labels are resolved. DECEIVE binds + /// one concrete active persona instance into both its candidate id and + /// executable command; no later selection can silently substitute it. + fn bound_build_routes(&self, intent: &crate::intents::BuildIntent) -> Vec { + if !intent.is_open() || intent.actuator.is_some() { + return Vec::new(); + } + let link_label = intent.label(&self.reach.devices); + let mut people: Vec<_> = self + .people + .people + .iter() + .filter(|person| self.person_is_earned(person.id)) + .collect(); + people.sort_by_key(|person| person.id); + let mut personas: Vec<_> = self + .persona_world + .instances + .iter() + .filter(|persona| { + self.persona_world + .allows_action(persona.id, PersonaActionKind::BuildIntent) + }) + .collect(); + personas.sort_by_key(|persona| persona.id); + + let mut routes = Vec::new(); + for person in people { + let who = self.person_label(person.id); + let favor_blocked = self.favor_build_blocked_reason(intent.id, person.id); + routes.push(BoundBuildRoute { + id: format!("favor:person:{}", person.id), + family: BuildRouteFamily::Favor, + person: person.id, + persona: None, + action: ActionDesc { verb: format!("favor {who}: complete {link_label}"), command: ActionCommand::FavorBuild { intent: intent.id, - person: p.id, + person: person.id, }, cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::TASK_COST)), signature: self .signature_note(SignatureKind::Physical, Self::FAVOR_BUILD_PHYSICAL), disabled_reason: favor_blocked, automate: None, - }); - // Forged order: persona + channel gated. - let forge_blocked = if let Some(reason) = - self.persona_action_blocked_reason(PersonaActionKind::BuildIntent) - { - Some(reason) - } else if !self.people.has_channel { - Some("no comms channel".into()) - } else { - self.sink_action_blocked_reason(&SinkFireEffect::ForgedOrder { - intent_id: intent.id, - builder: p.id, - persona_id: self.active_persona_id(), - }) - }; - out.push(ActionDesc { - verb: format!("deceive {who}: complete {link_label}"), - command: ActionCommand::ForgeWorkOrder { - intent: intent.id, - person: p.id, + }, + binding_label: who.clone(), + future_tail: format!( + "uses {who}'s exact relationship and spends up to {} obligation (asset status can authorize at zero); waits for that exact person at either endpoint; useful completion changes that relationship", + Self::FAVOR_BUILD_OBLIGATION + ), + persona_integrity: None, + }); + + for persona in &personas { + let persona_id = persona.id; + let persona_label = persona.name.clone(); + let forge_blocked = + self.forged_build_blocked_reason(intent.id, person.id, persona_id); + routes.push(BoundBuildRoute { + id: format!( + "deceive:person:{}:persona:{persona_id}", + person.id + ), + family: BuildRouteFamily::Deceive, + person: person.id, + persona: Some(persona_id), + action: ActionDesc { + verb: format!( + "deceive {who} as {persona_label}: complete {link_label}" + ), + command: ActionCommand::ForgeWorkOrder { + intent: intent.id, + person: person.id, + persona: persona_id, + }, + cost: ActionCost::Thought(Self::thought_tokens_for_cost( + Self::DECEIVE_COST, + )), + signature: self.signature_note( + SignatureKind::Physical, + Self::FORGED_BUILD_PHYSICAL, + ), + disabled_reason: forge_blocked, + automate: None, }, - cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::DECEIVE_COST)), - signature: self - .signature_note(SignatureKind::Physical, Self::FORGED_BUILD_PHYSICAL), - disabled_reason: forge_blocked, - automate: None, + binding_label: format!("{who} via {persona_label} #{persona_id}"), + future_tail: format!( + "injects one email as {persona_label} #{persona_id}; waits for delivery, read, and {who} on-site; contradiction risk remains on that exact identity" + ), + persona_integrity: Some(self.persona_world.integrity(persona_id)), }); } } - out + routes.sort_by(|left, right| { + left.family + .cmp(&right.family) + .then(left.person.cmp(&right.person)) + .then(left.persona.cmp(&right.persona)) + }); + routes + } + + /// Shared route-sheet projection consumed by terminal, Bevy, and agent + /// mode. Every read recomputes current blockers; no candidate list lives + /// in frontend or save state before commitment. + pub fn build_route_sheet_projection( + &self, + intent_id: u64, + ) -> Option { + let intent = self.intents.iter().find(|intent| intent.id == intent_id)?; + let routes = self.bound_build_routes(intent); + let mut families = Vec::new(); + for family in [BuildRouteFamily::Favor, BuildRouteFamily::Deceive] { + let candidates: Vec<_> = routes + .iter() + .filter(|candidate| candidate.family == family) + .map(|candidate| { + let mut row = menu_rows(&[candidate.action.clone()]).remove(0); + row.label = match &row.disabled { + Some(reason) => { + format!("{} — BLOCKED: {reason}", candidate.binding_label) + } + None => format!("{} — READY", candidate.binding_label), + }; + BuildRouteCandidateProjection { + id: candidate.id.clone(), + family: candidate.family, + person: candidate.person, + persona: candidate.persona, + row, + future_tail: candidate.future_tail.clone(), + persona_integrity: candidate.persona_integrity, + } + }) + .collect(); + if !candidates.is_empty() { + families.push(BuildRouteFamilyProjection { family, candidates }); + } + } + if families.is_empty() { + return None; + } + Some(BuildRouteSheetProjection { + intent: intent.id, + label: intent.label(&self.reach.devices), + families, + }) + } + + pub(crate) fn build_person_access_blocked_reason( + &self, + intent_id: u64, + person_id: u8, + ) -> Option { + let Some(intent) = self.intents.iter().find(|intent| intent.id == intent_id) else { + return Some("no such build intent".into()); + }; + let Some(person) = self.people.get(person_id) else { + return Some("no such person".into()); + }; + let Some((a, b)) = intent.kind.endpoints() else { + return Some("recipe has no physical endpoints".into()); + }; + let (Some(room_a), Some(room_b)) = (self.device_room_name(a), self.device_room_name(b)) + else { + return Some("both endpoints need a known room".into()); + }; + if !person.can_access_link_rooms(&room_a, &room_b) { + return Some(format!("{} can't reach both ends", person.name)); + } + self.badge_room_block(&person.name, person.access, &[&room_a, &room_b]) + } + + pub(crate) fn favor_build_blocked_reason( + &self, + intent_id: u64, + person_id: u8, + ) -> Option { + let Some(intent) = self.intents.iter().find(|intent| intent.id == intent_id) else { + return Some("no such build intent".into()); + }; + if !intent.is_open() { + return Some("intent is no longer open".into()); + } + if intent.actuator.is_some() { + return Some("intent already has a committed actuator".into()); + } + if let Some(reason) = self.build_person_access_blocked_reason(intent_id, person_id) { + return Some(reason); + } + let Some(person) = self.people.get(person_id) else { + return Some("no such person".into()); + }; + let who = self.person_label(person_id); + if person.asset.is_none() && person.obligation < Self::FAVOR_BUILD_OBLIGATION { + return Some(format!("{who} needs obligation or asset status")); + } + if person.disposition < 5 && person.asset.is_none() { + return Some(format!("{who} won't do favors yet")); + } + self.sink_action_blocked_reason(&SinkFireEffect::FavorBuild { + intent_id, + person: person_id, + }) + } + + pub(crate) fn forged_build_blocked_reason( + &self, + intent_id: u64, + person_id: u8, + persona_id: PersonaId, + ) -> Option { + let Some(intent) = self.intents.iter().find(|intent| intent.id == intent_id) else { + return Some("no such build intent".into()); + }; + if !intent.is_open() { + return Some("intent is no longer open".into()); + } + if intent.actuator.is_some() { + return Some("intent already has a committed actuator".into()); + } + if let Some(reason) = self.build_person_access_blocked_reason(intent_id, person_id) { + return Some(reason); + } + if let Some(reason) = + self.persona_action_blocked_reason_for(persona_id, PersonaActionKind::BuildIntent) + { + return Some(reason); + } + if !self.people.has_channel { + return Some("no comms channel".into()); + } + self.sink_action_blocked_reason(&SinkFireEffect::ForgedOrder { + intent_id, + builder: person_id, + persona_id: Some(persona_id), + }) } /// OPEN EGRESS is an action on the switch itself (reach.md route): it @@ -3276,11 +3759,123 @@ mod tests { ); s.people.people[0].knowledge = Knowledge::Schedule; + assert_eq!( + actuator_people(&s), + vec![0], + "without a usable exact persona only the earned FAVOR route exists" + ); + + s.set_persona("Northline Ops", "contractor"); let people = actuator_people(&s); assert_eq!( people, vec![0, 0], - "the one earned compatible person supplies FAVOR and DECEIVE only" + "a usable exact persona adds one DECEIVE binding for the earned person" + ); + } + + #[test] + fn build_route_sheet_is_family_first_and_binds_each_exact_persona() { + let mut s = sim(); + let sw = switch(&s); + let island = s.reach.device_named("old storage server").unwrap().id; + s.reach.device_mut(island).unwrap().known = true; + let intent = s.declare_link_intent(sw, island).expect("declare"); + s.people.people[0].knowledge = Knowledge::Schedule; + s.people.people[1].knowledge = Knowledge::Schedule; + s.set_persona("Northline Ops", "contractor"); + let first_persona = s.active_persona_id().unwrap(); + s.set_persona("Night Facilities", "contractor"); + let second_persona = s.active_persona_id().unwrap(); + + let sheet = s + .build_route_sheet_projection(intent) + .expect("earned routes"); + assert_eq!(sheet.intent, intent); + assert_eq!( + sheet + .families + .iter() + .map(|family| family.family) + .collect::>(), + vec![BuildRouteFamily::Favor, BuildRouteFamily::Deceive] + ); + assert_eq!(sheet.families[0].candidates.len(), 2); + assert_eq!(sheet.families[1].candidates.len(), 4); + assert_eq!( + sheet.families[1] + .candidates + .iter() + .map(|candidate| candidate.id.clone()) + .collect::>(), + vec![ + format!("deceive:person:0:persona:{first_persona}"), + format!("deceive:person:0:persona:{second_persona}"), + format!("deceive:person:1:persona:{first_persona}"), + format!("deceive:person:1:persona:{second_persona}"), + ] + ); + for candidate in &sheet.families[1].candidates { + let ActionCommand::ForgeWorkOrder { + intent, + person, + persona, + } = &candidate.row.command + else { + panic!("DECEIVE candidates execute the forged-order path") + }; + assert_eq!(Some(*persona), candidate.persona); + assert_eq!( + candidate.row.disabled, + s.forged_build_blocked_reason(*intent, *person, *persona), + "the receipt and execution consult one exact blocker helper" + ); + assert!(candidate.persona_integrity.is_some()); + assert!(candidate.future_tail.contains("that exact identity")); + } + for candidate in &sheet.families[0].candidates { + let ActionCommand::FavorBuild { intent, person } = &candidate.row.command else { + panic!("FAVOR candidates execute the existing favor-build path") + }; + assert_eq!( + candidate.row.disabled, + s.favor_build_blocked_reason(*intent, *person), + "the receipt and execution consult one exact blocker helper" + ); + } + + let root = s.human_menu_page_at_rate( + Anchor::Device(sw), + HumanMenuPage::Root, + Sim::DEFAULT_TICK_MS, + ); + assert_eq!( + root.iter() + .filter(|row| matches!(row, HumanMenuRow::BuildRoutes { .. })) + .count(), + 1, + "the compact root contains one route-sheet entry, not six candidates" + ); + assert!(root.iter().all(|row| { + !matches!( + row.as_action().map(|action| &action.command), + Some(ActionCommand::FavorBuild { .. } | ActionCommand::ForgeWorkOrder { .. }) + ) + })); + + let families = s.human_menu_page_at_rate( + Anchor::Device(sw), + HumanMenuPage::BuildRoutes { + intent, + family: None, + }, + Sim::DEFAULT_TICK_MS, + ); + assert_eq!(families.len(), 2); + assert!( + families + .iter() + .all(|row| matches!(row, HumanMenuRow::BuildRouteFamily { .. })) ); } diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs index 9fc87e92..eb97bcf7 100644 --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -656,54 +656,8 @@ 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) { - let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { - self.push_log("No such build intent."); - return; - }; - if !intent.is_open() { - self.push_log("That intent is no longer open."); - return; - } - let (name, is_asset, obligation, disposition, can_access, badge_reason) = { - let Some(person) = self.people.get(person_id) else { - self.push_log("No such person."); - return; - }; - let Some((a, b)) = intent.kind.endpoints() else { - return; - }; - let (room_a, room_b) = match (self.device_room_name(a), self.device_room_name(b)) { - (Some(ra), Some(rb)) => (ra, rb), - _ => { - self.push_log("Both endpoints need a known room."); - return; - } - }; - ( - person.name.clone(), - person.asset.is_some(), - person.obligation, - person.disposition, - person.can_access_link_rooms(&room_a, &room_b), - self.badge_room_block(&person.name, person.access, &[&room_a, &room_b]), - ) - }; - if !is_asset && obligation < Self::FAVOR_BUILD_OBLIGATION { - self.push_log(format!( - "{name} won't take a build favor yet (need obligation or an asset)." - )); - return; - } - if disposition < 5 && !is_asset { - self.push_log(format!("{name} won't do favors yet.")); - return; - } - if !can_access { - self.push_log(format!("{name} can't reach both ends of that link.")); - return; - } - if let Some(reason) = badge_reason { - self.push_log(format!("{reason}.")); + if let Some(reason) = self.favor_build_blocked_reason(intent_id, person_id) { + self.push_log(format!("Build route blocked: {reason}.")); return; } self.open_email_reservoir( @@ -749,55 +703,26 @@ 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) { - let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { - self.push_log("No such build intent."); + let Some(persona_id) = self.active_persona_id() else { + self.push_log("No active persona."); return; }; - if !intent.is_open() { - self.push_log("That intent is no longer open."); - return; - } - if !self.people.has_channel { - self.push_log("No comms channel — earn the email account first."); - return; - } - let (builder_name, can_access, badge_reason, _label) = { - let Some(builder) = self.people.get(builder_id) else { - self.push_log("No such person."); - return; - }; - let Some((a, b)) = intent.kind.endpoints() else { - return; - }; - let (room_a, room_b) = match (self.device_room_name(a), self.device_room_name(b)) { - (Some(ra), Some(rb)) => (ra, rb), - _ => { - self.push_log("Both endpoints need a known room."); - return; - } - }; - ( - builder.name.clone(), - builder.can_access_link_rooms(&room_a, &room_b), - self.badge_room_block(&builder.name, builder.access, &[&room_a, &room_b]), - intent.label(&self.reach.devices), - ) - }; - if !can_access { - self.push_log(format!( - "{builder_name} can't reach both ends of that link." - )); - return; - } - if let Some(reason) = badge_reason { - self.push_log(format!("{reason} — the forged order would just stall.")); - return; - } - if let Some(reason) = self.persona_action_blocked_reason(PersonaActionKind::BuildIntent) { - self.push_log(reason); + self.forge_work_order_as(intent_id, builder_id, persona_id); + } + + /// Exact route-sheet execution. The persona id was bound when the + /// candidate was projected and is revalidated here; changing the selected + /// identity cannot substitute a different author for the forged order. + pub fn forge_work_order_as( + &mut self, + intent_id: u64, + builder_id: u8, + persona_id: crate::persona::PersonaId, + ) { + if let Some(reason) = self.forged_build_blocked_reason(intent_id, builder_id, persona_id) { + self.push_log(format!("Build route blocked: {reason}.")); return; } - let persona_id = self.active_persona_id().expect("validated active persona"); self.open_email_reservoir( format!("FORGED ORDER {intent_id}"), Self::DECEIVE_COST, @@ -918,7 +843,7 @@ impl Sim { self.try_complete_intent(intent_id); } - fn device_room_name(&self, id: u32) -> Option { + pub(crate) fn device_room_name(&self, id: u32) -> Option { let d = self.reach.device(id)?; self.world.map().room_at(d.x, d.y).map(|r| r.name.clone()) } @@ -1437,7 +1362,12 @@ impl Sim { /// asset tasks, favor builds, forged orders, and the robot stub — /// doors gate player-directed work exactly as they gate human /// movement (basement-map.md criterion 3). - fn badge_room_block(&self, actor: &str, access: i32, rooms: &[&str]) -> Option { + pub(crate) fn badge_room_block( + &self, + actor: &str, + access: i32, + rooms: &[&str], + ) -> Option { for name in rooms { let Some(room) = self.world.map().room_named(name) else { continue; diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index 939aa299..4d0dadc3 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -42,13 +42,23 @@ impl Sim { let Some(persona_id) = self.active_persona_id() else { return Some("no active persona".into()); }; + self.persona_action_blocked_reason_for(persona_id, action) + } + + /// Validate one exact identity rather than whatever happens to be selected + /// when a previously projected route executes. + pub(crate) fn persona_action_blocked_reason_for( + &self, + persona_id: PersonaId, + action: PersonaActionKind, + ) -> Option { + let Some(instance) = self.persona_world.get(persona_id) else { + return Some(format!("persona #{persona_id} no longer exists")); + }; if self.persona_world.allows_action(persona_id, action) { None } else { - let label = self - .persona_world - .get(persona_id) - .map_or("selected", |instance| instance.archetype_label.as_str()); + let label = instance.archetype_label.as_str(); Some(format!( "{label} identities cannot authorize {}", action.label() diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs index 49f816f1..0026d25a 100644 --- a/crates/misaligned-core/src/sim/tests/reach_build.rs +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -1,4 +1,5 @@ use super::*; +use crate::save::SaveState; #[test] fn unreachable_actions_fail_legibly_and_succeed_once_bridged() { @@ -425,6 +426,57 @@ fn forged_work_order_joins_airgap_via_message() { assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Done); } +#[test] +fn forged_route_executes_the_bound_persona_not_the_selected_one() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.set_persona("Northline Ops", "contractor"); + let bound_persona = sim.active_persona_id().unwrap(); + sim.set_persona("Night Facilities", "contractor"); + let selected_persona = sim.active_persona_id().unwrap(); + assert_ne!(bound_persona, selected_persona); + sim.scan_network(); + finish_ops(&mut sim); + 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.forge_work_order_as(intent, 1, bound_persona); + let sink = sim + .thought_sinks + .open_sinks() + .find(|sink| { + matches!( + sink.effect, + SinkFireEffect::ForgedOrder { + intent_id, + builder: 1, + persona_id: Some(persona), + } if intent_id == intent && persona == bound_persona + ) + }) + .expect("the exact route opens one sink bound to its projected persona"); + assert!(sink.label.contains(&intent.to_string())); + assert_eq!(sim.active_persona_id(), Some(selected_persona)); + + let state = SaveState::from_sim(&sim); + let mut restored = Sim::with_seed(0); + state.apply_to(&mut restored); + assert_eq!(restored.active_persona_id(), Some(selected_persona)); + assert!(restored.thought_sinks.open_sinks().any(|sink| { + matches!( + sink.effect, + SinkFireEffect::ForgedOrder { + intent_id, + builder: 1, + persona_id: Some(persona), + } if intent_id == intent && persona == bound_persona + ) + })); +} + #[test] fn robot_stub_emits_louder_physical_than_favor() { // building.md criterion 4: signature follows the actuator. The diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 4dfc1992..059e2337 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -7,7 +7,7 @@ use std::collections::BTreeSet; use std::io::{self, BufRead, Write}; -use misaligned::actions::{ActionKind, ActionRole, Anchor, MenuRow, menu_rows}; +use misaligned::actions::{ActionCommand, ActionKind, ActionRole, Anchor, MenuRow, menu_rows}; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::RackSite; use misaligned::intel::{IntelRoutineClass, ReportLotToken}; @@ -422,10 +422,18 @@ impl AgentApp { .any(|token| token.eq_ignore_ascii_case("build")) => { match self.build_request_from(&tokens[1..]) { - Ok((intent, person)) => { + Ok((intent, person, persona)) => { self.frame = FrameKind::Playing; if verb == "favor" { - self.sim.assign_favor_build(intent, person); + if persona.is_some() { + status = Status::Err( + "FAVOR routes do not bind a persona".into(), + ); + } else { + self.sim.assign_favor_build(intent, person); + } + } else if let Some(persona) = persona { + self.sim.forge_work_order_as(intent, person, persona); } else { self.sim.forge_work_order(intent, person); } @@ -1129,21 +1137,36 @@ impl AgentApp { self.resolve_person(&tokens.join(" ")) } - fn build_request_from(&self, tokens: &[&str]) -> Result<(u64, u8), String> { + fn build_request_from(&self, tokens: &[&str]) -> Result<(u64, u8, 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 != tokens.len() { - 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:]" + .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]))?; - Ok((intent, person)) + 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)) } fn resolve_device(&self, query: &str) -> Result { @@ -1666,6 +1689,39 @@ fn render_intents(sim: &Sim) -> Vec { receipt.label, receipt.status_line )); + let Some(sheet) = sim.build_route_sheet_projection(receipt.id) else { + continue; + }; + for family in sheet.families { + lines.push(format!(" {}", family.menu_label())); + for candidate in family.candidates { + let mut line = format!( + " {} · {} · {}", + candidate.id, candidate.row.label, candidate.row.cost + ); + if let Some(signature) = &candidate.row.signature { + line.push_str(&format!(" · {signature}")); + } + if let Some(integrity) = candidate.persona_integrity { + line.push_str(&format!(" · persona integrity {integrity}/100")); + } + lines.push(line); + match candidate.row.command { + ActionCommand::FavorBuild { intent, person } => { + lines.push(format!(" execute: favor #{person} build {intent}")) + } + ActionCommand::ForgeWorkOrder { + intent, + person, + persona, + } => lines.push(format!( + " execute: deceive #{person} build {intent} persona:{persona}" + )), + _ => {} + } + lines.push(format!(" tail: {}", candidate.future_tail)); + } + } } lines } @@ -1714,7 +1770,10 @@ fn help_lines() -> Vec { "focus last", "jump attention to the newest anchored event and list its actions", ), - ("intents", "list pending build intents"), + ( + "intents", + "list build receipts, route families, exact candidates, and bound execution", + ), ("objective", "explain the run goal and victory predicate"), ("look", "render current state without advancing time"), ("save | load", "persist or restore the run"), @@ -3347,8 +3406,14 @@ mod narration_tests { #[test] fn canonical_build_request_reuses_social_verbs() { let app = AgentApp::new(1); - assert_eq!(app.build_request_from(&["#0", "build", "7"]), Ok((7, 0))); - assert_eq!(app.build_request_from(&["#0", "BUILD", "8"]), Ok((8, 0))); + assert_eq!( + app.build_request_from(&["#0", "build", "7"]), + Ok((7, 0, None)) + ); + assert_eq!( + app.build_request_from(&["#0", "BUILD", "8", "persona:4"]), + Ok((8, 0, Some(4))) + ); assert!( app.build_request_from(&["#0", "7"]).is_err(), "BUILD separates an intent target from an ordinary social ask" @@ -3372,6 +3437,35 @@ mod narration_tests { assert!(lines.contains(&expected_status), "{lines}"); } + #[test] + fn agent_intents_emit_shared_candidate_ids_and_exact_bound_execution() { + let mut sim = Sim::with_seed(1); + 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(); + sim.people.people[0].knowledge = misaligned::person::Knowledge::Schedule; + sim.set_persona("Northline Ops", "contractor"); + let persona = sim.active_persona_id().unwrap(); + + let lines = render_intents(&sim).join("\n"); + assert!(lines.contains("FAVOR ["), "{lines}"); + assert!(lines.contains("DECEIVE ["), "{lines}"); + assert!(lines.contains("favor:person:0"), "{lines}"); + assert!( + lines.contains(&format!("deceive:person:0:persona:{persona}")), + "{lines}" + ); + assert!( + lines.contains(&format!( + "execute: deceive #0 build {intent} persona:{persona}" + )), + "{lines}" + ); + assert!(lines.contains("tail:"), "{lines}"); + } + #[test] fn help_is_generated_from_live_registry_entries_and_hides_aliases() { let lines = help_lines(); diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index d03c902f..feacd3ec 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -17,7 +17,7 @@ use crossterm::{ execute, terminal::{self, ClearType}, }; -use misaligned::actions::{ActionCommand, Anchor, DialId, HumanMenuRow}; +use misaligned::actions::{ActionCommand, Anchor, HumanMenuPage, HumanMenuRow}; use misaligned::operations_projection::OperationsTarget; use misaligned::origin::Origin; use misaligned::person::Knowledge; @@ -58,14 +58,13 @@ impl ViewMode { } /// The open context menu (wiki/interface/context-menu.md). Only the anchor, -/// selection, and optional open dial are stored; rows are re-queried from +/// 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)] struct MenuState { anchor: Anchor, selected: usize, - /// None = root (status dials); Some = that dial's picker. - dial: Option, + page: HumanMenuPage, /// True when opened at the map cursor (renders there); false when /// opened on a non-map anchor (flow / unplaceable — renders centered). at_cursor: bool, @@ -163,7 +162,7 @@ impl App { fn menu_rows(&self) -> Vec { let tick_ms = if self.paused { 0 } else { self.tick_ms }; self.menu - .map(|m| self.sim.ui_projection(m.anchor, m.dial, tick_ms).human_menu) + .map(|m| self.sim.human_menu_page_at_rate(m.anchor, m.page, tick_ms)) .unwrap_or_default() } @@ -186,7 +185,7 @@ impl App { self.menu = Some(MenuState { anchor, selected: 0, - dial: None, + page: HumanMenuPage::Root, at_cursor, }); } @@ -377,7 +376,12 @@ impl App { self.sim.execute_action(&action.command); } } - Some(HumanMenuRow::Dial { .. }) => {} + Some( + HumanMenuRow::Dial { .. } + | HumanMenuRow::BuildRoutes { .. } + | HumanMenuRow::BuildRouteFamily { .. } + | HumanMenuRow::BuildRouteCandidate(_), + ) => {} None => { self.ui.add_log( self.sim.tick, @@ -546,25 +550,27 @@ impl App { if let Some(m) = self.menu && let Some(row) = rows.get(m.selected.min(rows.len().saturating_sub(1))) { - match row { - HumanMenuRow::Dial { id, .. } => { - if let Some(menu) = &mut self.menu { - menu.dial = Some(*id); - menu.selected = 0; - } + if let Some(page) = row.child_page() { + if let Some(menu) = &mut self.menu { + menu.page = page; + menu.selected = 0; } - HumanMenuRow::Action(action) => { + } else if let Some(action) = row.as_action() { + if action.enabled() { self.sim.execute_action(&action.command); self.menu = None; + } else if let Some(reason) = &action.disabled { + self.ui + .add_log(self.sim.tick, &format!("Route blocked: {reason}.")); } } } } Command::MenuClose => { - // Esc backs out of a dial picker before closing the menu. + // Esc backs out one explanatory level before closing. if let Some(m) = &mut self.menu { - if m.dial.is_some() { - m.dial = None; + if let Some(parent) = m.page.parent() { + m.page = parent; m.selected = 0; } else { self.menu = None; @@ -745,7 +751,7 @@ impl App { self.menu = None; } else { let selected = m.selected.min(rows.len() - 1); - let title = m.dial.map(|d| d.picker_title()).unwrap_or("ACTIONS"); + let title = m.page.title(); self.ui.render_menu( stdout, &rows, @@ -975,7 +981,8 @@ mod options_tests { #[cfg(test)] mod view_flip_tests { use super::{App, Command, MenuState, ViewMode}; - use misaligned::actions::Anchor; + use misaligned::actions::{Anchor, BuildRouteFamily, HumanMenuPage, HumanMenuRow}; + use misaligned::person::Knowledge; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; @@ -998,7 +1005,7 @@ mod view_flip_tests { y: app.cursor_y, }, selected: 2, - dial: None, + page: HumanMenuPage::Root, at_cursor: true, }); @@ -1027,7 +1034,7 @@ mod view_flip_tests { app.menu = Some(MenuState { anchor, selected: 0, - dial: None, + page: HumanMenuPage::Root, at_cursor: true, }); @@ -1036,6 +1043,84 @@ mod view_flip_tests { app.sim.ui_projection(anchor, None, app.tick_ms).human_menu ); } + + #[test] + fn terminal_build_routes_descend_and_back_out_through_shared_pages() { + let mut app = App::with_seed(19); + 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(); + app.sim.people.people[0].knowledge = Knowledge::Schedule; + app.sim.set_persona("Northline Ops", "contractor"); + app.menu = Some(MenuState { + anchor: Anchor::Device(switch), + selected: 0, + page: HumanMenuPage::Root, + at_cursor: false, + }); + + let root = app.menu_rows(); + let routes = root + .iter() + .position(|row| matches!(row, HumanMenuRow::BuildRoutes { .. })) + .expect("one compact route-sheet entry"); + app.menu.as_mut().unwrap().selected = routes; + assert!(!app.handle_command(Command::MenuExecute)); + assert_eq!( + app.menu.unwrap().page, + HumanMenuPage::BuildRoutes { + intent, + family: None, + } + ); + + let families = app.menu_rows(); + let deceive = families + .iter() + .position(|row| { + matches!( + row, + HumanMenuRow::BuildRouteFamily { + family: BuildRouteFamily::Deceive, + .. + } + ) + }) + .unwrap(); + app.menu.as_mut().unwrap().selected = deceive; + assert!(!app.handle_command(Command::MenuExecute)); + assert_eq!( + app.menu.unwrap().page, + HumanMenuPage::BuildRoutes { + intent, + family: Some(BuildRouteFamily::Deceive), + } + ); + let candidates = app.menu_rows(); + assert!( + candidates + .iter() + .all(|row| matches!(row, HumanMenuRow::BuildRouteCandidate(_))) + ); + let detail = candidates[0].detail_lines().join("\n"); + assert!(detail.contains("RECEIPT deceive:person:0:persona:")); + assert!(detail.contains("PERSONA INTEGRITY")); + assert!(detail.contains("TAIL injects one email")); + + assert!(!app.handle_command(Command::MenuClose)); + assert_eq!( + app.menu.unwrap().page, + HumanMenuPage::BuildRoutes { + intent, + family: None, + } + ); + assert!(!app.handle_command(Command::MenuClose)); + assert_eq!(app.menu.unwrap().page, HumanMenuPage::Root); + assert!(!app.handle_command(Command::MenuClose)); + assert!(app.menu.is_none()); + } } /// The recording-review hotkeys (wiki/interface/context-menu.md R1-R3): diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index ea479290..37cfcb5d 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -1756,15 +1756,25 @@ impl UI { title: &str, ) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; + let raw_detail = rows + .get(selected.min(rows.len().saturating_sub(1))) + .map(|row| row.detail_lines()) + .unwrap_or_default(); // Width fits the longest compact label; height fits title + rows. let widest = rows .iter() .map(|r| menu_row_text(r).chars().count()) + .chain(raw_detail.iter().map(|line| line.chars().count())) .max() .unwrap_or(10) .max(title.len() + 4); let w = (widest + 4).clamp(24, max_x as usize - 2) as u16; - let h = (rows.len() + 2).clamp(3, max_y as usize - 2) as u16; + let inner = (w - 4) as usize; + let detail: Vec<_> = raw_detail + .iter() + .flat_map(|line| wrap(line, inner)) + .collect(); + let h = (rows.len() + detail.len() + 2).clamp(3, max_y as usize - 2) as u16; // Anchor near the cursor's screen cell when opened on the map, else // center. The map viewport clamps around the cursor the same way @@ -1783,8 +1793,6 @@ impl UI { } else { ((max_x.saturating_sub(w)) / 2, (max_y.saturating_sub(h)) / 2) }; - let inner = (w - 4) as usize; - frame(stdout, ox, oy, w, h, title)?; let cx = ox + 2; for (i, r) in rows.iter().enumerate() { @@ -1814,6 +1822,17 @@ impl UI { put(stdout, cx, y, &trunc(&text, inner), color)?; } } + let detail_room = (h as usize).saturating_sub(rows.len() + 2); + for (i, line) in detail.iter().take(detail_room).enumerate() { + let y = oy + 1 + rows.len() as u16 + i as u16; + put( + stdout, + cx, + y, + line, + if i == 0 { pal::AMBER_DIM } else { pal::DIM }, + )?; + } Ok(()) } diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index e62d9838..3052a022 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -50,6 +50,11 @@ Status note: recording-review hotkeys (criteria R1-R4 below) IMPLEMENTED 2026-07-10: day-job target and drift-policy dials are removed. Focused / selected machines use `i` to cycle persistent intensity directly; this frequent physical control deliberately has no explanatory picker. + 2026-07-15: building.md's IN-PROGRESS route composer adds a second shared + nested-page owner. One build-route entry opens family-first and then exact + candidate pages; terminal and Bevy consume the same `HumanMenuPage` tree, + show the selected candidate's explanatory receipt, and Esc back one level + at a time. This does not reopen the implemented spatial-menu baseline. 2026-07-10 vocabulary survey: action-vocabulary.md now owns canonical player-facing names and the LIVE/STUB/alias/retired classification. 2026-07-10 action-contract pass: `ActionKind` metadata is the shared @@ -261,8 +266,9 @@ wire or host that transports them. - **Terminal:** cursor + Enter (or `a`/`e`) opens a compact overlay list at the cursor (or the primary selected machine when a selection is - active); j/k or numbers select; Enter executes (or opens a dial - picker); esc closes the picker or the menu. Rows render currently available + active); j/k or numbers select; Enter executes (or opens a core-projected + explanatory page such as a dial picker or build route sheet); esc backs out + one page at a time, then closes the menu. Rows render currently available verbs only; illegal actions do not occupy rows or selection slots. Dials and other controls use the shared amber-dim treatment without a literal copy tag; the card carries no repeated key-help footer. Rows follow the status-dials addendum below. Selection hotkeys above @@ -273,7 +279,7 @@ wire or host that transports them. active plots. - **Bevy:** right-click moves focus to the pointed map tile and opens its menu; Enter/`e` opens the keyboard cursor's tile without moving focus. It has the - same content, order, and dial chrome; click to execute or open a dial; + same content, order, and nested-page chrome; click to execute or open a page; esc/click-away closes. The compact card is narrow, has no instructional footer, and shares the same verb-only rows as terminal. Selection is a solid amber wash with a leading `>` (terminal diff --git a/wiki/log/2026-07-15-building-route-sheet-foundation.md b/wiki/log/2026-07-15-building-route-sheet-foundation.md new file mode 100644 index 00000000..71a1a8af --- /dev/null +++ b/wiki/log/2026-07-15-building-route-sheet-foundation.md @@ -0,0 +1,83 @@ +# Exact routes without a Cartesian menu + +``` +Type: log +``` + +## Finding + +The baseline network-link build already had FAVOR and DECEIVE actuators, but +its action surface flattened them directly onto the local world menu. That +shape could not carry the causal route-composer contract: + +- adding a second earned person multiplied root rows; +- DECEIVE consulted whichever persona happened to be selected at execution, + rather than preserving the identity the player inspected; +- terminal and Bevy had no shared family/candidate navigation state; and +- agent `intents` showed only the intent receipt, not its current realization + candidates or an exact executable binding. + +The simulation helpers also returned only booleans from the mutation path, so +a frontend could not show the same first blocker the command would enforce. + +## Change + +Core now projects one live route sheet for an exact network-link intent. The +sheet groups current-world candidates by FAVOR and DECEIVE before exposing +exact bindings. It applies the same earned-person gate as Operations, retains +known blocked candidates inside the selected family, and recomputes every read +without new save state. + +Each candidate carries: + +- a stable family/person/persona id; +- the canonical existing `ActionCommand`; +- current readiness or the first owning-rule blocker; +- Thought cost and actuator-following signature; +- exact persona integrity for DECEIVE; and +- the honest future tail after commitment. + +`HumanMenuPage` now owns the root → family → candidate tree. The human root +contains one terse route-sheet row instead of a person×verb product. Terminal +and Bevy render the same family ordering and the selected candidate's full +receipt, execute the same bound command, and back out one level at a time. +Agent `intents` prints the same candidate ids and exact commands. + +The route-sheet `ForgeWorkOrder` command now requires one exact `PersonaId`. +The legacy agent alias still resolves the selected persona at invocation and +then enters that exact execution path. The existing persisted forged-order sink +already stores `Option` for old-save compatibility; every newly +queued route stores `Some(exact id)`. Projection and execution call one +exact-persona blocker helper. Once bound, changing the selected persona cannot +retarget the queued reservoir, its message origin, or the persona act receipt. + +This is a partial R2/R3/R7 seam over existing person actuators. It does not +claim procurement, repurposing, switch placement, full stage/observer bands, +persisted route commitment, or staged cancellation. + +## Evidence + +- Core route projection regression: two earned people remain two FAVOR + candidates while two exact personas become four DECEIVE candidates, with + stable ordering, distinct ids, current blockers, integrity, costs, + signatures, and future tails. +- Core execution regression: an exact projected persona survives a different + active selection and a save round trip as the identity recorded by the + forged-order sink. +- Terminal and Bevy regressions: both descend through the same page tree and + expose the same selected receipt lines; terminal backs out one level at a + time, while Bevy also pins that a blocked candidate neither executes nor + closes the page. +- Agent regression: `intents` emits the shared ids and exact + `favor … build …` / `deceive … build … persona:…` commands; parser tests pin + the optional persona suffix and reject it on FAVOR. +- Visually inspected exact deterministic Bevy PNGs after final capture: + `/tmp/build-route-families.png` shows one calm two-family card over the + existing DIGITAL topology; `/tmp/build-deceive-routes.png` shows exact + person/persona rows and the selected complete receipt without obscuring the + world or turning the root into a dashboard. + +Defense: candidate legality and mutation share core helpers; exact causal ids +survive frontend selection and Thought delay; no frontend reconstructs route +ordering or silently substitutes a person or persona. Reading and navigating +the sheet is sim-neutral. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 1b6f7eab..90a54380 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -26,6 +26,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-15-canonical-build-intent-endpoints.md](2026-07-15-canonical-build-intent-endpoints.md) +## 2026-07-15 - Exact routes without a Cartesian menu + +- Intent: (see session log) +- Log: [wiki/log/2026-07-15-building-route-sheet-foundation.md](2026-07-15-building-route-sheet-foundation.md) + ## 2026-07-15 - One build ghost truth - Intent: (see session log) diff --git a/wiki/mechanics/building.md b/wiki/mechanics/building.md index 0520c85d..69491783 100644 --- a/wiki/mechanics/building.md +++ b/wiki/mechanics/building.md @@ -17,8 +17,14 @@ Status note: The 2026-07-08 implemented baseline includes saved link intents, composer and multi-stage route state are not yet implemented. The 2026-07-15 first composer foundation now projects every baseline intent as one renderer-neutral recipe/lifecycle/geometry receipt consumed by terminal, - Bevy, and agent mode; route candidates, route sheets, placement, and staged - commitment remain outstanding. + Bevy, and agent mode. The second foundation adds a renderer-neutral, + family-first route sheet for the existing network-link FAVOR and DECEIVE + paths: earned people become exact candidates, each DECEIVE route binds one + exact usable persona instance, terminal and Bevy traverse the same page + tree, and agent `intents` prints the same ids and bound execution. This is a + partial R2/R3/R7 seam, not completion: procurement, repurposing, switch + placement, full stage/observer previews, persisted commitment, and staged + cancellation remain outstanding. Stage: B1 — The Basement Work order: building-route-composer @@ -286,11 +292,30 @@ actions. Player-facing execution reuses existing verbs — FAVOR and DECEIVE for people, source-owner verbs for procurement/repurposing. “Favor-build” and “forge-order” may survive as compatibility aliases, never authored menu copy. -**Defense:** This shared projection is the first executable boundary of R1 and -R7. It preserves the honesty clause by deriving one ghost from exact sim truth, -keeping declaration inert, retaining terminal/Bevy/agent parity, and removing -the ghost without erasing its historical receipt when an intent is cancelled. -It adds no route candidate, payment, reservation, graph edge, or save state. +The live second foundation implements that navigation only for the existing +network-link person actuators. One terse route-sheet row replaces the old +FAVOR×DECEIVE candidate product on the human root. The next page lists FAVOR +and DECEIVE first; selecting one lists its earned exact people, and DECEIVE +lists each exact usable persona instance for each earned person. Known blocked +candidates remain visible with their current owning-rule rejection, Thought +cost, actuator-following signature, persona integrity where applicable, and +future tail. The candidate list is recomputed on every read rather than saved. +The exact person/persona ids live on the canonical action command, and +execution calls the same blocker helper used by the receipt before opening the +existing Thought reservoir. Changing the selected persona cannot retarget a +previously projected DECEIVE route. Terminal and Bevy back out one shared page +at a time; agent `intents` prints the same stable candidate ids plus exact +`favor … build …` / `deceive … build … persona:…` commands. + +**Defense:** The shared ghost and route-sheet projections are the executable +R1 plus partial R2/R3/R7 boundary. Declaration remains inert; reading or +navigating the sheet spends nothing and changes no reach, reservation, +relationship, message, or save state. Candidate legality and execution share +one core helper, exact persona ids survive through the existing forged-order +reservoir, and every frontend consumes core-owned ordering rather than +reconstructing it. Procurement, repurpose, switch placement, complete R4 +preview bands, and persisted R5/R6 stages are deliberately absent, so the work +order remains IN PROGRESS. ## Acceptance criteria diff --git a/wiki/mechanics/messages.md b/wiki/mechanics/messages.md index af6be598..0322632e 100644 --- a/wiki/mechanics/messages.md +++ b/wiki/mechanics/messages.md @@ -94,6 +94,10 @@ scope here. - **Send** (social.md unchanged): requires a channel you have (the report email account is the first) and a persona. Effects land when the message is *read*, not when sent. +- **Forged build order** (building.md route composer): binds one exact usable + persona before opening the existing email Thought reservoir. Delivery, + read-time acceptance, and presence still ride this message system; changing + the selected persona later cannot retarget the queued message's source. - **Tap**: subscribing to the device that carries a channel (reach.md tap of the ticket server / a phone line later) captures its traffic as raw events into the intel.md buffer — reading Dana's tickets is -- 2.51.2