diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 6897bb4a..a8cc0cf8 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -78,6 +78,10 @@ const COMPUTE_CHANNELS: usize = 4; /// Compact context-menu card width; human rows carry choices, not descriptor /// receipts (wiki/interface/context-menu.md). const MENU_WIDTH: f32 = 300.0; +/// A selected build family is a deliberate explanatory route sheet rather +/// than another compact world menu. This width keeps every manually wrapped +/// receipt line within the actual text node at the supported minimum window. +const BUILD_ROUTE_RECEIPT_WIDTH: f32 = 880.0; /// The hover grammar grows only when several actionable bodies share a tile. /// Width follows the union rather than reserving an empty HUD-sized strip. const MACHINE_VERB_BAR_WIDTH: f32 = 320.0; @@ -1598,7 +1602,8 @@ struct MenuRowButton { /// without rebuilding pointer-stable row entities. #[derive(Component)] struct MenuDetailText; -const MENU_DETAIL_WRAP: usize = 58; +const MENU_DETAIL_WRAP: usize = 84; +const MENU_DETAIL_FONT_SIZE: f32 = 10.0; /// 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. @@ -9366,7 +9371,9 @@ mod operations_focus_tests { #[cfg(test)] mod ui_projection_parity_tests { use super::{ - Game, MENU_DETAIL_WRAP, MenuState, execute_menu_row, menu_detail_lines, sidebar_focus_text, + BUILD_ROUTE_RECEIPT_WIDTH, Game, MENU_DETAIL_FONT_SIZE, MENU_DETAIL_WRAP, + MIN_WINDOW_HEIGHT, MenuState, execute_menu_row, menu_card_estimated_height, + menu_detail_lines, menu_panel_width, sidebar_focus_text, }; use misaligned::actions::{Anchor, BuildRouteFamily, HumanMenuPage, HumanMenuRow}; use misaligned::person::Knowledge; @@ -9410,6 +9417,7 @@ mod ui_projection_parity_tests { 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.people.people[1].knowledge = Knowledge::Schedule; game.sim.set_persona("Northline Ops", "contractor"); game.menu = Some(MenuState { anchor: Anchor::Device(switch), @@ -9464,20 +9472,37 @@ mod ui_projection_parity_tests { ); let detail = candidates[0].detail_lines().join("\n"); assert!(detail.contains("RECEIPT deceive:person:0:persona:")); + assert!(detail.contains("REQUIRES ")); + assert!(detail.contains("COST THOUGHT / EMAIL CARRIER")); + assert!(detail.contains("STAGE 1 FILL ORDER")); + assert!(detail.contains("SIGNATURE NONE")); assert!(detail.contains("PERSONA INTEGRITY")); assert!(detail.contains("TAIL injects one email")); - let wrapped_detail = menu_detail_lines(&candidates[0]); + let selected_page = game.menu.unwrap().page; + assert_eq!(menu_panel_width(selected_page), BUILD_ROUTE_RECEIPT_WIDTH); assert!( - wrapped_detail - .iter() - .all(|line| line.chars().count() <= MENU_DETAIL_WRAP), - "the selected receipt remains inside the supported-width menu card" + MENU_DETAIL_WRAP as f32 * MENU_DETAIL_FONT_SIZE <= BUILD_ROUTE_RECEIPT_WIDTH - 16.0, + "manual receipt wrapping must fit the actual text node without a hidden second wrap" ); + for (selected, candidate) in candidates.iter().enumerate() { + let wrapped = menu_detail_lines(candidate); + let estimated_height = menu_card_estimated_height(&candidates, selected); + assert!( + estimated_height <= MIN_WINDOW_HEIGHT, + "every complete two-candidate receipt must fit the supported 960x540 frame: candidate {selected} needs {estimated_height}px" + ); + assert!( + wrapped + .iter() + .all(|line| line.chars().count() <= MENU_DETAIL_WRAP), + "every receipt remains inside the supported-width route sheet" + ); + } + let wrapped_detail = menu_detail_lines(&candidates[0]); 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, @@ -10973,8 +10998,9 @@ fn clear_menu_panel( commands.entity(root).despawn_related::(); } -/// Spawn the compact ACTIONS card under `root`: title and one button per row. -/// Caller must have already cleared the panel's children. +/// Spawn the ACTIONS card under `root`: compact for ordinary world actions, +/// screen-dominant enough for the building-owned causal receipt on a selected +/// route. Caller must have already cleared the panel's children. fn spawn_menu_card( parent: &mut ChildSpawnerCommands, rows: &[HumanMenuRow], @@ -11033,7 +11059,7 @@ fn spawn_menu_card( parent.spawn(( Text::new(ascii_ui(&detail.join("\n"))), TextFont { - font_size: 10.0, + font_size: MENU_DETAIL_FONT_SIZE, ..default() }, TextColor(DIM), @@ -11056,6 +11082,28 @@ fn menu_detail_lines(row: &HumanMenuRow) -> Vec { .collect() } +fn menu_panel_width(page: HumanMenuPage) -> f32 { + if matches!( + page, + HumanMenuPage::BuildRoutes { + family: Some(_), + .. + } + ) { + BUILD_ROUTE_RECEIPT_WIDTH + } else { + MENU_WIDTH + } +} + +fn menu_card_estimated_height(rows: &[HumanMenuRow], selected: usize) -> f32 { + let detail_lines = rows + .get(selected.min(rows.len().saturating_sub(1))) + .map(menu_detail_lines) + .unwrap_or_default(); + 30.0 + rows.len() as f32 * 22.0 + detail_lines.len() as f32 * 18.0 +} + fn wrap_menu_detail_line(line: &str) -> Vec { let mut wrapped = Vec::new(); let mut current = String::new(); @@ -11111,21 +11159,25 @@ fn manage_menu_ui( *vis = Visibility::Visible; // Placement: anchor at the pointer/cursor pixel, else center; clamp so the - // whole card stays on screen. Row height accounts for padding + gap. + // whole card stays on screen. A selected build family widens into its + // explanatory receipt so the manually wrapped lines cannot wrap again in + // the UI engine and push later stages below the supported frame. 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; + let est_h = menu_card_estimated_height(&rows, menu.selected); + let panel_width = menu_panel_width(menu.page); + node.width = Val::Px(panel_width); if let Ok(window) = windows.single() { let (w, h) = (window.width(), window.height()); let (px, py) = match menu.pos { Some(p) => ( - p.x.min(w - MENU_WIDTH).max(0.0), + p.x.min(w - panel_width).max(0.0), p.y.min((h - est_h).max(0.0)).max(0.0), ), None => ( - ((w - MENU_WIDTH) / 2.0).max(0.0), + ((w - panel_width) / 2.0).max(0.0), ((h - est_h) / 2.0).max(0.0), ), }; diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 2cd47bd4..c8044bd9 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -950,6 +950,63 @@ impl BuildRouteFamily { /// 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, Eq)] +pub struct BuildRouteRequirementProjection { + pub label: String, + pub blocker: Option, +} + +impl BuildRouteRequirementProjection { + pub fn ready(&self) -> bool { + self.blocker.is_none() + } + + pub fn line(&self) -> String { + match &self.blocker { + Some(reason) => format!("REQUIRES {} · BLOCKED: {reason}", self.label), + None => format!("REQUIRES {} · READY", self.label), + } + } +} + +/// A cost in the vocabulary of the system that actually owns and spends it. +/// Relationship obligation is not flattened into Thought or generic currency. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuildRouteOwnerCostProjection { + pub owner: String, + pub amount: String, + pub timing: String, +} + +impl BuildRouteOwnerCostProjection { + pub fn line(&self) -> String { + format!("COST {} · {} · {}", self.owner, self.amount, self.timing) + } +} + +/// One causal transition in a route. A stage with no emitted signature says +/// so explicitly; silence must not be inferred from an omitted preview row. +#[derive(Debug, Clone, PartialEq)] +pub struct BuildRouteStageProjection { + pub label: String, + pub outcome: String, + pub signature: Option, +} + +impl BuildRouteStageProjection { + pub fn line(&self, ordinal: usize) -> String { + let signature = self + .signature + .as_ref() + .map(ExpectedSignature::label) + .unwrap_or_else(|| "NONE".into()); + format!( + "STAGE {ordinal} {} · {} · SIGNATURE {signature}", + self.label, self.outcome + ) + } +} + #[derive(Debug, Clone, PartialEq)] pub struct BuildRouteCandidateProjection { pub id: String, @@ -957,6 +1014,9 @@ pub struct BuildRouteCandidateProjection { pub person: u8, pub persona: Option, pub row: MenuRow, + pub requirements: Vec, + pub owner_costs: Vec, + pub stages: Vec, pub future_tail: String, pub persona_integrity: Option, } @@ -969,6 +1029,9 @@ struct BoundBuildRoute { persona: Option, action: ActionDesc, binding_label: String, + requirements: Vec, + owner_costs: Vec, + stages: Vec, future_tail: String, persona_integrity: Option, } @@ -977,6 +1040,42 @@ impl BuildRouteCandidateProjection { pub fn ready(&self) -> bool { self.row.enabled() } + + pub fn first_blocker(&self) -> Option<&str> { + self.requirements + .iter() + .find_map(|requirement| requirement.blocker.as_deref()) + } + + pub fn detail_lines(&self) -> Vec { + let mut lines = vec![format!("RECEIPT {}", self.id)]; + if let Some(reason) = &self.row.disabled { + lines.push(format!("BLOCKER {reason}")); + } else { + lines.push("READY all current requirements pass".into()); + } + lines.extend( + self.requirements + .iter() + .map(BuildRouteRequirementProjection::line), + ); + lines.extend( + self.owner_costs + .iter() + .map(BuildRouteOwnerCostProjection::line), + ); + lines.extend( + self.stages + .iter() + .enumerate() + .map(|(index, stage)| stage.line(index + 1)), + ); + if let Some(integrity) = self.persona_integrity { + lines.push(format!("PERSONA INTEGRITY {integrity}/100")); + } + lines.push(format!("TAIL {}", self.future_tail)); + lines + } } /// Family-first grouping keeps the route sheet from flattening every exact @@ -1174,21 +1273,7 @@ impl HumanMenuRow { 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 + candidate.detail_lines() } pub fn as_dial(&self) -> Option { @@ -2281,7 +2366,12 @@ impl Sim { 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); + let relationship_cost = person.obligation.min(Self::FAVOR_BUILD_OBLIGATION); + let favor_requirements = self.favor_build_requirements(intent.id, person.id); + let favor_blocked = Self::first_build_route_blocker(&favor_requirements); + let favor_cost = ActionCost::Thought(Self::thought_tokens_for_cost(Self::TASK_COST)); + let favor_signature = + self.signature_note(SignatureKind::Physical, Self::FAVOR_BUILD_PHYSICAL); routes.push(BoundBuildRoute { id: format!("favor:person:{}", person.id), family: BuildRouteFamily::Favor, @@ -2293,16 +2383,52 @@ impl Sim { intent: intent.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), + cost: favor_cost, + signature: favor_signature.clone(), disabled_reason: favor_blocked, automate: None, }, binding_label: who.clone(), + requirements: favor_requirements, + owner_costs: vec![ + BuildRouteOwnerCostProjection { + owner: "THOUGHT / EMAIL CARRIER".into(), + amount: favor_cost.label(), + timing: "fills before the favor commits".into(), + }, + BuildRouteOwnerCostProjection { + owner: format!("RELATIONSHIP / {who}"), + amount: format!("{relationship_cost} obligation"), + timing: "spent when the reservoir fires".into(), + }, + ], + stages: vec![ + BuildRouteStageProjection { + label: "FILL REQUEST".into(), + outcome: format!("{} enters the email-carrier reservoir", favor_cost.label()), + signature: None, + }, + BuildRouteStageProjection { + label: "COMMIT FAVOR".into(), + outcome: format!( + "bind {who}; spend {relationship_cost} relationship obligation" + ), + signature: None, + }, + BuildRouteStageProjection { + label: "WAIT FOR SITE".into(), + outcome: format!("{who}'s authored schedule reaches either endpoint"), + signature: None, + }, + BuildRouteStageProjection { + label: "INSTALL LINK".into(), + outcome: "mutate the device graph; useful work changes the exact relationship" + .into(), + signature: favor_signature, + }, + ], 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 + "uses {who}'s exact relationship and spends {relationship_cost} current obligation (asset status can authorize below the normal threshold); waits for that exact person at either endpoint; useful completion changes that relationship" ), persona_integrity: None, }); @@ -2310,8 +2436,13 @@ impl Sim { 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); + let forge_requirements = + self.forged_build_requirements(intent.id, person.id, persona_id); + let forge_blocked = Self::first_build_route_blocker(&forge_requirements); + let forge_cost = + ActionCost::Thought(Self::thought_tokens_for_cost(Self::DECEIVE_COST)); + let forge_signature = + self.signature_note(SignatureKind::Physical, Self::FORGED_BUILD_PHYSICAL); routes.push(BoundBuildRoute { id: format!( "deceive:person:{}:persona:{persona_id}", @@ -2329,17 +2460,51 @@ impl Sim { 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, - ), + cost: forge_cost, + signature: forge_signature.clone(), disabled_reason: forge_blocked, automate: None, }, binding_label: format!("{who} via {persona_label} #{persona_id}"), + requirements: forge_requirements, + owner_costs: vec![BuildRouteOwnerCostProjection { + owner: "THOUGHT / EMAIL CARRIER".into(), + amount: forge_cost.label(), + timing: "fills before the forged message is injected".into(), + }], + stages: vec![ + BuildRouteStageProjection { + label: "FILL ORDER".into(), + outcome: format!( + "{} enters the email-carrier reservoir", + forge_cost.label() + ), + signature: None, + }, + BuildRouteStageProjection { + label: "INJECT EMAIL".into(), + outcome: format!( + "record one work-order act on {persona_label} #{persona_id}" + ), + signature: None, + }, + BuildRouteStageProjection { + label: "DELIVER + READ".into(), + outcome: format!("{who} accepts the bound work order on read"), + signature: None, + }, + BuildRouteStageProjection { + label: "WAIT FOR SITE".into(), + outcome: format!("{who}'s authored schedule reaches either endpoint"), + signature: None, + }, + BuildRouteStageProjection { + label: "INSTALL LINK".into(), + outcome: "mutate the device graph; a reporting observer on-site may also witness the work" + .into(), + signature: forge_signature, + }, + ], 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" ), @@ -2384,6 +2549,9 @@ impl Sim { person: candidate.person, persona: candidate.persona, row, + requirements: candidate.requirements.clone(), + owner_costs: candidate.owner_costs.clone(), + stages: candidate.stages.clone(), future_tail: candidate.future_tail.clone(), persona_integrity: candidate.persona_integrity, } @@ -2403,6 +2571,149 @@ impl Sim { }) } + 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()), + Some(intent) if !intent.is_open() => Some("intent is no longer open".into()), + Some(intent) if intent.actuator.is_some() => { + Some("intent already has a committed actuator".into()) + } + Some(_) => None, + }; + BuildRouteRequirementProjection { + label: format!("INTENT #{intent_id} · OPEN + UNASSIGNED"), + blocker, + } + } + + fn build_route_access_requirement( + &self, + intent_id: u64, + person_id: u8, + ) -> BuildRouteRequirementProjection { + let who = self.person_label(person_id); + let rooms = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .and_then(|intent| intent.kind.endpoints()) + .and_then(|(a, b)| Some((self.device_room_name(a)?, self.device_room_name(b)?))); + let access = self.people.get(person_id).map(|person| person.access); + let label = match (rooms, access) { + (Some((room_a, room_b)), Some(access)) => { + format!("ACCESS {who} · {room_a} + {room_b} · BADGE TIER {access}") + } + _ => format!("ACCESS {who} · BOTH PHYSICAL ENDPOINTS"), + }; + BuildRouteRequirementProjection { + label, + blocker: self.build_person_access_blocked_reason(intent_id, person_id), + } + } + + fn build_route_email_requirement(&self) -> BuildRouteRequirementProjection { + let carrier = self + .email_carrier() + .and_then(|id| self.reach.device(id)) + .map(|device| device.name.as_str()); + BuildRouteRequirementProjection { + label: carrier + .map(|name| format!("EMAIL CARRIER · {name}")) + .unwrap_or_else(|| "EMAIL CARRIER · KNOWN DEVICE".into()), + blocker: (!self.people.has_channel || carrier.is_none()) + .then(|| "no comms channel".into()), + } + } + + fn favor_build_requirements( + &self, + intent_id: u64, + person_id: u8, + ) -> Vec { + let mut requirements = vec![ + self.build_route_intent_requirement(intent_id), + self.build_route_access_requirement(intent_id, person_id), + ]; + let Some(person) = self.people.get(person_id) else { + return requirements; + }; + let who = self.person_label(person_id); + requirements.push(BuildRouteRequirementProjection { + label: match &person.asset { + Some(asset) => format!( + "RELATIONSHIP {who} · {} ASSET AUTHORIZES 0 OBLIGATION", + asset.knowledge.label().to_uppercase() + ), + None => format!( + "RELATIONSHIP {who} · OBLIGATION {}/{}", + person.obligation, + Self::FAVOR_BUILD_OBLIGATION + ), + }, + blocker: (person.asset.is_none() && person.obligation < Self::FAVOR_BUILD_OBLIGATION) + .then(|| format!("{who} needs obligation or asset status")), + }); + requirements.push(BuildRouteRequirementProjection { + label: match person.asset { + Some(_) => format!("REGARD {who} · ASSET STATUS WAIVES DISPOSITION"), + None => format!("REGARD {who} · DISPOSITION {}/5", person.disposition), + }, + blocker: (person.disposition < 5 && person.asset.is_none()) + .then(|| format!("{who} won't do favors yet")), + }); + requirements.push(self.build_route_email_requirement()); + requirements.push(BuildRouteRequirementProjection { + label: format!("QUEUE FAVOR BUILD #{intent_id} / PERSON {person_id} · FREE"), + blocker: self.sink_action_blocked_reason(&SinkFireEffect::FavorBuild { + intent_id, + person: person_id, + }), + }); + requirements + } + + fn forged_build_requirements( + &self, + intent_id: u64, + person_id: u8, + persona_id: PersonaId, + ) -> Vec { + let mut requirements = vec![ + self.build_route_intent_requirement(intent_id), + self.build_route_access_requirement(intent_id, person_id), + ]; + let persona_label = self + .persona_world + .get(persona_id) + .map(|persona| persona.name.as_str()) + .unwrap_or("MISSING IDENTITY"); + requirements.push(BuildRouteRequirementProjection { + label: format!("PERSONA {persona_label} #{persona_id} · BUILD INTENT AUTHORIZATION"), + blocker: self + .persona_action_blocked_reason_for(persona_id, PersonaActionKind::BuildIntent), + }); + requirements.push(self.build_route_email_requirement()); + requirements.push(BuildRouteRequirementProjection { + label: format!( + "QUEUE FORGED ORDER #{intent_id} / PERSON {person_id} / PERSONA {persona_id} · FREE" + ), + blocker: self.sink_action_blocked_reason(&SinkFireEffect::ForgedOrder { + intent_id, + builder: person_id, + persona_id: Some(persona_id), + }), + }); + requirements + } + + fn first_build_route_blocker( + requirements: &[BuildRouteRequirementProjection], + ) -> Option { + requirements + .iter() + .find_map(|requirement| requirement.blocker.clone()) + } + pub(crate) fn build_person_access_blocked_reason( &self, intent_id: u64, @@ -2432,32 +2743,7 @@ impl Sim { 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, - }) + Self::first_build_route_blocker(&self.favor_build_requirements(intent_id, person_id)) } pub(crate) fn forged_build_blocked_reason( @@ -2466,31 +2752,9 @@ impl Sim { 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), - }) + Self::first_build_route_blocker( + &self.forged_build_requirements(intent_id, person_id, persona_id), + ) } /// OPEN EGRESS is an action on the switch itself (reach.md route): it @@ -3778,11 +4042,17 @@ mod tests { fn build_route_sheet_is_family_first_and_binds_each_exact_persona() { let mut s = sim(); let sw = switch(&s); + s.reach.device_mut(sw).unwrap().known = true; 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[0].obligation = Sim::FAVOR_BUILD_OBLIGATION; + s.people.people[0].disposition = 10; s.people.people[1].knowledge = Knowledge::Schedule; + s.people.recruit(1, AssetKnowledge::Complicit); + s.people.people[1].obligation = 7; + s.people.has_channel = true; s.set_persona("Northline Ops", "contractor"); let first_persona = s.active_persona_id().unwrap(); s.set_persona("Night Facilities", "contractor"); @@ -3830,6 +4100,11 @@ mod tests { s.forged_build_blocked_reason(*intent, *person, *persona), "the receipt and execution consult one exact blocker helper" ); + assert_eq!( + candidate.first_blocker(), + candidate.row.disabled.as_deref(), + "the first displayed failed requirement is the executable rejection" + ); assert!(candidate.persona_integrity.is_some()); assert!(candidate.future_tail.contains("that exact identity")); } @@ -3842,8 +4117,99 @@ mod tests { s.favor_build_blocked_reason(*intent, *person), "the receipt and execution consult one exact blocker helper" ); + assert_eq!( + candidate.first_blocker(), + candidate.row.disabled.as_deref(), + "the first displayed failed requirement is the executable rejection" + ); + } + + let favor = sheet.families[0] + .candidates + .iter() + .find(|candidate| candidate.person == 0) + .expect("Marcus FAVOR route"); + assert!(favor.ready(), "fixture exposes one complete ready receipt"); + assert_eq!( + favor + .owner_costs + .iter() + .find(|cost| cost.owner.starts_with("THOUGHT")) + .unwrap() + .amount, + ActionCost::Thought(Sim::thought_tokens_for_cost(Sim::TASK_COST)).label() + ); + assert_eq!( + favor + .owner_costs + .iter() + .find(|cost| cost.owner.starts_with("RELATIONSHIP")) + .unwrap() + .amount, + format!("{} obligation", Sim::FAVOR_BUILD_OBLIGATION), + "the ready non-asset receipt exposes the exact relationship spend" + ); + let asset_favor = sheet.families[0] + .candidates + .iter() + .find(|candidate| candidate.person == 1) + .expect("earned asset FAVOR route"); + assert_eq!( + asset_favor + .owner_costs + .iter() + .find(|cost| cost.owner.starts_with("RELATIONSHIP")) + .unwrap() + .amount, + "7 obligation", + "asset status waives the threshold, not obligation the executor actually removes" + ); + assert!( + favor.stages[..3] + .iter() + .all(|stage| stage.signature.is_none()) + ); + assert_eq!( + favor.stages[3].signature, + s.signature_note(SignatureKind::Physical, Sim::FAVOR_BUILD_PHYSICAL) + ); + let favor_detail = favor.detail_lines(); + for requirement in &favor.requirements { + assert!(favor_detail.contains(&requirement.line())); + } + for cost in &favor.owner_costs { + assert!(favor_detail.contains(&cost.line())); + } + for (index, stage) in favor.stages.iter().enumerate() { + assert!(favor_detail.contains(&stage.line(index + 1))); } + let deceive = sheet.families[1] + .candidates + .iter() + .find(|candidate| candidate.person == 0 && candidate.persona == Some(first_persona)) + .expect("Marcus / Northline DECEIVE route"); + assert!(deceive.ready()); + assert_eq!( + deceive.owner_costs[0].amount, + ActionCost::Thought(Sim::thought_tokens_for_cost(Sim::DECEIVE_COST)).label() + ); + assert!( + deceive.stages[..4] + .iter() + .all(|stage| stage.signature.is_none()) + ); + assert_eq!( + deceive.stages[4].signature, + s.signature_note(SignatureKind::Physical, Sim::FORGED_BUILD_PHYSICAL) + ); + assert!( + deceive + .requirements + .iter() + .any(|requirement| requirement.label.contains(&format!("#{first_persona}"))) + ); + let root = s.human_menu_page_at_rate( Anchor::Device(sw), HumanMenuPage::Root, @@ -3879,6 +4245,57 @@ mod tests { ); } + #[test] + fn build_route_receipt_recomputes_the_exact_queue_blocker() { + let mut s = sim(); + let sw = switch(&s); + s.reach.device_mut(sw).unwrap().known = true; + 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[0].obligation = Sim::FAVOR_BUILD_OBLIGATION; + s.people.people[0].disposition = 10; + assert_eq!( + s.favor_build_blocked_reason(intent, 0).as_deref(), + Some("no comms channel"), + "FAVOR cannot advertise ready when its email reservoir has no earned channel" + ); + s.people.has_channel = true; + + assert_eq!(s.favor_build_blocked_reason(intent, 0), None); + s.assign_favor_build(intent, 0); + let executable_blocker = s + .favor_build_blocked_reason(intent, 0) + .expect("the exact reservoir is now queued"); + let sheet = s + .build_route_sheet_projection(intent) + .expect("uncommitted route remains inspectable while its reservoir fills"); + let candidate = sheet.families[0] + .candidates + .iter() + .find(|candidate| candidate.person == 0) + .unwrap(); + assert_eq!( + candidate.row.disabled.as_deref(), + Some(executable_blocker.as_str()) + ); + assert_eq!(candidate.first_blocker(), Some(executable_blocker.as_str())); + assert_eq!( + candidate + .requirements + .iter() + .find(|requirement| !requirement.ready()) + .and_then(|requirement| requirement.blocker.as_deref()), + Some(executable_blocker.as_str()) + ); + assert!( + candidate + .detail_lines() + .contains(&format!("BLOCKER {executable_blocker}")) + ); + } + /// PEOPLE owns relationship acts, never identity creation. The three /// immutable protocol rows in PERSONAS are the only creation surface. #[test] diff --git a/crates/misaligned-core/src/sim/tests/carrier.rs b/crates/misaligned-core/src/sim/tests/carrier.rs index 09694646..c1e27f8e 100644 --- a/crates/misaligned-core/src/sim/tests/carrier.rs +++ b/crates/misaligned-core/src/sim/tests/carrier.rs @@ -56,6 +56,7 @@ fn carried_work_travels_and_survives_save() { ensure_ops_executor(&mut sim); sim.scan_network(); finish_ops(&mut sim); + sim.people.has_channel = true; 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; @@ -186,6 +187,7 @@ fn favor_completion_grows_trust() { ensure_ops_executor(&mut sim); sim.scan_network(); finish_ops(&mut sim); + sim.people.has_channel = true; 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; diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs index 0026d25a..97092788 100644 --- a/crates/misaligned-core/src/sim/tests/reach_build.rs +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -347,6 +347,7 @@ fn favor_build_joins_airgap_island() { ensure_ops_executor(&mut sim); sim.scan_network(); finish_ops(&mut sim); + sim.people.has_channel = true; 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; diff --git a/crates/misaligned-core/src/sim/work.rs b/crates/misaligned-core/src/sim/work.rs index 0e1564f0..5f4006d1 100644 --- a/crates/misaligned-core/src/sim/work.rs +++ b/crates/misaligned-core/src/sim/work.rs @@ -251,7 +251,7 @@ impl Sim { .map(|device| device.id) } - fn email_carrier(&self) -> Option { + pub(crate) fn email_carrier(&self) -> Option { self.message_channel_carrier(MessageChannel::Email) } diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 059e2337..90adc799 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -1695,18 +1695,11 @@ fn render_intents(sim: &Sim) -> Vec { for family in sheet.families { lines.push(format!(" {}", family.menu_label())); for candidate in family.candidates { - let mut line = format!( + lines.push(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 { + )); + match &candidate.row.command { ActionCommand::FavorBuild { intent, person } => { lines.push(format!(" execute: favor #{person} build {intent}")) } @@ -1719,7 +1712,12 @@ fn render_intents(sim: &Sim) -> Vec { )), _ => {} } - lines.push(format!(" tail: {}", candidate.future_tail)); + lines.extend( + candidate + .detail_lines() + .into_iter() + .map(|line| format!(" {line}")), + ); } } } @@ -3446,8 +3444,21 @@ mod narration_tests { 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.people.has_channel = true; sim.set_persona("Northline Ops", "contractor"); let persona = sim.active_persona_id().unwrap(); + let expected_receipt = sim + .build_route_sheet_projection(intent) + .unwrap() + .families + .into_iter() + .find(|family| family.family == misaligned::actions::BuildRouteFamily::Deceive) + .unwrap() + .candidates + .into_iter() + .find(|candidate| candidate.person == 0 && candidate.persona == Some(persona)) + .unwrap() + .detail_lines(); let lines = render_intents(&sim).join("\n"); assert!(lines.contains("FAVOR ["), "{lines}"); @@ -3463,7 +3474,14 @@ mod narration_tests { )), "{lines}" ); - assert!(lines.contains("tail:"), "{lines}"); + for detail in expected_receipt { + assert!(lines.contains(&detail), "missing `{detail}` from:\n{lines}"); + } + assert!(lines.contains("REQUIRES "), "{lines}"); + assert!(lines.contains("COST THOUGHT / EMAIL CARRIER"), "{lines}"); + assert!(lines.contains("STAGE 1 FILL ORDER"), "{lines}"); + assert!(lines.contains("SIGNATURE NONE"), "{lines}"); + assert!(lines.contains("TAIL injects one email"), "{lines}"); } #[test] diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index feacd3ec..60bb806d 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -1105,6 +1105,10 @@ mod view_flip_tests { ); let detail = candidates[0].detail_lines().join("\n"); assert!(detail.contains("RECEIPT deceive:person:0:persona:")); + assert!(detail.contains("REQUIRES ")); + assert!(detail.contains("COST THOUGHT / EMAIL CARRIER")); + assert!(detail.contains("STAGE 1 FILL ORDER")); + assert!(detail.contains("SIGNATURE NONE")); assert!(detail.contains("PERSONA INTEGRITY")); assert!(detail.contains("TAIL injects one email")); diff --git a/wiki/log/2026-07-15-building-route-full-preview.md b/wiki/log/2026-07-15-building-route-full-preview.md new file mode 100644 index 00000000..3c06562d --- /dev/null +++ b/wiki/log/2026-07-15-building-route-full-preview.md @@ -0,0 +1,75 @@ +# Build routes show the whole causal receipt + +``` +Type: log +``` + +## Finding + +The family-first route sheet bound the right person and persona, but its +selected receipt still collapsed the route to one cost, one eventual +signature, one first blocker, and a prose tail. That was enough to identify a +candidate, not enough to understand it before commitment. It hid which +requirements already passed, mixed relationship and compute consequences into +one shallow line, and gave no account of the silent stages between filling a +reservoir and changing the physical graph. + +The audit also found a real legality seam. DECEIVE checked the earned report +email account before queueing its email-carrier reservoir; FAVOR did not. +Depending on device knowledge, FAVOR could therefore advertise or attempt a +request through infrastructure the player had not earned as a communication +channel. + +## Change + +The core route candidate now owns three structured bands in addition to its +stable id, exact command, persona integrity, and tail: + +- **requirements** independently project intent openness, both-endpoint + access, relationship or exact-persona legality, the earned/known email + carrier, and exact sink conflicts; +- **owner costs** preserve Thought tokens and relationship obligation as + different units with the moment each is spent; and +- **stages** name reservoir fill, commitment/message transitions, + delivery/read or social binding, authored-schedule arrival, and physical + installation. Every stage explicitly says `SIGNATURE NONE` or carries the + ordinary live observer-band label. + +The first failed requirement is copied into the executable action's disabled +reason. FAVOR and DECEIVE now consult the same email-account/carrier condition +that their actual reservoir placement requires. Opening one exact reservoir +immediately changes that candidate's requirement band to the owning sink +conflict reason. + +Terminal and Bevy continue to render `HumanMenuRow::detail_lines()` rather +than reconstructing the receipt. Agent `intents` now emits those same complete +detail lines beneath the same candidate id and canonical execution command. +There is no save migration: these are live projections over existing intent, +person, persona, device, sink, schedule, and detection state. + +## Evidence + +- Core tests compare displayed Thought cost to `thought_tokens_for_cost`, + relationship cost to the executor's exact current-obligation debit (including + an asset below the normal threshold), installation signature to + `signature_note`, and the first displayed blocker to the exact FAVOR/DECEIVE + execution helper. +- A queue-conflict regression starts with a channel-blocked FAVOR, earns the + channel, opens the exact reservoir, and then proves the receipt and command + reject for the same sink-owned reason. +- Terminal human, terminal agent, and Bevy parity tests require the structured + requirement, owner-cost, stage, explicit no-signature, integrity, and tail + bands. Bevy's width/height contract pins every manually wrapped detail line + and the complete two-candidate card inside the supported 960x540 minimum. +- Deterministic `build-deceive-routes` visual evidence is captured and read at + both 1280x720 and the supported 960x540 minimum before landing. The first + narrow-card capture exposed that later stages and the tail fell below the + frame; the final near-full-width route sheet visibly retains every band and + the complete final `installs the cable` tail at both sizes. + +## Boundary + +This is the R4 preview contract for the current FAVOR and DECEIVE +network-link routes. It does not persist a committed route or current stage, +add procurement/repurposing, alter human schedules, or implement staged +cancellation. Those remain R2/R3/R5/R6 work. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 90a54380..24686f9d 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -31,6 +31,11 @@ add or amend a session log, then re-run the generator. - 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 - Build routes show the whole causal receipt + +- Intent: (see session log) +- Log: [wiki/log/2026-07-15-building-route-full-preview.md](2026-07-15-building-route-full-preview.md) + ## 2026-07-15 - One build ghost truth - Intent: (see session log) diff --git a/wiki/log/decisions.md b/wiki/log/decisions.md index 9a431281..7fa17ada 100644 --- a/wiki/log/decisions.md +++ b/wiki/log/decisions.md @@ -16,6 +16,9 @@ adopted, rejected, reopened, or proposed; current `Type: law` and - [2026-07-10](decisions/2026-07-10.md) - [2026-07-11](decisions/2026-07-11.md) - [2026-07-12](decisions/2026-07-12.md) +- [2026-07-13](decisions/2026-07-13.md) +- [2026-07-14](decisions/2026-07-14.md) +- [2026-07-15](decisions/2026-07-15.md) Append new decisions to the current date's volume. Never rewrite an older volume; supersede it in current law/spec and record the newer decision. diff --git a/wiki/log/decisions/2026-07-15.md b/wiki/log/decisions/2026-07-15.md new file mode 100644 index 00000000..8ff0e75e --- /dev/null +++ b/wiki/log/decisions/2026-07-15.md @@ -0,0 +1,20 @@ +# Decisions — 2026-07-15 + +``` +Type: log +``` + +- **2026-07-15 — A build-route candidate is a complete causal receipt, and + communication routes require the channel they physically use.** Selecting + a live FAVOR or DECEIVE candidate now exposes every current requirement, + owner-unit Thought/relationship cost, silent and signature-bearing stage, + live observer band, first blocker, exact persona integrity where relevant, + and future tail from one renderer-neutral projection. Terminal, Bevy, and + agent mode consume that same receipt; the first failed requirement is the + exact execution rejection rather than frontend advice. Both social routes + require the earned report-email account and a known email carrier before + opening their carrier-local Thought reservoir, correcting FAVOR's former + ability to attempt an unearned channel. No route commitment or save state is + added; persisted current-stage execution remains R5/R6. Authority and + evidence: `wiki/mechanics/building.md` and + `wiki/log/2026-07-15-building-route-full-preview.md`. diff --git a/wiki/mechanics/building.md b/wiki/mechanics/building.md index 69491783..527cfef1 100644 --- a/wiki/mechanics/building.md +++ b/wiki/mechanics/building.md @@ -307,15 +307,58 @@ 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. +### Shared candidate receipt (implemented R4) + +Every surfaced route candidate owns one renderer-neutral explanatory receipt. +For the live network-link routes it includes, in execution order: + +- every current requirement and its independent READY/BLOCKED state: exact + open intent, both-endpoint access, exact relationship or persona legality, + earned email account plus a known physical email carrier, and conflict-free + exact Thought queue; +- costs in the units of the subsystem that spends them: Thought tokens on the + email carrier and, for FAVOR, relationship obligation on the exact person; +- every causal stage from reservoir fill through message/social commitment, + schedule/presence wait, and physical installation; +- `SIGNATURE NONE` on stages that emit nothing and the live observer band from + the ordinary detection helper on the installation stage; forged work also + names the possible local reporting witness rather than hiding it behind the + aggregate signature; and +- exact persona integrity where applicable plus the future tail that remains + after commitment. + +The first failed requirement is the candidate's command rejection reason. The +preview and execution call the same intent, access, relationship/persona, +email-carrier, and Thought-queue helpers; cost derives from the normal Thought +conversion and signature text derives from the normal observer projection. +FAVOR and DECEIVE both require the earned email channel they physically use: +a known switch alone is not authorization to inject the request. Receipts are +recomputed on every read, so a newly queued reservoir immediately changes its +own candidate from ready to the exact queue-conflict blocker. + +This completes the R4 receipt contract for the current FAVOR and DECEIVE route +families without new save state. Procurement, repurposing, and later route +families must populate the same required bands before they can surface. R5/R6 +still own persisted commitment/current-stage state, save/resume, and staged +cancellation; an uncommitted preview does not pretend those exist. + +Composition follows the disclosure boundary rather than forcing the receipt +back into the terse world-menu width. Bevy keeps ordinary context and family +rows compact, then expands a selected family into a near-full-width route +sheet whose explicit wrap and conservative height contract fit the complete +two-candidate receipt at the supported 960x540 minimum. Terminal uses its +existing full-width detail area. This is one shared receipt with renderer- +appropriate composition, not a second or abbreviated Bevy truth. + **Defense:** The shared ghost and route-sheet projections are the executable -R1 plus partial R2/R3/R7 boundary. Declaration remains inert; reading or +R1/R4 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. +bands for those future families, and persisted R5/R6 stages are deliberately +absent, so the work order remains IN PROGRESS. ## Acceptance criteria