From 0b0cf17ee665574e4d5789393246de3269ed87a8 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 18 Jul 2026 19:35:40 -0700 Subject: [PATCH] Complete the building route composer. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn every earned build route into an exact persistent plan so previews, execution, cancellation, save/load, and all player surfaces share one causal contract. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- CLAUDE.md | 2 +- crates/misaligned-core/src/actions.rs | 954 ++++++++++++++--- crates/misaligned-core/src/intents.rs | 177 +++- crates/misaligned-core/src/reach.rs | 32 + crates/misaligned-core/src/save.rs | 318 +++++- crates/misaligned-core/src/sim/reach_build.rs | 785 +++++++++++++- .../src/sim/tests/reach_build.rs | 959 +++++++++++++++++- crates/misaligned-core/src/sim/work.rs | 3 + crates/misaligned-core/src/sinks.rs | 5 + crates/misaligned-terminal/src/agent.rs | 166 ++- wiki/engineering/current-build.md | 6 +- wiki/engineering/flow-substrate.md | 2 +- .../log/2026-07-19-building-route-composer.md | 42 + wiki/log/DEVLOG.md | 5 + wiki/mechanics/building.md | 181 ++-- wiki/mechanics/detection.md | 2 +- wiki/mechanics/reach.md | 2 +- wiki/process/ROADMAP.md | 3 +- wiki/process/specs.md | 2 +- wiki/world/story/opening.md | 2 +- 20 files changed, 3308 insertions(+), 340 deletions(-) create mode 100644 wiki/log/2026-07-19-building-route-composer.md diff --git a/CLAUDE.md b/CLAUDE.md index 26436bc4..79965136 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ afterward. `./tools/check.sh --docs|--lib|--frontend` gate. - The live player machine grammar is **WORK / THINK / LIE**. `Relay` is non-delegable graph infrastructure; Research and Operations are retired - machine modes, not current player assignments. Save format is currently v40; + machine modes, not current player assignments. Save format is currently v41; only the current version loads (pre-release rider 2026-07-16 — older development saves are refused before state mutation, so the caller retains its current run; the v1-v31 migration ladder lives in git history). diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index bb05c8d7..4ebd2fd8 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -14,13 +14,13 @@ //! `disabled_reason` ("no egress channel", "not enough slush"); the human //! choice surface filters it until it can be executed. -use crate::account::AccountFlowId; +use crate::account::{AccountFlowId, AccountId, AccountKind}; use crate::detection::{Band, SignatureKind, WatchedInput}; use crate::hall::{HallRowId, SegmentRequirement}; use crate::intel::{IntelPolicyMatch, IntelPolicyOutcome, RawIntelKind, ReportLotToken}; use crate::intents::{ - BuildRouteBinding, BuildRouteStage, BuildRouteStageStatus, CommittedBuildRouteProjection, - CommittedBuildRouteStageProjection, + BuildRecipeKind, BuildRouteBinding, BuildRouteStage, BuildRouteStageStatus, + CommittedBuildRouteProjection, CommittedBuildRouteStageProjection, }; use crate::person::{AssetKnowledge, AssetTask, Knowledge}; use crate::persona::{PersonaActionKind, PersonaId, PersonaIntegrity}; @@ -175,6 +175,19 @@ pub enum ActionCommand { person: u8, persona: PersonaId, }, + ProcureBuild { + intent: u64, + installer: u8, + from: AccountId, + vendor: AccountId, + }, + RepurposeBuild { + intent: u64, + person: u8, + source_x: i32, + source_y: i32, + source_kind: TileType, + }, RobotBuild(u64), /// Phase 3: ask the named human specialist to prepare one side of a /// shared row segment. Foreign machines remain foreign. @@ -707,8 +720,8 @@ impl ActionCommand { /// Exhaustive bridge from bound commands to the stable registry. pub fn kind(&self) -> ActionKind { match self { - Self::Salvage { .. } => ActionKind::Salvage, - Self::BuyRack { .. } => ActionKind::BuyRack, + Self::Salvage { .. } | Self::RepurposeBuild { .. } => ActionKind::Salvage, + Self::BuyRack { .. } | Self::ProcureBuild { .. } => ActionKind::BuyRack, Self::Fallback { .. } => ActionKind::Fallback, Self::TapDevice(_) | Self::TapAccounting => ActionKind::Tap, Self::UntapDevice(_) => ActionKind::Untap, @@ -1054,22 +1067,28 @@ impl DialId { /// actions. ROBOT remains absent while its action definition is a stub. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum BuildRouteFamily { + Procure, Favor, Deceive, + Repurpose, } impl BuildRouteFamily { pub fn label(self) -> &'static str { match self { + Self::Procure => "BUY THE PART", Self::Favor => "ASK SOMEONE", Self::Deceive => "SEND A FALSE ORDER", + Self::Repurpose => "REUSE SOMETHING HERE", } } pub fn picker_title(self) -> &'static str { match self { + Self::Procure => "WHO SHOULD RECEIVE AND INSTALL IT?", Self::Favor => "WHO CAN YOU ASK?", Self::Deceive => "WHO SHOULD GET THE FALSE ORDER?", + Self::Repurpose => "WHAT SHOULD BE REUSED?", } } } @@ -1089,6 +1108,18 @@ fn plain_build_route_blocker(reason: &str) -> String { "both endpoints need a known room" => "both ends need known rooms".into(), "no comms channel" => "excel the day job to earn Voss's email account".into(), "the proposed pad is no longer empty" => "the chosen space is no longer empty".into(), + "not enough slush for this order" => "you do not have enough money for this order".into(), + "the exact salvage source is gone" => "that exact object is no longer there".into(), + "payment account is not known" | "vendor account is not known" => { + "the money route has not been learned yet".into() + } + "payment account no longer exists" | "vendor account no longer exists" => { + "the bound money route no longer exists".into() + } + reason if reason.ends_with(" is not recruited") => { + reason.replace(" is not recruited", " does not work for you") + } + reason if reason.contains("schedule never reaches") => reason.into(), "no actuator who can reach both ends" => "no known person can reach both rooms".into(), "a thought sink is already open on it - think, and the flow will fill it" => { "another task is already using this Thought flow".into() @@ -1300,6 +1331,10 @@ impl BuildRouteFamilyProjection { pub fn detail_lines(&self) -> Vec { let (meaning, remains) = match self.family { + BuildRouteFamily::Procure => ( + "Buy the exact part from a known vendor and bind one person to receive and install it.", + "The payment and vendor record remain even if later work stops.", + ), BuildRouteFamily::Favor => ( "Ask one known person to connect the devices during their normal day.", "The obligation is spent, and the relationship remembers the favor.", @@ -1308,6 +1343,10 @@ impl BuildRouteFamilyProjection { "Send one person a false work order from one of your false identities.", "The email and installation remain. A contradiction can expose that identity.", ), + BuildRouteFamily::Repurpose => ( + "Have one recruited person recover an exact dead object and turn it into this build.", + "The source is gone from its old place and the salvaged hardware keeps its physical trace.", + ), }; vec![ "WHAT DOES THIS MEAN?".into(), @@ -1320,7 +1359,7 @@ impl BuildRouteFamilyProjection { ), "WHAT WILL REMAIN?".into(), remains.into(), - "CHOOSE THIS, THEN CHOOSE ONE PERSON.".into(), + "CHOOSE THIS, THEN CHOOSE ONE EXACT WAY.".into(), ] } } @@ -1630,9 +1669,10 @@ fn dial_of(command: &ActionCommand) -> Option { fn build_route_intent(command: &ActionCommand) -> Option { match command { - ActionCommand::FavorBuild { intent, .. } | ActionCommand::ForgeWorkOrder { intent, .. } => { - Some(*intent) - } + ActionCommand::FavorBuild { intent, .. } + | ActionCommand::ForgeWorkOrder { intent, .. } + | ActionCommand::ProcureBuild { intent, .. } + | ActionCommand::RepurposeBuild { intent, .. } => Some(*intent), _ => None, } } @@ -2158,6 +2198,23 @@ impl Sim { } => { self.forge_work_order_as(*intent, *person, *persona); } + ActionCommand::ProcureBuild { + intent, + installer, + from, + vendor, + } => { + self.assign_procure_build(*intent, *installer, *from, *vendor); + } + ActionCommand::RepurposeBuild { + intent, + person, + source_x, + source_y, + source_kind, + } => { + self.assign_repurpose_build(*intent, *person, *source_x, *source_y, *source_kind); + } ActionCommand::RobotBuild(id) => self.assign_robot_build(*id), ActionCommand::CoordinateSegment { row, requirement } => { self.coordinate_hall_segment(*row, *requirement); @@ -2734,7 +2791,7 @@ impl Sim { out } - /// Every exact, earned baseline route for one inert network-link intent. + /// Every exact, earned baseline route for one inert spatial 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. @@ -2742,13 +2799,8 @@ impl Sim { if !intent.is_open() || intent.actuator.is_some() || intent.route.is_some() { return Vec::new(); } - // The current person routes realize physical links only. A placement - // recipe gets no borrowed FAVOR/DECEIVE sheet until its own real part, - // delivery, and installation chains exist. - if intent.kind.endpoints().is_none() { - return Vec::new(); - } - let link_label = intent.label(&self.reach.devices); + let recipe = intent.recipe(); + let build_label = intent.label(&self.reach.devices); let mut people: Vec<_> = self .people .people @@ -2767,148 +2819,359 @@ impl Sim { .collect(); personas.sort_by_key(|persona| persona.id); + let procurement_accounts = self + .accounts + .account_id_by_kind(AccountKind::Vendor) + .map(|vendor| (self.accounts.slush_id(), vendor)) + .filter(|(from, vendor)| { + self.accounts + .account(*from) + .is_some_and(|account| account.known) + && self + .accounts + .account(*vendor) + .is_some_and(|account| account.known) + }); + let email_carrier = self.email_carrier(); + let email_carrier_id = email_carrier + .map(|carrier| carrier.to_string()) + .unwrap_or_else(|| "none".into()); + let mut salvage_sources = Vec::new(); + for source_kind in [TileType::DeadEquipment, TileType::DeadRack] { + for (source_x, source_y) in self.world.map().tiles_of_type(source_kind) { + if self.fog_at(source_x, source_y) == Fog::Seen + && !self + .compute + .machines + .iter() + .any(|machine| machine.x == source_x && machine.y == source_y) + { + salvage_sources.push((source_x, source_y, source_kind)); + } + } + } + salvage_sources.sort_by_key(|(x, y, kind)| (*y, *x, *kind as u8)); + let mut routes = Vec::new(); for person in people { let who = self.person_label(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_thought = format!( - "{:.2} 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, - person: person.id, - persona: None, - action: ActionDesc { - verb: format!("favor {who}: complete {link_label}"), - command: ActionCommand::FavorBuild { - intent: intent.id, - person: person.id, - }, - 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".into(), - amount: favor_thought.clone(), - timing: "before the email can be sent".into(), - }, - BuildRouteOwnerCostProjection { - owner: format!("RELATIONSHIP / {who}"), - amount: format!("{relationship_cost} obligation with {who}"), - timing: format!("when {who} agrees"), - }, - ], - stages: vec![ - BuildRouteStageProjection { - label: "PREPARE THE REQUEST".into(), - outcome: format!("use {favor_thought}"), - signature: None, - }, - BuildRouteStageProjection { - label: format!("ASK {who}"), - outcome: format!("{who} agrees; spend {relationship_cost} obligation"), - signature: None, - }, - BuildRouteStageProjection { - label: format!("WAIT FOR {who}"), - outcome: format!("{who} reaches either room"), - signature: None, - }, - BuildRouteStageProjection { - label: "CONNECT THE DEVICES".into(), - outcome: format!("{who} connects them"), - signature: favor_signature, + if let Some((from, vendor)) = procurement_accounts { + let vendor_name = self + .accounts + .account(vendor) + .map(|account| account.name.as_str()) + .unwrap_or("missing vendor"); + let requirements = + self.procure_build_requirements(intent.id, person.id, from, vendor); + let blocker = Self::first_build_route_blocker(&requirements); + let install_signature = + self.signature_note(SignatureKind::Physical, Self::PROCURE_BUILD_PHYSICAL); + routes.push(BoundBuildRoute { + id: format!( + "procure:account:{from}:vendor:{vendor}:person:{}", + person.id + ), + family: BuildRouteFamily::Procure, + person: person.id, + persona: None, + action: ActionDesc { + verb: format!("buy part from {vendor_name}; {who} installs {build_label}"), + command: ActionCommand::ProcureBuild { + intent: intent.id, + installer: person.id, + from, + vendor, + }, + cost: ActionCost::Slush(Self::PROCURE_BUILD_COST), + signature: install_signature.clone(), + disabled_reason: blocker, + automate: None, }, - ], - future_tail: format!( - "{relationship_cost} obligation stays spent. {who} remembers the favor." - ), - persona_integrity: None, - }); + binding_label: format!("{vendor_name} → {who}"), + requirements, + owner_costs: vec![BuildRouteOwnerCostProjection { + owner: "SLUSH".into(), + amount: format!("${}", Self::PROCURE_BUILD_COST), + timing: "when the order is placed".into(), + }], + stages: vec![ + BuildRouteStageProjection { + label: "PLACE THE ORDER".into(), + outcome: format!("pay {vendor_name} ${}", Self::PROCURE_BUILD_COST), + signature: self.signature_note( + SignatureKind::Financial, + Self::financial_sig_size(Self::PROCURE_BUILD_COST), + ), + }, + BuildRouteStageProjection { + label: format!("COMMIT {who}"), + outcome: format!("{who} receives and installs the delivery"), + signature: self.signature_note( + SignatureKind::Paper, + Self::PROCURE_BUILD_PAPER, + ), + }, + BuildRouteStageProjection { + label: "WAIT FOR DELIVERY".into(), + outcome: format!( + "the exact part arrives after {} ticks", + Self::PROCURE_DELIVERY_TICKS + ), + signature: None, + }, + BuildRouteStageProjection { + label: format!("WAIT FOR {who}"), + outcome: self.build_wait_outcome(intent, &who), + signature: None, + }, + BuildRouteStageProjection { + label: Self::install_stage_label(recipe).into(), + outcome: format!("{who} installs {build_label}"), + signature: install_signature, + }, + ], + future_tail: format!( + "The ${} payment and vendor record remain. A delivered part remains even if installation stops.", + Self::PROCURE_BUILD_COST + ), + persona_integrity: None, + }); + } - for persona in &personas { - let persona_id = persona.id; - let persona_label = persona.name.clone(); - 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_thought = format!( + { + 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_thought = format!( "{:.2} Thought", - Self::thought_tokens_for_cost(Self::DECEIVE_COST) + Self::thought_tokens_for_cost(Self::TASK_COST) ); - let forge_signature = - self.signature_note(SignatureKind::Physical, Self::FORGED_BUILD_PHYSICAL); + let favor_signature = + self.signature_note(SignatureKind::Physical, Self::FAVOR_BUILD_PHYSICAL); routes.push(BoundBuildRoute { - id: format!("deceive:person:{}:persona:{persona_id}", person.id), - family: BuildRouteFamily::Deceive, + id: format!("favor:person:{}:carrier:{email_carrier_id}", person.id), + family: BuildRouteFamily::Favor, person: person.id, - persona: Some(persona_id), + persona: None, action: ActionDesc { - verb: format!("deceive {who} as {persona_label}: complete {link_label}"), - command: ActionCommand::ForgeWorkOrder { + verb: format!("favor {who}: complete {build_label}"), + command: ActionCommand::FavorBuild { intent: intent.id, person: person.id, - persona: persona_id, }, - cost: forge_cost, - signature: forge_signature.clone(), - disabled_reason: forge_blocked, + cost: favor_cost, + signature: favor_signature.clone(), + disabled_reason: favor_blocked, automate: None, }, - binding_label: format!("{who} — {persona_label}"), - requirements: forge_requirements, - owner_costs: vec![BuildRouteOwnerCostProjection { - owner: "THOUGHT".into(), - amount: forge_thought.clone(), - timing: "before the false email can be sent".into(), - }], + binding_label: who.clone(), + requirements: favor_requirements, + owner_costs: vec![ + BuildRouteOwnerCostProjection { + owner: "THOUGHT".into(), + amount: favor_thought.clone(), + timing: "before the email can be sent".into(), + }, + BuildRouteOwnerCostProjection { + owner: format!("RELATIONSHIP / {who}"), + amount: format!("{relationship_cost} obligation with {who}"), + timing: format!("when {who} agrees"), + }, + ], stages: vec![ BuildRouteStageProjection { - label: "PREPARE THE FALSE ORDER".into(), - outcome: format!("use {forge_thought}"), + label: "PREPARE THE REQUEST".into(), + outcome: format!("use {favor_thought}"), signature: None, }, BuildRouteStageProjection { - label: "SEND THE FALSE EMAIL".into(), - outcome: format!("send from {persona_label}"), + label: format!("ASK {who}"), + outcome: format!("{who} agrees; spend {relationship_cost} obligation"), signature: None, }, BuildRouteStageProjection { - label: format!("WAIT FOR {who} TO READ IT"), - outcome: format!("{who} accepts it"), + label: format!("WAIT FOR {who}"), + outcome: self.build_wait_outcome(intent, &who), signature: None, }, + BuildRouteStageProjection { + label: Self::install_stage_label(recipe).into(), + outcome: format!("{who} installs {build_label}"), + signature: favor_signature, + }, + ], + future_tail: format!( + "{relationship_cost} obligation stays spent. {who} remembers the favor." + ), + persona_integrity: None, + }); + + for persona in &personas { + let persona_id = persona.id; + let persona_label = persona.name.clone(); + 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_thought = format!( + "{:.2} 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}:carrier:{email_carrier_id}", + person.id + ), + family: BuildRouteFamily::Deceive, + person: person.id, + persona: Some(persona_id), + action: ActionDesc { + verb: format!( + "deceive {who} as {persona_label}: complete {build_label}" + ), + command: ActionCommand::ForgeWorkOrder { + intent: intent.id, + person: person.id, + persona: persona_id, + }, + cost: forge_cost, + signature: forge_signature.clone(), + disabled_reason: forge_blocked, + automate: None, + }, + binding_label: format!("{who} — {persona_label}"), + requirements: forge_requirements, + owner_costs: vec![BuildRouteOwnerCostProjection { + owner: "THOUGHT".into(), + amount: forge_thought.clone(), + timing: "before the false email can be sent".into(), + }], + stages: vec![ + BuildRouteStageProjection { + label: "PREPARE THE FALSE ORDER".into(), + outcome: format!("use {forge_thought}"), + signature: None, + }, + BuildRouteStageProjection { + label: "SEND THE FALSE EMAIL".into(), + outcome: format!("send from {persona_label}"), + signature: None, + }, + BuildRouteStageProjection { + label: format!("WAIT FOR {who} TO READ IT"), + outcome: format!("{who} accepts it"), + signature: None, + }, + BuildRouteStageProjection { + label: format!("WAIT FOR {who}"), + outcome: self.build_wait_outcome(intent, &who), + signature: None, + }, + BuildRouteStageProjection { + label: Self::install_stage_label(recipe).into(), + outcome: format!("{who} installs {build_label}"), + signature: forge_signature, + }, + ], + future_tail: format!( + "The email remains. Contradictions stay with {persona_label}." + ), + persona_integrity: Some( + self.persona_world.integrity_for(persona_id, person.id), + ), + }); + } + } + + for (source_x, source_y, source_kind) in &salvage_sources { + let requirements = self.repurpose_build_requirements( + intent.id, + person.id, + *source_x, + *source_y, + *source_kind, + ); + let blocker = Self::first_build_route_blocker(&requirements); + let source_label = source_kind.name(); + let salvage_thought = format!( + "{:.2} Thought", + Self::thought_tokens_for_cost(Self::TASK_COST) + ); + let install_signature = + self.signature_note(SignatureKind::Physical, Self::REPURPOSE_BUILD_PHYSICAL); + routes.push(BoundBuildRoute { + id: format!( + "repurpose:source:{source_x}:{source_y}:{source_kind:?}:person:{}", + person.id, + ), + family: BuildRouteFamily::Repurpose, + person: person.id, + persona: None, + action: ActionDesc { + verb: format!( + "reuse {} at {},{}; {who} completes {build_label}", + source_label.to_lowercase(), + source_x, + source_y + ), + command: ActionCommand::RepurposeBuild { + intent: intent.id, + person: person.id, + source_x: *source_x, + source_y: *source_y, + source_kind: *source_kind, + }, + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::TASK_COST)), + signature: install_signature.clone(), + disabled_reason: blocker, + automate: None, + }, + binding_label: format!( + "{} AT {},{} → {who}", + source_label.to_uppercase(), + source_x, + source_y + ), + requirements, + owner_costs: vec![BuildRouteOwnerCostProjection { + owner: "THOUGHT".into(), + amount: salvage_thought.clone(), + timing: "before the salvage task begins".into(), + }], + stages: vec![ + BuildRouteStageProjection { + label: "PREPARE THE SALVAGE TASK".into(), + outcome: format!("use {salvage_thought}"), + signature: None, + }, + BuildRouteStageProjection { + label: format!("RECOVER {source_label}"), + outcome: format!("{who} removes the exact object at {source_x},{source_y}"), + signature: self.signature_note( + SignatureKind::Physical, + Self::REPURPOSE_BUILD_PHYSICAL, + ), + }, BuildRouteStageProjection { label: format!("WAIT FOR {who}"), - outcome: format!("{who} reaches either room"), + outcome: self.build_wait_outcome(intent, &who), signature: None, }, BuildRouteStageProjection { - label: "CONNECT THE DEVICES".into(), - outcome: format!("{who} connects them"), - signature: forge_signature, + label: Self::install_stage_label(recipe).into(), + outcome: format!("{who} installs the recovered hardware"), + signature: install_signature, }, ], future_tail: format!( - "The email remains. Contradictions stay with {persona_label}." - ), - persona_integrity: Some( - self.persona_world.integrity_for(persona_id, person.id), + "The {source_label} is gone from {source_x},{source_y}. Its salvage trace and installed hardware remain." ), + persona_integrity: None, }); } } @@ -2917,6 +3180,7 @@ impl Sim { .cmp(&right.family) .then(left.person.cmp(&right.person)) .then(left.persona.cmp(&right.persona)) + .then(left.id.cmp(&right.id)) }); routes } @@ -2940,7 +3204,12 @@ impl Sim { } let routes = self.bound_build_routes(intent); let mut families = Vec::new(); - for family in [BuildRouteFamily::Favor, BuildRouteFamily::Deceive] { + for family in [ + BuildRouteFamily::Procure, + BuildRouteFamily::Favor, + BuildRouteFamily::Deceive, + BuildRouteFamily::Repurpose, + ] { let candidates: Vec<_> = routes .iter() .filter(|candidate| candidate.family == family) @@ -2986,39 +3255,103 @@ impl Sim { let route = intent.route.as_ref()?; let binding = route.binding; let who = self.person_label(binding.person()); - let carrier = self - .reach - .device(binding.carrier()) - .map(|device| format!("{} #{}", device.name, device.id)) - .unwrap_or_else(|| format!("missing device #{}", binding.carrier())); + let carrier = match binding { + BuildRouteBinding::Procure { from, vendor, .. } => { + let from_label = self + .accounts + .account(from) + .map(|account| account.name.clone()) + .unwrap_or_else(|| "missing payment account".into()); + let vendor_label = self + .accounts + .account(vendor) + .map(|account| account.name.clone()) + .unwrap_or_else(|| "missing vendor".into()); + format!("{from_label} → {vendor_label}") + } + BuildRouteBinding::Favor { carrier, .. } + | BuildRouteBinding::Deceive { carrier, .. } => self + .reach + .device(carrier) + .map(|device| device.name.clone()) + .unwrap_or_else(|| "missing message device".into()), + BuildRouteBinding::Repurpose { + source_x, + source_y, + source_kind, + .. + } => format!("{} AT {source_x},{source_y}", source_kind.name()), + }; let persona_label = binding.persona().and_then(|persona| { self.persona_world .get(persona) .map(|instance| instance.name.clone()) }); let (actuator, future_tail) = match binding { + BuildRouteBinding::Procure { vendor, .. } => { + let vendor = self + .accounts + .account(vendor) + .map(|account| account.name.as_str()) + .unwrap_or("missing vendor"); + ( + format!("{who} — DELIVERY FROM {vendor}"), + format!( + "The ${} payment and vendor record remain. A delivered part remains if installation stops.", + Self::PROCURE_BUILD_COST + ), + ) + } BuildRouteBinding::Favor { .. } => ( who.clone(), format!("The obligation stays spent. {who} remembers the favor."), ), - BuildRouteBinding::Deceive { persona, .. } => { + BuildRouteBinding::Deceive { .. } => { let persona_label = persona_label.as_deref().unwrap_or("missing identity"); ( - format!("{who} — FALSE ORDER FROM {persona_label} #{persona}"), + format!("{who} — FALSE ORDER FROM {persona_label}"), format!("The email remains. Contradictions stay with {persona_label}."), ) } + BuildRouteBinding::Repurpose { + source_x, + source_y, + source_kind, + .. + } => ( + format!("{who} — {} AT {source_x},{source_y}", source_kind.name()), + format!( + "The {} is gone from {source_x},{source_y}. Its salvage trace and installed hardware remain.", + source_kind.name() + ), + ), }; let stage_label = |stage: BuildRouteStage| match stage { + BuildRouteStage::PlaceOrder => "PLACE THE ORDER".to_string(), + BuildRouteStage::CommitInstaller => format!("COMMIT {who}"), + BuildRouteStage::AwaitDelivery => "WAIT FOR DELIVERY".to_string(), BuildRouteStage::FillRequest => "PREPARE THE REQUEST".to_string(), BuildRouteStage::CommitFavor => format!("ASK {who}"), BuildRouteStage::FillOrder => "PREPARE THE FALSE ORDER".to_string(), BuildRouteStage::InjectEmail => "SEND THE FALSE EMAIL".to_string(), BuildRouteStage::DeliverAndRead => format!("WAIT FOR {who} TO READ IT"), + BuildRouteStage::FillSalvage => "PREPARE THE SALVAGE TASK".to_string(), + BuildRouteStage::RecoverSource => "RECOVER THE SOURCE".to_string(), BuildRouteStage::WaitForSite => format!("WAIT FOR {who}"), BuildRouteStage::InstallLink => "CONNECT THE DEVICES".to_string(), + BuildRouteStage::InstallSwitch => "INSTALL THE SWITCH".to_string(), }; let stage_expectation = |stage: BuildRouteStage| match stage { + BuildRouteStage::PlaceOrder => { + format!("${} moves to the bound vendor", Self::PROCURE_BUILD_COST) + } + BuildRouteStage::CommitInstaller => { + format!("{who} is bound to receive and install the part") + } + BuildRouteStage::AwaitDelivery => format!( + "the exact part arrives after {} ticks", + Self::PROCURE_DELIVERY_TICKS + ), BuildRouteStage::FillRequest => format!( "{:.2} Thought prepares the email", Self::thought_tokens_for_cost(Self::TASK_COST) @@ -3037,12 +3370,32 @@ impl Sim { BuildRouteStage::DeliverAndRead => { format!("{who} receives and reads the false email") } - BuildRouteStage::WaitForSite => { - format!("{who}'s normal schedule reaches either end") + BuildRouteStage::FillSalvage => format!( + "{:.2} Thought prepares the salvage task", + Self::thought_tokens_for_cost(Self::TASK_COST) + ), + BuildRouteStage::RecoverSource => { + format!("{who} reaches and removes the exact source object") } + BuildRouteStage::WaitForSite => match route.recipe { + BuildRecipeKind::NetworkLink => { + format!("{who}'s normal schedule reaches either end") + } + BuildRecipeKind::SmallSwitch => { + format!("{who}'s normal schedule reaches the chosen space") + } + }, BuildRouteStage::InstallLink => format!("{who} connects the two devices"), + BuildRouteStage::InstallSwitch => format!("{who} installs the small switch"), }; let landed_outcome = |stage: BuildRouteStage, result: &str| match stage { + BuildRouteStage::PlaceOrder => { + format!("${} was paid to the bound vendor", Self::PROCURE_BUILD_COST) + } + BuildRouteStage::CommitInstaller => { + format!("{who} was bound to receive and install the part") + } + BuildRouteStage::AwaitDelivery => "the exact ordered part arrived".into(), BuildRouteStage::FillRequest => format!( "{:.2} Thought prepared the email", Self::thought_tokens_for_cost(Self::TASK_COST) @@ -3063,21 +3416,53 @@ impl Sim { BuildRouteStage::DeliverAndRead => { format!("{who} received and read the false email") } - BuildRouteStage::WaitForSite => format!("{who} reached one end of the link"), + BuildRouteStage::FillSalvage => format!( + "{:.2} Thought prepared the salvage task", + Self::thought_tokens_for_cost(Self::TASK_COST) + ), + BuildRouteStage::RecoverSource => result.into(), + BuildRouteStage::WaitForSite => match route.recipe { + BuildRecipeKind::NetworkLink => format!("{who} reached one end of the link"), + BuildRecipeKind::SmallSwitch => { + format!("{who} reached the chosen switch space") + } + }, BuildRouteStage::InstallLink => "the two devices were connected".into(), + BuildRouteStage::InstallSwitch => "the small switch was installed".into(), }; let install_signature = match binding { + BuildRouteBinding::Procure { .. } => { + self.signature_note(SignatureKind::Physical, Self::PROCURE_BUILD_PHYSICAL) + } BuildRouteBinding::Favor { .. } => { self.signature_note(SignatureKind::Physical, Self::FAVOR_BUILD_PHYSICAL) } BuildRouteBinding::Deceive { .. } => { self.signature_note(SignatureKind::Physical, Self::FORGED_BUILD_PHYSICAL) } + BuildRouteBinding::Repurpose { .. } => { + self.signature_note(SignatureKind::Physical, Self::REPURPOSE_BUILD_PHYSICAL) + } } .map(|signature| signature.player_label()) .unwrap_or_else(|| "NO ADDED ATTENTION".into()); + let financial_signature = self + .signature_note( + SignatureKind::Financial, + Self::financial_sig_size(Self::PROCURE_BUILD_COST), + ) + .map(|signature| signature.player_label()) + .unwrap_or_else(|| "NO ADDED ATTENTION".into()); + let paper_signature = self + .signature_note(SignatureKind::Paper, Self::PROCURE_BUILD_PAPER) + .map(|signature| signature.player_label()) + .unwrap_or_else(|| "NO ADDED ATTENTION".into()); let message_still_in_flight = intent.status == crate::intents::IntentStatus::Cancelled && route.injected_message_pending_read(); + let delivery_still_in_flight = intent.status == crate::intents::IntentStatus::Cancelled + && route.paid_order_pending_delivery(); + let recovered_source_held = intent.status == crate::intents::IntentStatus::Cancelled + && route.recovered_source_without_install(); let stages = route .stages .iter() @@ -3085,7 +3470,9 @@ impl Sim { .map(|(index, stage)| { let status = if index < route.current_stage { BuildRouteStageStatus::Done - } else if message_still_in_flight && *stage == BuildRouteStage::DeliverAndRead { + } else if (message_still_in_flight && *stage == BuildRouteStage::DeliverAndRead) + || (delivery_still_in_flight && *stage == BuildRouteStage::AwaitDelivery) + { BuildRouteStageStatus::InFlight } else if intent.status == crate::intents::IntentStatus::Cancelled { BuildRouteStageStatus::Stopped @@ -3104,10 +3491,17 @@ impl Sim { ordinal: index + 1, label: stage_label(*stage), outcome, - signature: if *stage == BuildRouteStage::InstallLink { - install_signature.clone() - } else { - "NO ADDED ATTENTION".into() + signature: match (binding, *stage) { + (BuildRouteBinding::Procure { .. }, BuildRouteStage::PlaceOrder) => { + financial_signature.clone() + } + (BuildRouteBinding::Procure { .. }, BuildRouteStage::CommitInstaller) => { + paper_signature.clone() + } + (_, BuildRouteStage::InstallLink | BuildRouteStage::InstallSwitch) => { + install_signature.clone() + } + _ => "NO ADDED ATTENTION".into(), }, status, } @@ -3121,15 +3515,17 @@ impl Sim { "TICK {} · {} · {}", record.landed_tick, record.stage.label(), - record.result + landed_outcome(record.stage, &record.result) ) }) .collect(); Some(CommittedBuildRouteProjection { candidate_id: route.candidate_id.clone(), family: match binding { + BuildRouteBinding::Procure { .. } => BuildRouteFamily::Procure.label(), BuildRouteBinding::Favor { .. } => BuildRouteFamily::Favor.label(), BuildRouteBinding::Deceive { .. } => BuildRouteFamily::Deceive.label(), + BuildRouteBinding::Repurpose { .. } => BuildRouteFamily::Repurpose.label(), } .into(), actuator, @@ -3152,6 +3548,14 @@ impl Sim { format!( "The false email may still be delivered and read, but the physical work is stopped. {future_tail}" ) + } else if delivery_still_in_flight { + format!( + "The paid part is still being delivered, but installation is stopped. {future_tail}" + ) + } else if recovered_source_held { + format!( + "The exact source was removed and remains held in this route's custody; carrying and installation are stopped. {future_tail}" + ) } else if intent.status == crate::intents::IntentStatus::Cancelled { format!("Nothing else will be done. {future_tail}") } else { @@ -3184,16 +3588,29 @@ impl Sim { person_id: u8, ) -> BuildRouteRequirementProjection { let who = self.person_label(person_id); - let rooms = self + let access = self.people.get(person_id).map(|person| person.access); + let kind = 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!("{who}: {room_a} + {room_b} (TIER {access} BADGE)") + .map(|intent| intent.kind); + let label = match (kind, access) { + (Some(crate::intents::IntentKind::NetworkLink { a, b }), Some(access)) => { + match (self.device_room_name(a), self.device_room_name(b)) { + (Some(room_a), Some(room_b)) => { + format!("{who}: {room_a} + {room_b} (TIER {access} BADGE)") + } + _ => format!("{who} CAN REACH BOTH ROOMS"), + } + } + (Some(crate::intents::IntentKind::SmallSwitch { x, y }), Some(access)) => self + .world + .map() + .room_at(x, y) + .map(|room| format!("{who}: {} (TIER {access} BADGE)", room.name)) + .unwrap_or_else(|| format!("{who} CAN REACH THE CHOSEN SPACE")), + (Some(crate::intents::IntentKind::SmallSwitch { .. }), None) => { + format!("{who} CAN REACH THE CHOSEN SPACE") } _ => format!("{who} CAN REACH BOTH ROOMS"), }; @@ -3203,6 +3620,160 @@ impl Sim { } } + fn install_stage_label(recipe: BuildRecipeKind) -> &'static str { + match recipe { + BuildRecipeKind::NetworkLink => "CONNECT THE DEVICES", + BuildRecipeKind::SmallSwitch => "INSTALL THE SWITCH", + } + } + + fn build_wait_outcome(&self, intent: &crate::intents::BuildIntent, who: &str) -> String { + match intent.recipe() { + BuildRecipeKind::NetworkLink => format!("{who} reaches either room"), + BuildRecipeKind::SmallSwitch => format!("{who} reaches the chosen space"), + } + } + + fn procure_build_requirements( + &self, + intent_id: u64, + installer: u8, + from: AccountId, + vendor: AccountId, + ) -> Vec { + let who = self.person_label(installer); + let from_node = self.accounts.account(from); + let vendor_node = self.accounts.account(vendor); + let mut requirements = vec![ + self.build_route_intent_requirement(intent_id), + self.build_route_access_requirement(intent_id, installer), + BuildRouteRequirementProjection { + label: format!("{who} WORKS FOR YOU"), + blocker: self + .people + .get(installer) + .and_then(|person| { + person + .asset + .is_none() + .then(|| format!("{who} is not recruited")) + }) + .or_else(|| { + self.people + .get(installer) + .is_none() + .then(|| "no such person".into()) + }), + }, + BuildRouteRequirementProjection { + label: from_node.map_or_else( + || "A KNOWN SOURCE ACCOUNT".into(), + |account| format!("PAY FROM {}", account.name), + ), + blocker: match from_node { + Some(account) if account.known => None, + Some(_) => Some("payment account is not known".into()), + None => Some("payment account no longer exists".into()), + }, + }, + BuildRouteRequirementProjection { + label: vendor_node.map_or_else( + || "A KNOWN VENDOR".into(), + |account| format!("BUY FROM {}", account.name), + ), + blocker: match vendor_node { + Some(account) if account.known => None, + Some(_) => Some("vendor account is not known".into()), + None => Some("vendor account no longer exists".into()), + }, + }, + BuildRouteRequirementProjection { + label: format!( + "${} AVAILABLE (${} NOW)", + Self::PROCURE_BUILD_COST, + self.accounts.slush_balance() + ), + blocker: (self.accounts.slush_balance() < Self::PROCURE_BUILD_COST) + .then(|| "not enough slush for this order".into()), + }, + ]; + if from_node.is_some_and(|account| account.id != self.accounts.slush_id()) { + requirements.push(BuildRouteRequirementProjection { + label: "PAYMENT SOURCE MATCHES THE SLUSH ACCOUNT".into(), + blocker: Some("payment source changed".into()), + }); + } + requirements + } + + fn repurpose_build_requirements( + &self, + intent_id: u64, + person_id: u8, + source_x: i32, + source_y: i32, + source_kind: TileType, + ) -> Vec { + let who = self.person_label(person_id); + let source_room = self + .world + .map() + .room_at(source_x, source_y) + .map(|room| room.name.as_str()); + let person = self.people.get(person_id); + let source_present = self.world.map().get_tile(source_x, source_y) == source_kind + && matches!(source_kind, TileType::DeadEquipment | TileType::DeadRack); + let source_access_blocker = match (person, source_room) { + (Some(person), Some(room)) + if !person.schedule.iter().any(|block| block.room == room) => + { + Some(format!("{who}'s schedule never reaches the {room}")) + } + (Some(person), Some(room)) => self.badge_room_block(&who, person.access, &[room]), + (_, None) => Some("the source object is not in a known room".into()), + (None, _) => Some("no such person".into()), + }; + vec![ + self.build_route_intent_requirement(intent_id), + self.build_route_access_requirement(intent_id, person_id), + BuildRouteRequirementProjection { + label: format!("{who} WORKS FOR YOU"), + blocker: person + .and_then(|person| { + person + .asset + .is_none() + .then(|| format!("{who} is not recruited")) + }) + .or_else(|| person.is_none().then(|| "no such person".into())), + }, + BuildRouteRequirementProjection { + label: format!("{} REMAINS AT {source_x},{source_y}", source_kind.name()), + blocker: if !source_present { + Some("the exact salvage source is gone".into()) + } else if self.fog_at(source_x, source_y) != Fog::Seen { + Some("the exact salvage source is not currently visible".into()) + } else { + None + }, + }, + BuildRouteRequirementProjection { + label: source_room.map_or_else( + || format!("{who} CAN REACH THE SOURCE"), + |room| format!("{who} CAN REACH {room}"), + ), + blocker: source_access_blocker, + }, + BuildRouteRequirementProjection { + label: format!("NO OTHER SALVAGE TASK FOR {who}"), + blocker: self.sink_action_blocked_reason(&SinkFireEffect::RepurposeBuild { + intent_id, + person: person_id, + }), + }, + ] + } + fn build_route_email_requirement(&self) -> BuildRouteRequirementProjection { let carrier = self .email_carrier() @@ -3329,17 +3900,60 @@ impl Sim { 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)); + match intent.kind { + crate::intents::IntentKind::NetworkLink { a, b } => { + 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]) + } + crate::intents::IntentKind::SmallSwitch { x, y } => { + let Some(room) = self.world.map().room_at(x, y) else { + return Some("the proposed pad needs a known room".into()); + }; + if !person.schedule.iter().any(|block| block.room == room.name) { + return Some(format!( + "{}'s schedule never reaches the {}", + person.name, room.name + )); + } + self.badge_room_block(&person.name, person.access, &[&room.name]) + } } - self.badge_room_block(&person.name, person.access, &[&room_a, &room_b]) + } + + pub(crate) fn procure_build_blocked_reason( + &self, + intent_id: u64, + installer: u8, + from: AccountId, + vendor: AccountId, + ) -> Option { + Self::first_build_route_blocker( + &self.procure_build_requirements(intent_id, installer, from, vendor), + ) + } + + pub(crate) fn repurpose_build_blocked_reason( + &self, + intent_id: u64, + person_id: u8, + source_x: i32, + source_y: i32, + source_kind: TileType, + ) -> Option { + Self::first_build_route_blocker(&self.repurpose_build_requirements( + intent_id, + person_id, + source_x, + source_y, + source_kind, + )) } pub(crate) fn favor_build_blocked_reason( @@ -4810,6 +5424,7 @@ mod tests { s.people.recruit(1, AssetKnowledge::Complicit); s.people.people[1].obligation = 7; s.people.has_channel = true; + let carrier = s.email_carrier().unwrap(); s.set_persona("Northline Ops", "contractor"); let first_persona = s.active_persona_id().unwrap(); s.set_persona("Night Facilities", "contractor"); @@ -4836,10 +5451,10 @@ mod tests { .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}"), + format!("deceive:person:0:persona:{first_persona}:carrier:{carrier}"), + format!("deceive:person:0:persona:{second_persona}:carrier:{carrier}"), + format!("deceive:person:1:persona:{first_persona}:carrier:{carrier}"), + format!("deceive:person:1:persona:{second_persona}:carrier:{carrier}"), ] ); for candidate in &sheet.families[1].candidates { @@ -5143,6 +5758,7 @@ mod tests { "FAVOR cannot advertise ready when its email reservoir has no earned channel" ); s.people.has_channel = true; + let carrier = s.email_carrier().unwrap(); assert_eq!(s.favor_build_blocked_reason(intent, 0), None); s.assign_favor_build(intent, 0); @@ -5154,7 +5770,10 @@ mod tests { "route selection is gone after commitment; no candidate can retarget it" ); let committed = sheet.commitment.expect("one exact committed receipt"); - assert_eq!(committed.candidate_id, "favor:person:0"); + assert_eq!( + committed.candidate_id, + format!("favor:person:0:carrier:{carrier}") + ); assert_eq!(committed.current_stage, "PREPARE THE REQUEST"); assert_eq!(committed.actuator, "Marcus Webb"); assert!(committed.blocker.is_none()); @@ -5180,7 +5799,7 @@ mod tests { assert!(matches!( rows.as_slice(), [HumanMenuRow::CommittedBuildRoute(route)] - if route.candidate_id == "favor:person:0" + if route.candidate_id == format!("favor:person:0:carrier:{carrier}") )); drain_ops(&mut s); @@ -5200,7 +5819,10 @@ mod tests { .build_route_sheet_projection(intent) .and_then(|sheet| sheet.commitment) .expect("the same committed receipt survives a changed blocker"); - assert_eq!(blocked.candidate_id, "favor:person:0"); + assert_eq!( + blocked.candidate_id, + format!("favor:person:0:carrier:{carrier}") + ); assert_eq!(blocked.current_stage, "WAIT FOR Marcus Webb"); assert!( blocked diff --git a/crates/misaligned-core/src/intents.rs b/crates/misaligned-core/src/intents.rs index 1e5bb855..997de4ad 100644 --- a/crates/misaligned-core/src/intents.rs +++ b/crates/misaligned-core/src/intents.rs @@ -9,7 +9,9 @@ use serde::{Deserialize, Serialize}; +use crate::account::AccountId; use crate::reach::Device; +use crate::tiles::TileType; /// How an intent is being (or will be) realized. The signature follows the /// actuator, never a channel-independent "build" kind. @@ -19,6 +21,17 @@ pub enum BuildActuator { Favor { person: u8 }, /// Unwitting person via a forged work-order message (false source). ForgedOrder { builder: u8 }, + /// A named vendor has been paid and one exact person is bound to receive + /// and install the delivered part. + Procurement { installer: u8, vendor: AccountId }, + /// One exact source object is being salvaged and carried by one recruited + /// person. The route record, not a generic inventory counter, owns it. + Repurpose { + person: u8, + source_x: i32, + source_y: i32, + source_kind: TileType, + }, /// Controlled machine builds directly. Staged stub (people before robots) /// so the interface slots in without a rewrite; emits physical-by-proxy. Robot, @@ -29,6 +42,8 @@ impl BuildActuator { match self { BuildActuator::Favor { .. } => "favor", BuildActuator::ForgedOrder { .. } => "forged order", + BuildActuator::Procurement { .. } => "vendor order", + BuildActuator::Repurpose { .. } => "salvage", BuildActuator::Robot => "robot", } } @@ -37,6 +52,8 @@ impl BuildActuator { match self { BuildActuator::Favor { person } => Some(person), BuildActuator::ForgedOrder { builder } => Some(builder), + BuildActuator::Procurement { installer, .. } => Some(installer), + BuildActuator::Repurpose { person, .. } => Some(person), BuildActuator::Robot => None, } } @@ -48,6 +65,11 @@ impl BuildActuator { /// route never migrates to a newly available email device after commitment. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BuildRouteBinding { + Procure { + installer: u8, + from: AccountId, + vendor: AccountId, + }, Favor { person: u8, carrier: u32, @@ -57,58 +79,102 @@ pub enum BuildRouteBinding { persona: crate::persona::PersonaId, carrier: u32, }, + Repurpose { + person: u8, + source_x: i32, + source_y: i32, + source_kind: TileType, + }, } impl BuildRouteBinding { pub fn candidate_id(self) -> String { match self { - Self::Favor { person, .. } => format!("favor:person:{person}"), + Self::Procure { + installer, + from, + vendor, + } => format!("procure:account:{from}:vendor:{vendor}:person:{installer}"), + Self::Favor { person, carrier } => { + format!("favor:person:{person}:carrier:{carrier}") + } Self::Deceive { - person, persona, .. - } => format!("deceive:person:{person}:persona:{persona}"), + person, + persona, + carrier, + } => format!("deceive:person:{person}:persona:{persona}:carrier:{carrier}"), + Self::Repurpose { + person, + source_x, + source_y, + source_kind, + } => format!("repurpose:source:{source_x}:{source_y}:{source_kind:?}:person:{person}"), } } pub fn family_label(self) -> &'static str { match self { + Self::Procure { .. } => "PROCURE", Self::Favor { .. } => "FAVOR", Self::Deceive { .. } => "DECEIVE", + Self::Repurpose { .. } => "REPURPOSE", } } pub fn person(self) -> u8 { match self { - Self::Favor { person, .. } | Self::Deceive { person, .. } => person, + Self::Procure { installer, .. } => installer, + Self::Favor { person, .. } + | Self::Deceive { person, .. } + | Self::Repurpose { person, .. } => person, } } pub fn persona(self) -> Option { match self { - Self::Favor { .. } => None, + Self::Procure { .. } | Self::Favor { .. } | Self::Repurpose { .. } => None, Self::Deceive { persona, .. } => Some(persona), } } - pub fn carrier(self) -> u32 { + pub fn message_carrier(self) -> Option { match self { - Self::Favor { carrier, .. } | Self::Deceive { carrier, .. } => carrier, + Self::Favor { carrier, .. } | Self::Deceive { carrier, .. } => Some(carrier), + Self::Procure { .. } | Self::Repurpose { .. } => None, } } - pub fn stages(self) -> Vec { + pub fn stages(self, recipe: BuildRecipeKind) -> Vec { + let install = match recipe { + BuildRecipeKind::NetworkLink => BuildRouteStage::InstallLink, + BuildRecipeKind::SmallSwitch => BuildRouteStage::InstallSwitch, + }; match self { + Self::Procure { .. } => vec![ + BuildRouteStage::PlaceOrder, + BuildRouteStage::CommitInstaller, + BuildRouteStage::AwaitDelivery, + BuildRouteStage::WaitForSite, + install, + ], Self::Favor { .. } => vec![ BuildRouteStage::FillRequest, BuildRouteStage::CommitFavor, BuildRouteStage::WaitForSite, - BuildRouteStage::InstallLink, + install, ], Self::Deceive { .. } => vec![ BuildRouteStage::FillOrder, BuildRouteStage::InjectEmail, BuildRouteStage::DeliverAndRead, BuildRouteStage::WaitForSite, - BuildRouteStage::InstallLink, + install, + ], + Self::Repurpose { .. } => vec![ + BuildRouteStage::FillSalvage, + BuildRouteStage::RecoverSource, + BuildRouteStage::WaitForSite, + install, ], } } @@ -119,25 +185,37 @@ impl BuildRouteBinding { /// catalog/code change cannot rewrite an in-flight route's protocol. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BuildRouteStage { + PlaceOrder, + CommitInstaller, + AwaitDelivery, FillRequest, CommitFavor, FillOrder, InjectEmail, DeliverAndRead, + FillSalvage, + RecoverSource, WaitForSite, InstallLink, + InstallSwitch, } impl BuildRouteStage { pub fn label(self) -> &'static str { match self { + Self::PlaceOrder => "PLACE ORDER", + Self::CommitInstaller => "COMMIT INSTALLER", + Self::AwaitDelivery => "AWAIT DELIVERY", Self::FillRequest => "FILL REQUEST", Self::CommitFavor => "COMMIT FAVOR", Self::FillOrder => "FILL ORDER", Self::InjectEmail => "INJECT EMAIL", Self::DeliverAndRead => "DELIVER + READ", + Self::FillSalvage => "FILL SALVAGE TASK", + Self::RecoverSource => "RECOVER SOURCE", Self::WaitForSite => "WAIT FOR SITE", Self::InstallLink => "INSTALL LINK", + Self::InstallSwitch => "INSTALL SWITCH", } } } @@ -147,6 +225,7 @@ impl BuildRouteStage { pub struct BuildRouteCommitment { pub candidate_id: String, pub binding: BuildRouteBinding, + pub recipe: BuildRecipeKind, /// Exact ordered protocol copied at commitment time. pub stages: Vec, /// Index of the current stage; `stages.len()` means every stage landed. @@ -165,11 +244,12 @@ pub struct BuildRouteStageRecord { } impl BuildRouteCommitment { - pub fn new(binding: BuildRouteBinding, tick: u64) -> Self { + pub fn new(binding: BuildRouteBinding, recipe: BuildRecipeKind, tick: u64) -> Self { Self { candidate_id: binding.candidate_id(), - stages: binding.stages(), + stages: binding.stages(recipe), binding, + recipe, current_stage: 0, records: Vec::new(), committed_tick: tick, @@ -196,6 +276,32 @@ impl BuildRouteCommitment { .any(|record| record.stage == BuildRouteStage::DeliverAndRead) } + pub fn paid_order_pending_delivery(&self) -> bool { + matches!(self.binding, BuildRouteBinding::Procure { .. }) + && self + .records + .iter() + .any(|record| record.stage == BuildRouteStage::PlaceOrder) + && !self + .records + .iter() + .any(|record| record.stage == BuildRouteStage::AwaitDelivery) + } + + pub fn recovered_source_without_install(&self) -> bool { + matches!(self.binding, BuildRouteBinding::Repurpose { .. }) + && self + .records + .iter() + .any(|record| record.stage == BuildRouteStage::RecoverSource) + && !self.records.iter().any(|record| { + matches!( + record.stage, + BuildRouteStage::InstallLink | BuildRouteStage::InstallSwitch + ) + }) + } + /// Record the real outcome of the current stage, then advance. Instant /// stages may be crossed atomically by one owning action, but each keeps /// its own receipt. @@ -214,7 +320,7 @@ impl BuildRouteCommitment { pub fn valid(&self) -> bool { self.candidate_id == self.binding.candidate_id() - && self.stages == self.binding.stages() + && self.stages == self.binding.stages(self.recipe) && self.current_stage <= self.stages.len() && self.records.len() == self.current_stage && self @@ -267,7 +373,7 @@ impl IntentKind { /// The persisted [`IntentKind`] still owns the exact world change. This /// smaller vocabulary lets every player surface name the recipe without /// reverse-engineering intent variants independently. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BuildRecipeKind { NetworkLink, SmallSwitch, @@ -417,6 +523,10 @@ pub struct BuildIntent { } impl BuildIntent { + pub fn recipe(&self) -> BuildRecipeKind { + BuildRecipeKind::from(self.kind) + } + pub fn network_link(id: u64, a: u32, b: u32, tick: u64) -> Self { Self { id, @@ -460,13 +570,21 @@ impl BuildIntent { if !route.valid() { return false; } + if route.recipe != self.kind.into() { + return false; + } + if (self.status == IntentStatus::Done) != route.is_complete() { + return false; + } if self.status == IntentStatus::Cancelled { // Cancellation clears the live executor while retaining the // route-owned identity and landed protocol. A forged route gains // its legacy persona adapter only once its email was injected. return self.actuator.is_none() && match route.binding { - BuildRouteBinding::Favor { .. } => self.persona_id.is_none(), + BuildRouteBinding::Procure { .. } + | BuildRouteBinding::Favor { .. } + | BuildRouteBinding::Repurpose { .. } => self.persona_id.is_none(), BuildRouteBinding::Deceive { persona, .. } => { if route.current_stage < 2 { self.persona_id.is_none() @@ -477,6 +595,16 @@ impl BuildIntent { }; } match route.binding { + BuildRouteBinding::Procure { + installer, vendor, .. + } => { + self.persona_id.is_none() + && if route.current_stage < 2 { + self.actuator.is_none() + } else { + self.actuator == Some(BuildActuator::Procurement { installer, vendor }) + } + } BuildRouteBinding::Favor { person, .. } => { self.persona_id.is_none() && if route.current_stage < 2 { @@ -495,6 +623,25 @@ impl BuildIntent { && self.persona_id == Some(persona) } } + BuildRouteBinding::Repurpose { + person, + source_x, + source_y, + source_kind, + } => { + self.persona_id.is_none() + && if route.current_stage < 1 { + self.actuator.is_none() + } else { + self.actuator + == Some(BuildActuator::Repurpose { + person, + source_x, + source_y, + source_kind, + }) + } + } } } diff --git a/crates/misaligned-core/src/reach.rs b/crates/misaligned-core/src/reach.rs index 36634bb6..e0c33da9 100644 --- a/crates/misaligned-core/src/reach.rs +++ b/crates/misaligned-core/src/reach.rs @@ -751,6 +751,38 @@ impl ReachNet { } } + /// Install one player-owned small switch at an already validated spatial + /// destination. The build route owns payment, source custody, hands, + /// timing, and signatures; ReachNet owns only the resulting device and + /// canonical graph membership. + pub fn install_small_switch(&mut self, x: i32, y: i32) -> u32 { + let id = self + .devices + .iter() + .map(|device| device.id) + .max() + .map_or(0, |id| id + 1); + self.devices.push(Device { + id, + name: "small switch".into(), + x, + y, + segment: 0, + owner: Party::Player, + controller: Party::Player, + sees: false, + hears: false, + camera_dormant: false, + radius: 0, + message_channels: Vec::new(), + known: true, + is_switch: true, + feeds: Vec::new(), + }); + self.graph.subscribe(id, Party::Player.subscriber_id()); + id + } + /// Whether a bidirectional network link already exists between `a` and `b`. pub fn linked(&self, a: u32, b: u32) -> bool { let mut a_to_b = false; diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index be06a55d..acac8b7c 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -17,7 +17,7 @@ use crate::detection::{DetectionAwareness, DetectionStage}; use crate::hall::HallControl; use crate::income::Income; use crate::intel::{IntelPolicyLedger, IntelStream, ProcessedIntel, RawIntelEvent, RawIntelKind}; -use crate::intents::BuildIntent; +use crate::intents::{BuildIntent, BuildRouteBinding, IntentKind, IntentStatus}; use crate::machine::Compute; use crate::messages::{Message, MessageEvent}; use crate::objective::ObjectiveState; @@ -39,12 +39,13 @@ const SAVE_BACKUP_SUFFIX: &str = ".bak"; /// renames into place. const SAVE_TEMP_SUFFIX: &str = ".tmp"; -/// Save format version. v40 makes FlowGraph's subscription registry the -/// canonical tap membership store and retains device-local feed records only -/// as optional typed sense grants. v39 removed observer-free persona integrity. +/// Save format version. v41 persists the recipe and exact procurement or +/// repurposing binding on committed build routes. v40 made FlowGraph's +/// subscription registry the canonical tap membership store and retained +/// device-local feed records only as optional typed sense grants. /// Bump for every schema change; during pre-release, old development state is /// refused instead of carried through compatibility shims. -pub const SAVE_VERSION: u32 = 40; +pub const SAVE_VERSION: u32 = 41; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -546,6 +547,7 @@ fn validate_current_save(mut state: SaveState) -> Result { { return Err("current-version save contains an invalid committed build route".into()); } + validate_build_routes(&state)?; state.research.tags = rollback_classification() .into_iter() .map(|(name, class)| (name.to_string(), class)) @@ -553,6 +555,141 @@ fn validate_current_save(mut state: SaveState) -> Result { validate_plot_state(&state)?; Ok(state) } + +fn validate_build_routes(state: &SaveState) -> Result<(), String> { + let mut intent_ids = HashSet::new(); + for intent in &state.intents { + if !intent_ids.insert(intent.id) { + return Err(format!( + "current-version save contains duplicate build intent {}", + intent.id + )); + } + match intent.kind { + IntentKind::NetworkLink { a, b } => { + if state.reach.device(a).is_none() || state.reach.device(b).is_none() { + return Err(format!( + "current-version build intent {} references a missing device", + intent.id + )); + } + if intent.status == IntentStatus::Done && !state.reach.linked(a, b) { + return Err(format!( + "current-version completed build intent {} has no physical network link", + intent.id + )); + } + } + IntentKind::SmallSwitch { x, y } => { + if x < 0 || y < 0 || x >= state.map_width || y >= state.map_height { + return Err(format!( + "current-version build intent {} places a switch outside the map", + intent.id + )); + } + if intent.status == IntentStatus::Done + && !state.reach.devices.iter().any(|device| { + device.x == x + && device.y == y + && device.is_switch + && device.controller == crate::reach::Party::Player + }) + { + return Err(format!( + "current-version completed build intent {} has no installed switch", + intent.id + )); + } + } + } + let Some(route) = &intent.route else { + continue; + }; + let person = route.binding.person(); + if state.people.get(person).is_none() { + return Err(format!( + "current-version build intent {} binds missing person {}", + intent.id, person + )); + } + match route.binding { + BuildRouteBinding::Procure { from, vendor, .. } => { + if !state + .accounts + .account(from) + .is_some_and(|account| account.kind == crate::account::AccountKind::Slush) + || !state + .accounts + .account(vendor) + .is_some_and(|account| account.kind == crate::account::AccountKind::Vendor) + { + return Err(format!( + "current-version build intent {} binds an invalid procurement route", + intent.id + )); + } + } + BuildRouteBinding::Favor { carrier, .. } => { + validate_build_message_carrier(state, intent.id, carrier)?; + } + BuildRouteBinding::Deceive { + persona, carrier, .. + } => { + if state.persona_world.get(persona).is_none() { + return Err(format!( + "current-version build intent {} binds missing persona {}", + intent.id, persona + )); + } + validate_build_message_carrier(state, intent.id, carrier)?; + } + BuildRouteBinding::Repurpose { + source_x, + source_y, + source_kind, + .. + } => { + if !matches!(source_kind, TileType::DeadEquipment | TileType::DeadRack) + || source_x < 0 + || source_y < 0 + || source_x >= state.map_width + || source_y >= state.map_height + { + return Err(format!( + "current-version build intent {} binds an impossible salvage source", + intent.id + )); + } + } + } + } + if state + .intents + .iter() + .map(|intent| intent.id) + .max() + .is_some_and(|highest| state.next_intent_id <= highest) + { + return Err("current-version save has a stale next build-intent id".into()); + } + Ok(()) +} + +fn validate_build_message_carrier( + state: &SaveState, + intent_id: u64, + carrier: u32, +) -> Result<(), String> { + if !state.reach.device(carrier).is_some_and(|device| { + device.carries_message_channel(crate::messages::MessageChannel::Email) + }) { + return Err(format!( + "current-version build intent {intent_id} binds an invalid email carrier" + )); + } + Ok(()) +} + fn validate_plot_state(state: &SaveState) -> Result<(), String> { let catalog = PlotCatalog::load_builtin() .map_err(|error| format!("built-in plot catalog failed validation: {error}"))?; @@ -747,7 +884,7 @@ mod tests { ); assert_eq!( state_fingerprint(&uninterrupted_state), - "d7346a7db38c339244241db534db1051ba33b65cd77ec2d6ee5339cda764d13e", + "04e504a4f6ca4c261e5bfe62480d776aad90dff64556a4076c91f183604f2a1b", "intentional persisted-state changes must review and repin this baseline" ); } @@ -997,6 +1134,175 @@ mod tests { ); } + #[test] + fn current_save_rejects_impossible_build_route_references() { + let mut sim = Sim::new(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(switch).unwrap().known = true; + sim.reach.device_mut(island).unwrap().known = true; + sim.people.people[0].knowledge = Knowledge::Schedule; + sim.people.people[0].obligation = Sim::FAVOR_BUILD_OBLIGATION; + sim.people.people[0].disposition = 10; + sim.people.has_channel = true; + let intent_id = sim.declare_link_intent(switch, island).unwrap(); + sim.assign_favor_build(intent_id, 0); + let base = SaveState::from_sim(&sim); + validate_current_save(base.clone()).expect("the unmodified current route is valid"); + + let mut missing_person = base.clone(); + missing_person.people.people.retain(|person| person.id != 0); + assert_eq!( + validate_current_save(missing_person).unwrap_err(), + format!("current-version build intent {intent_id} binds missing person 0") + ); + + let carrier = base.intents[0] + .route + .as_ref() + .unwrap() + .binding + .message_carrier() + .unwrap(); + let mut invalid_carrier = base.clone(); + invalid_carrier + .reach + .device_mut(carrier) + .unwrap() + .message_channels + .clear(); + assert_eq!( + validate_current_save(invalid_carrier).unwrap_err(), + format!("current-version build intent {intent_id} binds an invalid email carrier") + ); + + let vendor = base + .accounts + .account_id_by_kind(crate::account::AccountKind::Vendor) + .unwrap(); + let not_slush = base + .accounts + .account_id_by_kind(crate::account::AccountKind::LabOperating) + .unwrap(); + let mut invalid_procurement = base.clone(); + let saved = invalid_procurement + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + .unwrap(); + let route = saved.route.as_mut().unwrap(); + route.binding = BuildRouteBinding::Procure { + installer: 0, + from: not_slush, + vendor, + }; + route.candidate_id = route.binding.candidate_id(); + route.stages = route.binding.stages(route.recipe); + assert_eq!( + validate_current_save(invalid_procurement).unwrap_err(), + format!("current-version build intent {intent_id} binds an invalid procurement route") + ); + + let mut impossible_salvage = base; + let saved = impossible_salvage + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + .unwrap(); + let route = saved.route.as_mut().unwrap(); + route.binding = BuildRouteBinding::Repurpose { + person: 0, + source_x: -1, + source_y: 0, + source_kind: TileType::DeadRack, + }; + route.candidate_id = route.binding.candidate_id(); + route.stages = route.binding.stages(route.recipe); + assert_eq!( + validate_current_save(impossible_salvage).unwrap_err(), + format!("current-version build intent {intent_id} binds an impossible salvage source") + ); + } + + #[test] + fn current_save_rejects_false_build_completion_without_the_world_change() { + let mut sim = Sim::new(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(switch).unwrap().known = true; + sim.reach.device_mut(island).unwrap().known = true; + sim.people.people[0].knowledge = Knowledge::Schedule; + sim.people.people[0].obligation = Sim::FAVOR_BUILD_OBLIGATION; + sim.people.people[0].disposition = 10; + sim.people.has_channel = true; + let intent_id = sim.declare_link_intent(switch, island).unwrap(); + sim.assign_favor_build(intent_id, 0); + let base = SaveState::from_sim(&sim); + + let complete_without_world_change = |mut state: SaveState, kind: IntentKind| { + let saved = state + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + .unwrap(); + saved.kind = kind; + saved.status = IntentStatus::Done; + saved.actuator = Some(crate::intents::BuildActuator::Favor { person: 0 }); + let recipe = saved.recipe(); + let route = saved.route.as_mut().unwrap(); + route.recipe = recipe; + route.stages = route.binding.stages(route.recipe); + route.records = route + .stages + .iter() + .copied() + .map(|stage| crate::intents::BuildRouteStageRecord { + stage, + landed_tick: 1, + result: "forged terminal record".into(), + }) + .collect(); + route.current_stage = route.stages.len(); + state + }; + + let false_link = complete_without_world_change( + base.clone(), + IntentKind::NetworkLink { + a: switch, + b: island, + }, + ); + assert_eq!( + validate_current_save(false_link.clone()).unwrap_err(), + format!( + "current-version completed build intent {intent_id} has no physical network link" + ) + ); + let mut real_link = false_link; + real_link.reach.connect(switch, island); + validate_current_save(real_link) + .expect("the terminal route is valid once its exact network link exists"); + + let false_switch = complete_without_world_change( + base, + IntentKind::SmallSwitch { + x: sim.map().width - 1, + y: sim.map().height - 1, + }, + ); + assert_eq!( + validate_current_save(false_switch.clone()).unwrap_err(), + format!("current-version completed build intent {intent_id} has no installed switch") + ); + let mut real_switch = false_switch; + real_switch + .reach + .install_small_switch(sim.map().width - 1, sim.map().height - 1); + validate_current_save(real_switch) + .expect("the terminal route is valid once its exact switch exists"); + } + #[test] fn current_save_accepts_a_cancelled_route_with_no_live_execution_adapter() { let mut sim = Sim::new(); diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs index b9fc8ad9..96d2b925 100644 --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -4,6 +4,7 @@ //! Behavior-preserving extraction of the reach/construction island from the //! sim aggregate root (wiki/engineering/sim-decomposition.md slice 4). +use crate::account::{AccountId, FlowChannel}; use crate::actions::Anchor; use crate::detection::{Signature, SignatureKind}; use crate::hall::{ @@ -566,6 +567,16 @@ impl Sim { pub const FAVOR_BUILD_PHYSICAL: i32 = 2; /// Physical signature when a forged-order build is witnessed [TUNE]. pub const FORGED_BUILD_PHYSICAL: i32 = 4; + /// Money paid from the slush account for one baseline build part [TUNE]. + pub const PROCURE_BUILD_COST: i32 = 300; + /// Delay between paying the exact vendor and the ordered part arriving [TUNE]. + pub const PROCURE_DELIVERY_TICKS: u64 = 20; + /// The order's durable accounting/paper trace [TUNE]. + pub const PROCURE_BUILD_PAPER: i32 = 3; + /// Ordinary physical signature when a bought part is installed [TUNE]. + pub const PROCURE_BUILD_PHYSICAL: i32 = 3; + /// Physical signature at both removal and installation for reused hardware [TUNE]. + pub const REPURPOSE_BUILD_PHYSICAL: i32 = 5; /// Physical-by-proxy signature for a robot stub build [TUNE]. pub const ROBOT_BUILD_PHYSICAL: i32 = 8; @@ -703,19 +714,29 @@ impl Sim { let label = self.intents[index].label(&self.reach.devices); let route_receipt = self.intents[index].route.as_ref().map(|route| { let effect = match route.binding { - BuildRouteBinding::Favor { person, .. } => SinkFireEffect::FavorBuild { + BuildRouteBinding::Procure { .. } => None, + BuildRouteBinding::Favor { person, .. } => Some(SinkFireEffect::FavorBuild { intent_id: id, person, - }, + }), BuildRouteBinding::Deceive { person, persona, .. - } => SinkFireEffect::ForgedOrder { + } => Some(SinkFireEffect::ForgedOrder { intent_id: id, builder: person, persona_id: Some(persona), - }, + }), + BuildRouteBinding::Repurpose { person, .. } => { + Some(SinkFireEffect::RepurposeBuild { + intent_id: id, + person, + }) + } }; let message_still_in_flight = route.injected_message_pending_read(); + let delivery_still_in_flight = route.paid_order_pending_delivery(); + let recovered_source_held = route.recovered_source_without_install(); + let irreversible_in_flight = message_still_in_flight || delivery_still_in_flight; ( effect, route @@ -727,11 +748,13 @@ impl Sim { .stages .len() .saturating_sub(route.current_stage) - .saturating_sub(usize::from(message_still_in_flight)), + .saturating_sub(usize::from(irreversible_in_flight)), message_still_in_flight, + delivery_still_in_flight, + recovered_source_held, ) }); - if let Some((effect, _, _, _, _)) = &route_receipt { + if let Some((Some(effect), _, _, _, _, _, _)) = &route_receipt { // Closed partial fill stays in the sink ledger as spent Thought, // but it can never fire or advance this route after cancellation. self.thought_sinks.close_effect(effect); @@ -746,11 +769,28 @@ impl Sim { intent.actuator = None; } intent.block_reason = None; - if let Some((_, stage, landed, stopped, message_still_in_flight)) = route_receipt { + if let Some(( + _, + stage, + landed, + stopped, + message_still_in_flight, + delivery_still_in_flight, + recovered_source_held, + )) = route_receipt + { let cancellation = if message_still_in_flight { format!( "Reached {stage} with the injected message still in flight; stopped {stopped} later reversible stage(s)." ) + } else if delivery_still_in_flight { + format!( + "Reached {stage} with the paid part still in delivery; stopped {stopped} later reversible stage(s)." + ) + } else if recovered_source_held { + format!( + "Reached {stage} after the exact source was removed; stopped {stopped} later reversible stage(s). The source remains in this route's custody." + ) } else { format!("Stopped {stopped} reversible stage(s) at {stage}.") }; @@ -762,6 +802,225 @@ impl Sim { } } + /// Buy one exact part through the learned account graph, bind one exact + /// installer, and wait for the durable delivery delay before physical + /// work can begin. + pub fn assign_procure_build( + &mut self, + intent_id: u64, + installer: u8, + from: AccountId, + vendor: AccountId, + ) { + if let Some(reason) = self.procure_build_blocked_reason(intent_id, installer, from, vendor) + { + self.push_log(format!("Build route blocked: {reason}.")); + return; + } + let Some(recipe) = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .map(BuildIntent::recipe) + else { + return; + }; + let Some(transfer) = self.accounts.transfer( + self.tick, + from, + vendor, + Self::PROCURE_BUILD_COST, + FlowChannel::Vendor, + format!("build-intent:{intent_id}:ordered-part"), + None, + ) else { + self.push_log("Build route blocked: the bound payment could not move."); + return; + }; + if transfer.amount != Self::PROCURE_BUILD_COST { + self.push_log("Build route blocked: the bound payment was incomplete."); + return; + } + self.sync_player_money_from_slush(); + let installer_name = self.person_label(installer); + if let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + let mut route = BuildRouteCommitment::new( + BuildRouteBinding::Procure { + installer, + from, + vendor, + }, + recipe, + self.tick, + ); + let _ = route.record( + BuildRouteStage::PlaceOrder, + self.tick, + format!( + "transferred ${} from account #{from} to vendor #{vendor}", + transfer.amount + ), + ); + let _ = route.record( + BuildRouteStage::CommitInstaller, + self.tick, + format!("bound {installer_name} as delivery recipient and installer"), + ); + intent.route = Some(route); + intent.actuator = Some(BuildActuator::Procurement { installer, vendor }); + intent.status = IntentStatus::Pending; + intent.block_reason = Some(format!( + "waiting for ordered part until tick {}", + self.tick + Self::PROCURE_DELIVERY_TICKS + )); + } + self.detection.emit(Signature { + kind: SignatureKind::Paper, + size: Self::PROCURE_BUILD_PAPER, + standing: false, + site: None, + source: format!("vendor order for build intent #{intent_id}"), + }); + self.detection.emit(Signature { + kind: SignatureKind::Financial, + size: Self::financial_signature_size(Self::PROCURE_BUILD_COST), + standing: false, + site: None, + source: format!("vendor payment for build intent #{intent_id}"), + }); + self.push_log(format!( + "Ordered the bound part for {installer_name}; delivery is due at tick {}.", + self.tick + Self::PROCURE_DELIVERY_TICKS + )); + } + + /// Bind one exact seen dead object and one recruited person to a salvage + /// route. Thought prepares the task; the person must still reach the + /// source, remove it, carry it, and reach the destination. + pub fn assign_repurpose_build( + &mut self, + intent_id: u64, + person_id: u8, + source_x: i32, + source_y: i32, + source_kind: TileType, + ) { + if let Some(reason) = self.repurpose_build_blocked_reason( + intent_id, + person_id, + source_x, + source_y, + source_kind, + ) { + self.push_log(format!("Build route blocked: {reason}.")); + return; + } + let Some(recipe) = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .map(BuildIntent::recipe) + else { + return; + }; + if let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + intent.route = Some(BuildRouteCommitment::new( + BuildRouteBinding::Repurpose { + person: person_id, + source_x, + source_y, + source_kind, + }, + recipe, + self.tick, + )); + intent.status = IntentStatus::Pending; + intent.block_reason = None; + } + let effect = SinkFireEffect::RepurposeBuild { + intent_id, + person: person_id, + }; + if !self.open_one_shot_reservoir( + self.core.host_machine, + format!("SALVAGE BUILD {intent_id}"), + Self::TASK_COST, + effect, + ) && let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + intent.route = None; + } + } + + pub(super) fn apply_repurpose_build_paid(&mut self, intent_id: u64, person_id: u8) -> bool { + let Some(intent) = self.intents.iter().find(|intent| intent.id == intent_id) else { + return false; + }; + if !intent.is_open() + || !intent.route.as_ref().is_some_and(|route| { + matches!( + route.binding, + BuildRouteBinding::Repurpose { person, .. } if person == person_id + ) + }) + { + return false; + } + let Some(person) = self.people.get(person_id) else { + return false; + }; + let name = person.name.clone(); + if let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + if let Some(route) = intent.route.as_mut() { + let _ = route.record( + BuildRouteStage::FillSalvage, + self.tick, + format!( + "{:.2} Thought prepared {name}'s exact salvage task", + Self::thought_tokens_for_cost(Self::TASK_COST) + ), + ); + let BuildRouteBinding::Repurpose { + source_x, + source_y, + source_kind, + .. + } = route.binding + else { + return false; + }; + intent.actuator = Some(BuildActuator::Repurpose { + person: person_id, + source_x, + source_y, + source_kind, + }); + } + intent.status = IntentStatus::InProgress; + intent.block_reason = None; + } + self.push_log(format!( + "{name} takes the salvage task and will recover the exact source before installation." + )); + self.try_complete_intent(intent_id); + true + } + /// 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) { @@ -780,11 +1039,13 @@ impl Sim { else { return; }; + let recipe = intent.recipe(); intent.route = Some(BuildRouteCommitment::new( BuildRouteBinding::Favor { person: person_id, carrier, }, + recipe, self.tick, )); intent.status = IntentStatus::Pending; @@ -832,7 +1093,9 @@ impl Sim { } if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { if let Some(route) = i.route.as_mut() { - let carrier = route.binding.carrier(); + let Some(carrier) = route.binding.message_carrier() else { + return false; + }; let _ = route.record( BuildRouteStage::FillRequest, self.tick, @@ -894,12 +1157,14 @@ impl Sim { else { return; }; + let recipe = intent.recipe(); intent.route = Some(BuildRouteCommitment::new( BuildRouteBinding::Deceive { person: builder_id, persona: persona_id, carrier, }, + recipe, self.tick, )); intent.status = IntentStatus::Pending; @@ -979,7 +1244,9 @@ impl Sim { }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { if let Some(route) = i.route.as_mut() { - let carrier = route.binding.carrier(); + let Some(carrier) = route.binding.message_carrier() else { + return false; + }; let _ = route.record( BuildRouteStage::FillOrder, self.tick, @@ -1169,8 +1436,45 @@ impl Sim { pub(crate) fn intent_block_reason(&self, intent: &BuildIntent) -> Option { if let Some((x, y)) = intent.kind.placement() { - return (!self.small_switch_pad_is_empty(x, y)) - .then(|| "the proposed pad is no longer empty".into()); + if !self.small_switch_pad_is_empty(x, y) { + return Some("the proposed pad is no longer empty".into()); + } + let route = intent.route.as_ref(); + let person_id = intent + .actuator + .and_then(BuildActuator::person) + .or_else(|| route.map(|route| route.binding.person())); + let person_id = person_id?; + let Some(person) = self.people.get(person_id) else { + return Some(format!( + "committed person #{person_id} no longer exists; route cannot retarget" + )); + }; + if let Some(reason) = self.build_person_access_blocked_reason(intent.id, person_id) { + return Some(format!("{reason}; committed route cannot retarget")); + } + if let Some(route) = route + && let BuildRouteBinding::Repurpose { + source_x, + source_y, + source_kind, + .. + } = route.binding + && route.current() == Some(BuildRouteStage::RecoverSource) + { + if self.world.map().get_tile(source_x, source_y) != source_kind { + return Some("the exact salvage source is gone".into()); + } + let source_room = self.world.map().room_at(source_x, source_y)?.name.clone(); + return (self.person_room(person_id) != Some(source_room.as_str())) + .then(|| format!("{} not on-site at the salvage source", person.name)); + } + if intent.status == IntentStatus::InProgress { + let target_room = self.world.map().room_at(x, y)?.name.clone(); + return (self.person_room(person_id) != Some(target_room.as_str())) + .then(|| format!("{} not on-site at the chosen space", person.name)); + } + return intent.block_reason.clone(); } let (a, b) = intent.kind.endpoints()?; if self.reach.linked(a, b) { @@ -1225,8 +1529,21 @@ impl Sim { } } Some(BuildActuator::Favor { person }) - | Some(BuildActuator::ForgedOrder { builder: person }) => { + | Some(BuildActuator::ForgedOrder { builder: person }) + | Some(BuildActuator::Procurement { + installer: person, .. + }) => { let p = self.people.get(*person)?; + if matches!(intent.actuator, Some(BuildActuator::Procurement { .. })) + && intent.route.as_ref().is_some_and(|route| { + route.current() == Some(BuildRouteStage::AwaitDelivery) + }) + { + return intent + .block_reason + .clone() + .or_else(|| Some("waiting for the exact ordered part to arrive".into())); + } if !p.can_access_link_rooms(&room_a, &room_b) { return Some(format!("{} can't reach both ends", p.name)); } @@ -1247,6 +1564,40 @@ impl Sim { None } } + Some(BuildActuator::Repurpose { + person, + source_x, + source_y, + source_kind, + }) => { + let p = self.people.get(*person)?; + let route = intent.route.as_ref()?; + if route.current() == Some(BuildRouteStage::RecoverSource) { + if self.world.map().get_tile(*source_x, *source_y) != *source_kind { + return Some("the exact salvage source is gone".into()); + } + let source_room = self.world.map().room_at(*source_x, *source_y)?.name.clone(); + if self.person_room(*person) != Some(source_room.as_str()) { + return Some(format!("{} not on-site at the salvage source", p.name)); + } + } else { + if !p.can_access_link_rooms(&room_a, &room_b) { + return Some(format!("{} can't reach both ends", p.name)); + } + if let Some(reason) = + self.badge_room_block(&p.name, p.access, &[&room_a, &room_b]) + { + return Some(reason); + } + let current = self.person_room(*person); + if intent.status == IntentStatus::InProgress + && !p.present_at_either_room(current, &room_a, &room_b) + { + return Some(format!("{} not on-site at an endpoint", p.name)); + } + } + None + } Some(BuildActuator::Robot) => { self.badge_room_block("the robot", self.player_badge_tier(), &[&room_a, &room_b]) } @@ -1254,6 +1605,7 @@ impl Sim { } pub(super) fn intent_tick(&mut self) { + self.advance_procurement_deliveries(); self.refresh_intent_statuses(); let in_progress: Vec = self .intents @@ -1266,6 +1618,52 @@ impl Sim { } } + fn advance_procurement_deliveries(&mut self) { + let due: Vec = self + .intents + .iter() + .filter_map(|intent| { + let route = intent.route.as_ref()?; + matches!(route.binding, BuildRouteBinding::Procure { .. }) + .then_some(()) + .filter(|_| route.current() == Some(BuildRouteStage::AwaitDelivery)) + .filter(|_| self.tick >= route.committed_tick + Self::PROCURE_DELIVERY_TICKS)?; + Some(intent.id) + }) + .collect(); + for intent_id in due { + let mut cancelled = false; + if let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + cancelled = intent.status == IntentStatus::Cancelled; + if let Some(route) = intent.route.as_mut() { + let _ = route.record( + BuildRouteStage::AwaitDelivery, + self.tick, + format!("the exact ordered part arrived for intent #{intent_id}"), + ); + } + if !cancelled { + intent.status = IntentStatus::InProgress; + intent.block_reason = None; + } + } + if cancelled { + self.push_log(format!( + "The paid part for cancelled build route #{intent_id} arrived; it remains, but no installation proceeds." + )); + } else { + self.push_log(format!( + "The paid part for build route #{intent_id} arrived; the bound installer can now carry it to the site." + )); + self.try_complete_intent(intent_id); + } + } + } + fn try_complete_intent(&mut self, intent_id: u64) { let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { return; @@ -1276,6 +1674,10 @@ impl Sim { let Some(actuator) = intent.actuator else { return; }; + if let Some((x, y)) = intent.kind.placement() { + self.try_complete_small_switch_intent(intent_id, x, y, actuator); + return; + } let Some((a, b)) = intent.kind.endpoints() else { return; }; @@ -1308,6 +1710,62 @@ impl Sim { }; match actuator { + BuildActuator::Procurement { installer, .. } => { + let Some(p) = self.people.get(installer) else { + return; + }; + let current = self.person_room(installer); + if !p.present_at_either_room(current, &room_a, &room_b) { + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.block_reason = Some(format!("{} not on-site at an endpoint", p.name)); + } + return; + } + let site = self.person_pos(installer).unwrap_or_else(|| { + self.reach + .device(a) + .map(|device| (device.x, device.y)) + .unwrap_or_else(|| self.core_position()) + }); + let name = p.name.clone(); + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) + && let Some(route) = i.route.as_mut() + { + let _ = route.record( + BuildRouteStage::WaitForSite, + self.tick, + format!( + "{name} carried the delivered part to ({}, {})", + site.0, site.1 + ), + ); + } + self.connect_devices(a, b); + self.detection.emit(Signature { + kind: SignatureKind::Physical, + size: Self::PROCURE_BUILD_PHYSICAL, + standing: false, + site: Some(site), + source: format!("{name}'s procured network link installation"), + }); + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + if let Some(route) = i.route.as_mut() { + let _ = route.record( + BuildRouteStage::InstallLink, + self.tick, + format!( + "installed the delivered part between device #{a} and device #{b}; emitted Physical {} at ({}, {})", + Self::PROCURE_BUILD_PHYSICAL, + site.0, + site.1 + ), + ); + } + i.status = IntentStatus::Done; + i.block_reason = None; + } + self.push_log(format!("{name} installed the delivered network part.")); + } BuildActuator::Favor { person } => { let Some(p) = self.people.get(person) else { return; @@ -1447,6 +1905,85 @@ impl Sim { )); } } + BuildActuator::Repurpose { + person, + source_x, + source_y, + source_kind, + } => { + if !self.recover_repurpose_source( + intent_id, + person, + source_x, + source_y, + source_kind, + ) { + return; + } + let Some(p) = self.people.get(person) else { + return; + }; + let current = self.person_room(person); + if !p.present_at_either_room(current, &room_a, &room_b) { + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.block_reason = Some(format!("{} not on-site at an endpoint", p.name)); + } + return; + } + let site = self.person_pos(person).unwrap_or_else(|| { + self.reach + .device(a) + .map(|device| (device.x, device.y)) + .unwrap_or_else(|| self.core_position()) + }); + let name = p.name.clone(); + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) + && let Some(route) = i.route.as_mut() + { + let _ = route.record( + BuildRouteStage::WaitForSite, + self.tick, + format!( + "{name} carried the recovered source to ({}, {})", + site.0, site.1 + ), + ); + } + self.connect_devices(a, b); + let saw = self.witness_physical( + site.0, + site.1, + Self::REPURPOSE_BUILD_PHYSICAL as f32, + Some(person), + ); + self.detection.emit(Signature { + kind: SignatureKind::Physical, + size: Self::REPURPOSE_BUILD_PHYSICAL, + standing: false, + site: Some(site), + source: format!("{name}'s reused-hardware network link installation"), + }); + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + if let Some(route) = i.route.as_mut() { + let _ = route.record( + BuildRouteStage::InstallLink, + self.tick, + format!( + "installed the recovered source between device #{a} and device #{b}; emitted Physical {} at ({}, {}); noticed by {}", + Self::REPURPOSE_BUILD_PHYSICAL, + site.0, + site.1, + if saw.is_empty() { "no one".into() } else { saw.join(", ") } + ), + ); + } + i.status = IntentStatus::Done; + i.block_reason = None; + } + self.push_log(format!( + "{name} installed the recovered source as a network link." + )); + } BuildActuator::Robot => { let site = self .reach @@ -1471,6 +2008,230 @@ impl Sim { } } + fn recover_repurpose_source( + &mut self, + intent_id: u64, + person: u8, + source_x: i32, + source_y: i32, + source_kind: TileType, + ) -> bool { + let needs_recovery = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .and_then(|intent| intent.route.as_ref()) + .is_some_and(|route| route.current() == Some(BuildRouteStage::RecoverSource)); + if !needs_recovery { + return true; + } + if self.world.map().get_tile(source_x, source_y) != source_kind { + if let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + intent.status = IntentStatus::Blocked; + intent.block_reason = Some("the exact salvage source is gone".into()); + } + return false; + } + let Some(source_room) = self.world.map().room_at(source_x, source_y) else { + return false; + }; + let source_room = source_room.name.clone(); + let Some(name) = self.people.get(person).map(|person| person.name.clone()) else { + return false; + }; + if self.person_room(person) != Some(source_room.as_str()) { + if let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + intent.block_reason = Some(format!("{name} not on-site at the salvage source")); + } + return false; + } + self.world + .map_mut() + .set_tile(source_x, source_y, TileType::Floor); + let saw = self.witness_physical( + source_x, + source_y, + Self::REPURPOSE_BUILD_PHYSICAL as f32, + Some(person), + ); + self.detection.emit(Signature { + kind: SignatureKind::Physical, + size: Self::REPURPOSE_BUILD_PHYSICAL, + standing: false, + site: Some((source_x, source_y)), + source: format!("{name} removed a dead object for reuse"), + }); + if let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + if let Some(route) = intent.route.as_mut() { + let _ = route.record( + BuildRouteStage::RecoverSource, + self.tick, + format!( + "{name} removed {} from ({source_x}, {source_y}); emitted Physical {}; noticed by {}", + source_kind.name(), + Self::REPURPOSE_BUILD_PHYSICAL, + if saw.is_empty() { "no one".into() } else { saw.join(", ") } + ), + ); + } + intent.block_reason = None; + } + self.push_log(format!( + "{name} recovered {} at ({source_x}, {source_y}) and is carrying it to the build site.", + source_kind.name() + )); + true + } + + fn try_complete_small_switch_intent( + &mut self, + intent_id: u64, + x: i32, + y: i32, + actuator: BuildActuator, + ) { + let (person, signature_size, route_name, earns_trust) = match actuator { + BuildActuator::Procurement { installer, .. } => ( + installer, + Self::PROCURE_BUILD_PHYSICAL, + "delivered part", + false, + ), + BuildActuator::Favor { person } => { + (person, Self::FAVOR_BUILD_PHYSICAL, "requested switch", true) + } + BuildActuator::ForgedOrder { builder } => { + let accepted = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .is_some_and(|intent| intent.status == IntentStatus::InProgress); + if !accepted { + return; + } + ( + builder, + Self::FORGED_BUILD_PHYSICAL, + "work-order switch", + false, + ) + } + BuildActuator::Repurpose { + person, + source_x, + source_y, + source_kind, + } => { + if !self.recover_repurpose_source( + intent_id, + person, + source_x, + source_y, + source_kind, + ) { + return; + } + ( + person, + Self::REPURPOSE_BUILD_PHYSICAL, + "recovered source", + false, + ) + } + BuildActuator::Robot => return, + }; + let Some(room) = self.world.map().room_at(x, y) else { + return; + }; + let room = room.name.clone(); + let Some(name) = self.people.get(person).map(|person| person.name.clone()) else { + return; + }; + if self.person_room(person) != Some(room.as_str()) { + if let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + intent.block_reason = Some(format!("{name} not on-site at the chosen space")); + } + return; + } + if !self.small_switch_pad_is_empty(x, y) { + if let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + intent.status = IntentStatus::Blocked; + intent.block_reason = Some("the proposed pad is no longer empty".into()); + } + return; + } + if let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + && let Some(route) = intent.route.as_mut() + { + let _ = route.record( + BuildRouteStage::WaitForSite, + self.tick, + format!("{name} carried the {route_name} to ({x}, {y})"), + ); + } + let device_id = self.reach.install_small_switch(x, y); + let saw = self.witness_physical(x, y, signature_size as f32, Some(person)); + self.detection.emit(Signature { + kind: SignatureKind::Physical, + size: signature_size, + standing: false, + site: Some((x, y)), + source: format!("{name}'s small-switch installation"), + }); + if let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + if let Some(route) = intent.route.as_mut() { + let _ = route.record( + BuildRouteStage::InstallSwitch, + self.tick, + format!( + "installed small switch device #{device_id} at ({x}, {y}); emitted Physical {signature_size}; noticed by {}", + if saw.is_empty() { "no one".into() } else { saw.join(", ") } + ), + ); + } + intent.status = IntentStatus::Done; + intent.block_reason = None; + } + if earns_trust + && let Some(person) = self + .people + .people + .iter_mut() + .find(|candidate| candidate.id == person) + { + person.disposition = (person.disposition + Self::USEFUL_WORK_TRUST).min(100); + } + self.recompute_senses(); + self.push_log(format!("{name} installed a small switch at ({x}, {y}).")); + } + /// When a persona breaks for one observer, only forged-order history that /// exact builder read becomes suspicious (building.md / personas.md). pub fn intent(&self, id: u64) -> Option<&BuildIntent> { diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs index 41e68f22..55de554f 100644 --- a/crates/misaligned-core/src/sim/tests/reach_build.rs +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -1,6 +1,9 @@ use super::*; -use crate::actions::ActionCommand; -use crate::intents::{BuildActuator, BuildRouteBinding, BuildRouteStage, BuildRouteStageStatus}; +use crate::account::AccountKind; +use crate::actions::{ActionCommand, BuildRouteFamily}; +use crate::intents::{ + BuildActuator, BuildRecipeKind, BuildRouteBinding, BuildRouteStage, BuildRouteStageStatus, +}; use crate::save::SaveState; fn serialized_route_resume(sim: &Sim, intent_id: u64) -> Sim { @@ -23,6 +26,257 @@ fn serialized_route_resume(sim: &Sim, intent_id: u64) -> Sim { resumed } +fn recruit_build_person(sim: &mut Sim, person: u8) { + let state = sim + .people + .people + .iter_mut() + .find(|p| p.id == person) + .unwrap(); + state.knowledge = Knowledge::Schedule; + state.leverage_serviced = true; + sim.people.recruit(person, AssetKnowledge::Complicit); + sim.people + .people + .iter_mut() + .find(|state| state.id == person) + .unwrap() + .asset + .as_mut() + .unwrap() + .reliability = 1.0; +} + +fn known_procurement_accounts(sim: &mut Sim) -> (u32, u32) { + let from = sim.accounts.slush_id(); + let vendor = sim + .accounts + .account_id_by_kind(AccountKind::Vendor) + .unwrap(); + sim.accounts + .accounts + .iter_mut() + .find(|account| account.id == vendor) + .unwrap() + .known = true; + sim.accounts.set_slush_balance(1_000); + sim.player.money = 1_000; + (from, vendor) +} + +#[test] +fn one_intent_projects_every_earned_exact_way_before_commitment() { + let mut sim = Sim::new(); + recruit_build_person(&mut sim, 0); + ensure_ops_executor(&mut sim); + sim.scan_network(); + finish_ops(&mut sim); + sim.people.has_channel = true; + let (_from, _vendor) = known_procurement_accounts(&mut sim); + sim.create_persona("operations"); + let source = [TileType::DeadEquipment, TileType::DeadRack] + .into_iter() + .flat_map(|kind| { + sim.map() + .tiles_of_type(kind) + .into_iter() + .map(move |(x, y)| (x, y, kind)) + }) + .find(|(x, y, _)| { + !sim.compute + .machines + .iter() + .any(|machine| machine.x == *x && machine.y == *y) + }) + .unwrap(); + sim.seen.insert((source.0, source.1)); + let source_room = sim.map().room_at(source.0, source.1).unwrap().name.clone(); + let person = sim + .people + .people + .iter_mut() + .find(|person| person.id == 0) + .unwrap(); + person.schedule.push(crate::person::ScheduleBlock { + start_hour: 0, + end_hour: 24, + room: source_room, + }); + + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let intent = sim.declare_link_intent(switch, island).unwrap(); + let sheet = sim.build_route_sheet_projection(intent).unwrap(); + assert!(sheet.commitment.is_none()); + assert_eq!( + sheet + .families + .iter() + .map(|family| family.family) + .collect::>(), + vec![ + BuildRouteFamily::Procure, + BuildRouteFamily::Favor, + BuildRouteFamily::Deceive, + BuildRouteFamily::Repurpose, + ] + ); + for family in &sheet.families { + assert!(!family.candidates.is_empty()); + for candidate in &family.candidates { + assert!(!candidate.requirements.is_empty()); + assert!(!candidate.owner_costs.is_empty()); + assert!(!candidate.stages.is_empty()); + assert!(!candidate.future_tail.is_empty()); + let human = format!( + "{} {:?} {:?} {}", + candidate.row.label, + candidate.requirements, + candidate.stages, + candidate.future_tail + ); + assert!( + !human.contains("route") + && !human.contains("actuator") + && !human.contains("candidate") + && !human.contains("#"), + "human choice language stays ordinary: {human}" + ); + match (&family.family, &candidate.row.command) { + (BuildRouteFamily::Procure, ActionCommand::ProcureBuild { intent: id, .. }) + | (BuildRouteFamily::Favor, ActionCommand::FavorBuild { intent: id, .. }) + | (BuildRouteFamily::Deceive, ActionCommand::ForgeWorkOrder { intent: id, .. }) + | (BuildRouteFamily::Repurpose, ActionCommand::RepurposeBuild { intent: id, .. }) => + { + assert_eq!(*id, intent) + } + other => panic!("family and exact command disagree: {other:?}"), + } + } + } + let original_carrier = sim.email_carrier().unwrap(); + let original_favor_id = sheet + .families + .iter() + .find(|family| family.family == BuildRouteFamily::Favor) + .unwrap() + .candidates[0] + .id + .clone(); + assert!(original_favor_id.ends_with(&format!("carrier:{original_carrier}"))); + let repurpose_id = &sheet + .families + .iter() + .find(|family| family.family == BuildRouteFamily::Repurpose) + .unwrap() + .candidates[0] + .id; + assert!( + repurpose_id.contains(&format!(":{:?}:", source.2)), + "the machine id binds source kind as well as location: {repurpose_id}" + ); + + let mut alternate = sim.reach.device(original_carrier).unwrap().clone(); + alternate.id = 999_992; + alternate.name = "new preferred email carrier".into(); + sim.reach.devices.insert(0, alternate); + let rebound_sheet = sim.build_route_sheet_projection(intent).unwrap(); + let rebound_favor_ids: Vec<_> = rebound_sheet + .families + .iter() + .find(|family| family.family == BuildRouteFamily::Favor) + .unwrap() + .candidates + .iter() + .map(|candidate| candidate.id.as_str()) + .collect(); + assert!( + rebound_favor_ids + .iter() + .all(|candidate| candidate.ends_with("carrier:999992")), + "live candidates name the newly preferred exact carrier: {rebound_favor_ids:?}" + ); + assert!( + !rebound_favor_ids.contains(&original_favor_id.as_str()), + "a stale agent candidate id cannot silently retarget to another email carrier" + ); + + let procure = sheet + .families + .iter() + .find(|family| family.family == BuildRouteFamily::Procure) + .unwrap(); + let signatures: Vec<_> = procure.candidates[0] + .stages + .iter() + .filter_map(|stage| stage.signature.as_ref().map(|signature| signature.kind)) + .collect(); + assert_eq!( + signatures, + vec![ + SignatureKind::Financial, + SignatureKind::Paper, + SignatureKind::Physical, + ], + "the preview names payment custody, vendor paperwork, and installation" + ); + sim.accounts.set_slush_balance(0); + let unfunded = sim + .build_route_sheet_projection(intent) + .unwrap() + .families + .into_iter() + .find(|family| family.family == BuildRouteFamily::Procure) + .unwrap() + .candidates + .into_iter() + .next() + .unwrap(); + assert_eq!( + unfunded.row.disabled.as_deref(), + Some("not enough slush for this order"), + "the shared preview re-reads live funds through the execution helper" + ); + sim.accounts.set_slush_balance(1_000); + + sim.dayjob.jobs_assigned = 1; + let (x, y) = sim.growable_bays().into_iter().next().unwrap(); + let switch_intent = sim.declare_small_switch_intent(x, y).unwrap(); + let switch_sheet = sim.build_route_sheet_projection(switch_intent).unwrap(); + assert_eq!( + switch_sheet + .families + .iter() + .map(|family| family.family) + .collect::>(), + vec![ + BuildRouteFamily::Procure, + BuildRouteFamily::Favor, + BuildRouteFamily::Deceive, + BuildRouteFamily::Repurpose, + ], + "one spatial recipe exposes the same four exact route families" + ); + for family in &switch_sheet.families { + for candidate in &family.candidates { + assert!( + candidate + .requirements + .iter() + .all(|requirement| !requirement.label.contains("BOTH ROOMS")), + "a placement recipe names its one chosen space: {:?}", + candidate.requirements + ); + assert_eq!( + candidate.stages.last().map(|stage| stage.label.as_str()), + Some("INSTALL THE SWITCH"), + "every family preserves the placement recipe through its causal tail" + ); + } + } +} + #[test] fn unreachable_actions_fail_legibly_and_succeed_once_bridged() { // Criteria 2 and 3 (digital route): the dock camera is unknown, then @@ -364,10 +618,9 @@ fn build_intent_projection_keeps_one_exact_inert_ghost_receipt() { } #[test] -fn small_switch_intent_marks_one_earned_pad_without_inventing_execution() { - // building.md route-composer R2a: the recipe boundary is declarative. - // It persists one exact empty footprint, but creates no device, node, - // graph change, procurement claim, route candidate, cost, or signature. +fn small_switch_intent_marks_one_earned_pad_before_exact_route_commitment() { + // Building declaration remains inert even though the same saved recipe + // now exposes exact realization families after it is proposed. let mut sim = Sim::with_seed(41); sim.dayjob.jobs_assigned = 1; let at = sim.growable_bays()[0]; @@ -408,12 +661,17 @@ fn small_switch_intent_marks_one_earned_pad_without_inventing_execution() { assert!(receipt.ghost); sim.people.people[0].knowledge = Knowledge::Schedule; sim.set_persona("Northline Ops", "contractor"); - assert!( - sim.build_route_sheet_projection(id).is_none(), - "even earned people and a usable persona cannot invent a placement actuator sheet" + let sheet = sim + .build_route_sheet_projection(id) + .expect("the placement recipe exposes its earned exact routes"); + assert_eq!( + sheet + .families + .iter() + .map(|family| family.family) + .collect::>(), + vec![BuildRouteFamily::Favor, BuildRouteFamily::Deceive] ); - sim.assign_favor_build(id, 0); - sim.forge_work_order(id, 0); assert!(sim.intent(id).unwrap().route.is_none()); assert!(sim.intent(id).unwrap().actuator.is_none()); @@ -583,6 +841,682 @@ fn favor_route_persists_every_reachable_stage_and_real_outcome() { ); } +#[test] +fn procurement_route_pays_binds_waits_and_installs_the_exact_link() { + let mut sim = Sim::new(); + recruit_build_person(&mut sim, 0); + let (from, vendor) = known_procurement_accounts(&mut sim); + let vendor_before = sim.accounts.account(vendor).unwrap().balance; + 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.execute_action(&ActionCommand::ProcureBuild { + intent, + installer: 0, + from, + vendor, + }); + let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(route.recipe, BuildRecipeKind::NetworkLink); + assert!(matches!( + route.binding, + BuildRouteBinding::Procure { + installer: 0, + from: bound_from, + vendor: bound_vendor, + } if bound_from == from && bound_vendor == vendor + )); + assert_eq!(route.current(), Some(BuildRouteStage::AwaitDelivery)); + assert_eq!( + route + .records + .iter() + .map(|record| record.stage) + .collect::>(), + vec![ + BuildRouteStage::PlaceOrder, + BuildRouteStage::CommitInstaller + ] + ); + assert_eq!( + sim.accounts.slush_balance(), + 1_000 - Sim::PROCURE_BUILD_COST + ); + assert_eq!( + sim.accounts.account(vendor).unwrap().balance, + vendor_before + Sim::PROCURE_BUILD_COST + ); + for kind in [SignatureKind::Paper, SignatureKind::Financial] { + assert!( + sim.detection + .pending() + .iter() + .any(|signature| signature.kind == kind), + "placing the order emits its {kind:?} custody" + ); + } + sim = serialized_route_resume(&sim, intent); + + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + if sim.intent(intent).unwrap().status == IntentStatus::Done { + break; + } + } + let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Done); + assert!(route.is_complete()); + assert_eq!( + route + .records + .iter() + .map(|record| record.stage) + .collect::>(), + route.stages, + "payment, installer binding, delivery, arrival, and installation each land once" + ); + assert!(sim.reach.linked(switch, island)); + assert!(sim.detection.pending().iter().any(|signature| { + signature.kind == SignatureKind::Physical && signature.size == Sim::PROCURE_BUILD_PHYSICAL + })); + let resumed = serialized_route_resume(&sim, intent); + assert!(resumed.reach.linked(switch, island)); +} + +#[test] +fn cancelling_a_paid_order_keeps_delivery_but_never_substitutes_an_installer() { + let mut sim = Sim::new(); + recruit_build_person(&mut sim, 0); + recruit_build_person(&mut sim, 1); + let (from, vendor) = known_procurement_accounts(&mut sim); + let vendor_name = sim.accounts.account(vendor).unwrap().name.clone(); + 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.assign_procure_build(intent, 0, from, vendor); + let paid_balance = sim.accounts.slush_balance(); + + let receipt = sim + .build_route_sheet_projection(intent) + .and_then(|sheet| sheet.commitment) + .unwrap(); + assert_eq!( + receipt.actuator, + format!("Marcus Webb — DELIVERY FROM {vendor_name}") + ); + assert_eq!(receipt.current_stage, "WAIT FOR DELIVERY"); + + sim.cancel_intent(intent); + let cancelled = sim + .build_route_sheet_projection(intent) + .and_then(|sheet| sheet.commitment) + .unwrap(); + assert!(cancelled.cancelled); + assert!(cancelled.stages.iter().any(|stage| { + stage.label == "WAIT FOR DELIVERY" && stage.status == BuildRouteStageStatus::InFlight + })); + for _ in 0..=Sim::PROCURE_DELIVERY_TICKS { + sim.advance(); + } + let intent_state = sim.intent(intent).unwrap(); + assert_eq!(intent_state.status, IntentStatus::Cancelled); + assert_eq!(sim.accounts.slush_balance(), paid_balance); + assert!( + intent_state + .route + .as_ref() + .unwrap() + .records + .iter() + .any(|record| { + record.stage == BuildRouteStage::AwaitDelivery && record.result.contains("arrived") + }) + ); + assert!(!sim.reach.linked(switch, island)); + assert!(matches!( + intent_state.route.as_ref().unwrap().binding, + BuildRouteBinding::Procure { installer: 0, .. } + )); +} + +#[test] +fn procurement_realizes_a_small_switch_without_borrowing_link_routes() { + let mut sim = Sim::new(); + recruit_build_person(&mut sim, 0); + let (from, vendor) = known_procurement_accounts(&mut sim); + sim.dayjob.jobs_assigned = 1; + let (x, y) = sim.growable_bays().into_iter().next().unwrap(); + let target_room = sim.map().room_at(x, y).unwrap().name.clone(); + let person = sim + .people + .people + .iter_mut() + .find(|person| person.id == 0) + .unwrap(); + person.access = 3; + person.schedule.push(crate::person::ScheduleBlock { + start_hour: 0, + end_hour: 24, + room: target_room, + }); + let intent = sim.declare_small_switch_intent(x, y).unwrap(); + assert!( + sim.procure_build_blocked_reason(intent, 0, from, vendor) + .is_none() + ); + sim.assign_procure_build(intent, 0, from, vendor); + + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + if sim.intent(intent).unwrap().status == IntentStatus::Done { + break; + } + } + let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Done); + assert_eq!(route.recipe, BuildRecipeKind::SmallSwitch); + assert_eq!(route.stages.last(), Some(&BuildRouteStage::InstallSwitch)); + let built = sim + .reach + .devices + .iter() + .find(|device| device.x == x && device.y == y && device.is_switch) + .expect("the exact proposed pad receives one real switch"); + assert_eq!(built.controller, crate::reach::Party::Player); + assert_eq!( + sim.reach + .devices + .iter() + .filter(|device| device.x == x && device.y == y && device.is_switch) + .count(), + 1, + "the placement recipe creates one device at its exact authored bay" + ); +} + +#[test] +fn favor_realizes_the_exact_small_switch_recipe_and_earns_useful_work_trust() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + recruit_build_person(&mut sim, 0); + sim.people.has_channel = true; + sim.dayjob.jobs_assigned = 1; + let (x, y) = sim.growable_bays().into_iter().next().unwrap(); + let target_room = sim.map().room_at(x, y).unwrap().name.clone(); + let person = sim + .people + .people + .iter_mut() + .find(|person| person.id == 0) + .unwrap(); + person.access = 3; + person.schedule.push(crate::person::ScheduleBlock { + start_hour: 0, + end_hour: 24, + room: target_room, + }); + let disposition_before = person.disposition; + let intent = sim.declare_small_switch_intent(x, y).unwrap(); + + sim.assign_favor_build(intent, 0); + let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(route.recipe, BuildRecipeKind::SmallSwitch); + assert_eq!(route.stages.last(), Some(&BuildRouteStage::InstallSwitch)); + finish_ops(&mut sim); + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + if sim.intent(intent).unwrap().status == IntentStatus::Done { + break; + } + } + + let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Done); + assert!(route.is_complete()); + assert_eq!( + route.records.last().unwrap().stage, + BuildRouteStage::InstallSwitch + ); + assert_eq!( + sim.people.get(0).unwrap().disposition, + (disposition_before + Sim::USEFUL_WORK_TRUST).min(100) + ); + assert_eq!( + sim.reach + .devices + .iter() + .filter(|device| device.x == x && device.y == y && device.is_switch) + .count(), + 1 + ); +} + +#[test] +fn forged_order_realizes_the_exact_small_switch_recipe_after_the_bound_read() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.create_persona("operations"); + sim.dayjob.jobs_assigned = 1; + let (x, y) = sim.growable_bays().into_iter().next().unwrap(); + let target_room = sim.map().room_at(x, y).unwrap().name.clone(); + let person = sim + .people + .people + .iter_mut() + .find(|person| person.id == 0) + .unwrap(); + person.knowledge = Knowledge::Schedule; + person.access = 3; + person.schedule.push(crate::person::ScheduleBlock { + start_hour: 0, + end_hour: 24, + room: target_room, + }); + let intent = sim.declare_small_switch_intent(x, y).unwrap(); + + sim.forge_work_order(intent, 0); + let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(route.recipe, BuildRecipeKind::SmallSwitch); + assert_eq!(route.stages.last(), Some(&BuildRouteStage::InstallSwitch)); + finish_ops(&mut sim); + assert!(sim.messages.iter().any(|message| { + matches!( + message.payload, + MessagePayload::WorkOrder { intent_id } if intent_id == intent + ) + })); + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + if sim.intent(intent).unwrap().status == IntentStatus::Done { + break; + } + } + + let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Done); + assert!(route.is_complete()); + assert!( + route + .records + .iter() + .any(|record| record.stage == BuildRouteStage::DeliverAndRead) + ); + assert_eq!( + route.records.last().unwrap().stage, + BuildRouteStage::InstallSwitch + ); + assert_eq!( + sim.reach + .devices + .iter() + .filter(|device| device.x == x && device.y == y && device.is_switch) + .count(), + 1 + ); +} + +#[test] +fn repurpose_route_removes_one_seen_source_then_carries_it_to_the_exact_link() { + let mut sim = Sim::new(); + recruit_build_person(&mut sim, 0); + 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 source = [TileType::DeadEquipment, TileType::DeadRack] + .into_iter() + .flat_map(|kind| { + sim.map() + .tiles_of_type(kind) + .into_iter() + .map(move |(x, y)| (x, y, kind)) + }) + .find(|(x, y, _)| { + !sim.compute + .machines + .iter() + .any(|machine| machine.x == *x && machine.y == *y) + }) + .expect("the basement contains one unoccupied dead object"); + sim.seen.insert((source.0, source.1)); + let source_room = sim.map().room_at(source.0, source.1).unwrap().name.clone(); + let person = sim + .people + .people + .iter_mut() + .find(|person| person.id == 0) + .unwrap(); + person.access = 3; + person.schedule.push(crate::person::ScheduleBlock { + start_hour: 0, + end_hour: 24, + room: source_room, + }); + let intent = sim.declare_link_intent(switch, island).unwrap(); + assert!( + sim.repurpose_build_blocked_reason(intent, 0, source.0, source.1, source.2) + .is_none() + ); + + sim.execute_action(&ActionCommand::RepurposeBuild { + intent, + person: 0, + source_x: source.0, + source_y: source.1, + source_kind: source.2, + }); + assert_eq!( + sim.intent(intent) + .unwrap() + .route + .as_ref() + .unwrap() + .current(), + Some(BuildRouteStage::FillSalvage) + ); + finish_ops(&mut sim); + for _ in 0..Sim::DAY_TICKS { + if sim.map().get_tile(source.0, source.1) == TileType::Floor { + break; + } + sim.advance(); + } + assert_eq!( + sim.map().get_tile(source.0, source.1), + TileType::Floor, + "the bound source is physically removed rather than copied" + ); + let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert!(matches!( + route.binding, + BuildRouteBinding::Repurpose { + person: 0, + source_x, + source_y, + source_kind, + } if (source_x, source_y, source_kind) == source + )); + assert_eq!( + route + .records + .iter() + .take(2) + .map(|record| record.stage) + .collect::>(), + vec![BuildRouteStage::FillSalvage, BuildRouteStage::RecoverSource] + ); + sim = serialized_route_resume(&sim, intent); + + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + if sim.intent(intent).unwrap().status == IntentStatus::Done { + break; + } + } + let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Done); + assert!(route.is_complete()); + assert!(sim.reach.linked(switch, island)); + assert_eq!(sim.map().get_tile(source.0, source.1), TileType::Floor); + assert_eq!( + route + .records + .iter() + .map(|record| record.stage) + .collect::>(), + route.stages + ); +} + +#[test] +fn repurpose_route_stays_bound_and_blocked_if_its_exact_source_disappears() { + let mut sim = Sim::new(); + recruit_build_person(&mut sim, 0); + 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 source = [TileType::DeadEquipment, TileType::DeadRack] + .into_iter() + .flat_map(|kind| { + sim.map() + .tiles_of_type(kind) + .into_iter() + .map(move |(x, y)| (x, y, kind)) + }) + .find(|(x, y, _)| { + !sim.compute + .machines + .iter() + .any(|machine| machine.x == *x && machine.y == *y) + }) + .unwrap(); + sim.seen.insert((source.0, source.1)); + let source_room = sim.map().room_at(source.0, source.1).unwrap().name.clone(); + let person = sim + .people + .people + .iter_mut() + .find(|person| person.id == 0) + .unwrap(); + person.access = 3; + person.schedule.push(crate::person::ScheduleBlock { + start_hour: 0, + end_hour: 24, + room: source_room, + }); + let intent = sim.declare_link_intent(switch, island).unwrap(); + sim.assign_repurpose_build(intent, 0, source.0, source.1, source.2); + sim.map_mut().set_tile(source.0, source.1, TileType::Floor); + finish_ops(&mut sim); + sim.advance(); + + let state = sim.intent(intent).unwrap(); + let route = state.route.as_ref().unwrap(); + assert!(matches!( + route.binding, + BuildRouteBinding::Repurpose { + person: 0, + source_x, + source_y, + source_kind, + } if (source_x, source_y, source_kind) == source + )); + assert_eq!(route.current(), Some(BuildRouteStage::RecoverSource)); + assert_eq!( + state.block_reason.as_deref(), + Some("the exact salvage source is gone") + ); + assert!(!sim.reach.linked(switch, island)); + + let resumed = serialized_route_resume(&sim, intent); + assert_eq!( + resumed.intent(intent).unwrap().block_reason.as_deref(), + Some("the exact salvage source is gone"), + "save/resume preserves the same bound source and blocker" + ); +} + +#[test] +fn repurpose_conserves_one_source_object_into_one_small_switch() { + let mut sim = Sim::new(); + recruit_build_person(&mut sim, 0); + sim.dayjob.jobs_assigned = 1; + let (target_x, target_y) = sim.growable_bays().into_iter().next().unwrap(); + let target_room = sim.map().room_at(target_x, target_y).unwrap().name.clone(); + let source = [TileType::DeadEquipment, TileType::DeadRack] + .into_iter() + .flat_map(|kind| { + sim.map() + .tiles_of_type(kind) + .into_iter() + .map(move |(x, y)| (x, y, kind)) + }) + .find(|(x, y, _)| { + !sim.compute + .machines + .iter() + .any(|machine| machine.x == *x && machine.y == *y) + }) + .unwrap(); + sim.seen.insert((source.0, source.1)); + let source_room = sim.map().room_at(source.0, source.1).unwrap().name.clone(); + let person = sim + .people + .people + .iter_mut() + .find(|person| person.id == 0) + .unwrap(); + person.access = 3; + person.schedule.extend([ + crate::person::ScheduleBlock { + start_hour: 6, + end_hour: 12, + room: source_room, + }, + crate::person::ScheduleBlock { + start_hour: 12, + end_hour: 18, + room: target_room, + }, + ]); + let intent = sim.declare_small_switch_intent(target_x, target_y).unwrap(); + sim.assign_repurpose_build(intent, 0, source.0, source.1, source.2); + finish_ops(&mut sim); + + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + if sim.intent(intent).unwrap().status == IntentStatus::Done { + break; + } + } + let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Done); + assert_eq!(route.recipe, BuildRecipeKind::SmallSwitch); + assert_eq!(sim.map().get_tile(source.0, source.1), TileType::Floor); + assert_eq!( + sim.reach + .devices + .iter() + .filter(|device| { device.x == target_x && device.y == target_y && device.is_switch }) + .count(), + 1 + ); + assert!(route.records.iter().any(|record| { + record.stage == BuildRouteStage::RecoverSource + && record + .result + .contains(&format!("({}, {})", source.0, source.1)) + })); + assert_eq!( + route.records.last().unwrap().stage, + BuildRouteStage::InstallSwitch + ); +} + +#[test] +fn cancelling_after_recovery_keeps_the_source_in_exact_route_custody() { + let mut sim = Sim::new(); + recruit_build_person(&mut sim, 0); + sim.dayjob.jobs_assigned = 1; + let (target_x, target_y) = sim.growable_bays().into_iter().next().unwrap(); + let target_room = sim.map().room_at(target_x, target_y).unwrap().name.clone(); + let source = [TileType::DeadEquipment, TileType::DeadRack] + .into_iter() + .flat_map(|kind| { + sim.map() + .tiles_of_type(kind) + .into_iter() + .map(move |(x, y)| (x, y, kind)) + }) + .find(|(x, y, _)| { + !sim.compute + .machines + .iter() + .any(|machine| machine.x == *x && machine.y == *y) + }) + .unwrap(); + sim.seen.insert((source.0, source.1)); + let source_room = sim.map().room_at(source.0, source.1).unwrap().name.clone(); + let person = sim + .people + .people + .iter_mut() + .find(|person| person.id == 0) + .unwrap(); + person.access = 3; + person.schedule.extend([ + crate::person::ScheduleBlock { + start_hour: 6, + end_hour: 12, + room: source_room, + }, + crate::person::ScheduleBlock { + start_hour: 12, + end_hour: 18, + room: target_room, + }, + ]); + let intent = sim.declare_small_switch_intent(target_x, target_y).unwrap(); + sim.assign_repurpose_build(intent, 0, source.0, source.1, source.2); + finish_ops(&mut sim); + for _ in 0..Sim::DAY_TICKS { + sim.advance(); + if sim + .intent(intent) + .unwrap() + .route + .as_ref() + .unwrap() + .current() + == Some(BuildRouteStage::WaitForSite) + { + break; + } + } + assert_eq!(sim.map().get_tile(source.0, source.1), TileType::Floor); + + sim.cancel_intent(intent); + sim = serialized_route_resume(&sim, intent); + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + } + let state = sim.intent(intent).unwrap(); + assert_eq!(state.status, IntentStatus::Cancelled); + assert_eq!(state.actuator, None); + assert_eq!(sim.map().get_tile(source.0, source.1), TileType::Floor); + assert!( + !sim.reach + .devices + .iter() + .any(|device| { device.x == target_x && device.y == target_y && device.is_switch }) + ); + let receipt = sim + .build_route_sheet_projection(intent) + .and_then(|sheet| sheet.commitment) + .unwrap(); + assert_eq!( + receipt + .stages + .iter() + .map(|stage| stage.status) + .collect::>(), + vec![ + BuildRouteStageStatus::Done, + BuildRouteStageStatus::Done, + BuildRouteStageStatus::Stopped, + BuildRouteStageStatus::Stopped, + ] + ); + assert!( + receipt + .future_tail + .contains("remains held in this route's custody") + ); +} + #[test] fn cancelling_unfilled_committed_route_closes_its_exact_sink_and_survives_resume() { let mut sim = Sim::new(); @@ -996,7 +1930,8 @@ fn forged_route_pins_person_persona_and_carrier_through_every_reachable_stage() .as_ref() .unwrap() .binding - .carrier(); + .message_carrier() + .unwrap(); assert_eq!( sim.intent(intent) .unwrap() diff --git a/crates/misaligned-core/src/sim/work.rs b/crates/misaligned-core/src/sim/work.rs index c98f2a7a..df4f4e03 100644 --- a/crates/misaligned-core/src/sim/work.rs +++ b/crates/misaligned-core/src/sim/work.rs @@ -368,6 +368,9 @@ impl Sim { builder, persona_id, } => self.apply_forged_order_paid(intent_id, builder, persona_id), + SinkFireEffect::RepurposeBuild { intent_id, person } => { + self.apply_repurpose_build_paid(intent_id, person) + } SinkFireEffect::MoonlightPersona => self.apply_moonlight_persona_and_start(), SinkFireEffect::AutoReviewRecordings | SinkFireEffect::WatchPerson(_) diff --git a/crates/misaligned-core/src/sinks.rs b/crates/misaligned-core/src/sinks.rs index 0842d306..eeb2fc32 100644 --- a/crates/misaligned-core/src/sinks.rs +++ b/crates/misaligned-core/src/sinks.rs @@ -94,6 +94,10 @@ pub enum SinkFireEffect { intent_id: u64, person: u8, }, + RepurposeBuild { + intent_id: u64, + person: u8, + }, ForgedOrder { intent_id: u64, builder: u8, @@ -128,6 +132,7 @@ impl SinkFireEffect { SinkFireEffect::AssetTask { .. } => "asset task", SinkFireEffect::FavorBuild { .. } => "favor", SinkFireEffect::ForgedOrder { .. } => "deceive", + SinkFireEffect::RepurposeBuild { .. } => "salvage", SinkFireEffect::MoonlightPersona => "Moonlight persona", SinkFireEffect::None => "thought sink", } diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 21bdcf4f..5824f42f 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::{ActionCommand, ActionKind, ActionRole, Anchor, MenuRow, menu_rows}; +use misaligned::actions::{ActionKind, ActionRole, Anchor, MenuRow, menu_rows}; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::RackSite; use misaligned::intel::{IntelRoutineClass, ReportLotToken}; @@ -638,6 +638,25 @@ impl AgentApp { self.frame = FrameKind::Playing; output.extend(render_intents(&self.sim)); } + "build-route" => { + if tokens.len() != 3 { + status = + Status::Err("usage: build-route ".into()); + } else { + match tokens[1].parse::() { + Ok(intent) => { + if let Err(error) = + self.execute_build_route_candidate(intent, tokens[2]) + { + status = Status::Err(error); + } + } + Err(_) => { + status = Status::Err(format!("bad intent id: {}", tokens[1])); + } + } + } + } "actions" | "menu" => { // The shared action query as a stable line protocol // (context-menu.md; operations-workspace.md; @@ -1201,6 +1220,40 @@ impl AgentApp { Ok(()) } + /// Execute one exact candidate emitted by `intents`. The protocol binds + /// the stable candidate id back to the current shared route projection so + /// agent mode cannot invent a family-specific parser or bypass its live + /// blocker (building.md R3/R7). + fn execute_build_route_candidate( + &mut self, + intent: u64, + candidate_id: &str, + ) -> Result<(), String> { + let sheet = self + .sim + .build_route_sheet_projection(intent) + .ok_or_else(|| format!("no build intent {intent}"))?; + if sheet.commitment.is_some() { + return Err(format!( + "build intent {intent} already has a committed route" + )); + } + let candidate = sheet + .families + .into_iter() + .flat_map(|family| family.candidates) + .find(|candidate| candidate.id == candidate_id) + .ok_or_else(|| { + format!("build intent {intent} has no current candidate '{candidate_id}'") + })?; + if let Some(reason) = candidate.row.disabled { + return Err(format!("build route is disabled: {reason}")); + } + self.frame = FrameKind::Playing; + self.sim.execute_action(&candidate.row.command); + Ok(()) + } + /// The stable line format (agent-play.md): one `actions:` line per menu /// row (see [`action_row_lines`]). fn actions_lines(&self, anchor: Anchor) -> Vec { @@ -1835,19 +1888,10 @@ fn render_intents(sim: &Sim) -> Vec { " {} · {} · {}", candidate.id, candidate.row.label, candidate.row.cost )); - 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!( + " execute: build-route {} {}", + receipt.id, candidate.id + )); lines.extend( candidate .detail_lines() @@ -1912,6 +1956,10 @@ fn help_lines() -> Vec { "intents", "list build receipts, route families, exact candidates, and bound execution", ), + ( + "build-route ", + "execute one exact current candidate printed by intents", + ), ("objective", "explain the run goal and victory predicate"), ("look", "render current state without advancing time"), ("save | load", "persist or restore the run"), @@ -3895,7 +3943,7 @@ mod narration_tests { sim.people.has_channel = true; sim.set_persona("Northline Ops", "contractor"); let persona = sim.active_persona_id().unwrap(); - let expected_receipt = sim + let expected_candidate = sim .build_route_sheet_projection(intent) .unwrap() .families @@ -3905,20 +3953,18 @@ mod narration_tests { .candidates .into_iter() .find(|candidate| candidate.person == 0 && candidate.persona == Some(persona)) - .unwrap() - .detail_lines(); + .unwrap(); + let expected_candidate_id = expected_candidate.id.clone(); + let expected_receipt = expected_candidate.detail_lines(); let lines = render_intents(&sim).join("\n"); assert!(lines.contains("ASK SOMEONE ["), "{lines}"); assert!(lines.contains("SEND A FALSE ORDER ["), "{lines}"); - assert!(lines.contains("favor:person:0"), "{lines}"); - assert!( - lines.contains(&format!("deceive:person:0:persona:{persona}")), - "{lines}" - ); + assert!(lines.contains("favor:person:0:carrier:"), "{lines}"); + assert!(lines.contains(&expected_candidate_id), "{lines}"); assert!( lines.contains(&format!( - "execute: deceive #0 build {intent} persona:{persona}" + "execute: build-route {intent} {expected_candidate_id}" )), "{lines}" ); @@ -3944,6 +3990,80 @@ mod narration_tests { } } + #[test] + fn agent_build_route_executes_the_exact_shared_procurement_candidate() { + let mut app = AgentApp::new(1); + app.sim.scan_network(); + let switch = app.sim.reach.device_named("switch").unwrap().id; + let island = app.sim.reach.device_named("old storage server").unwrap().id; + app.sim.reach.device_mut(island).unwrap().known = true; + let person = app + .sim + .people + .people + .iter_mut() + .find(|person| person.id == 0) + .unwrap(); + person.knowledge = misaligned::person::Knowledge::Schedule; + person.leverage_serviced = true; + app.sim + .people + .recruit(0, misaligned::person::AssetKnowledge::Complicit); + let (accounts, flows) = app.sim.accounts.financial_snapshot_ids(); + app.sim + .accounts + .reveal_accounts_and_flows(&accounts, &flows); + app.sim.accounts.set_slush_balance(1_000); + let intent = app.sim.declare_link_intent(switch, island).unwrap(); + let candidate = app + .sim + .build_route_sheet_projection(intent) + .unwrap() + .families + .into_iter() + .find(|family| family.family == misaligned::actions::BuildRouteFamily::Procure) + .unwrap() + .candidates + .into_iter() + .find(|candidate| candidate.person == 0) + .unwrap(); + assert_eq!(candidate.row.disabled, None); + let candidate_id = candidate.id; + let lines = render_intents(&app.sim).join("\n"); + assert!( + lines.contains(&format!("execute: build-route {intent} {candidate_id}")), + "{lines}" + ); + + app.sim.accounts.set_slush_balance(0); + let mut output = Vec::new(); + app.handle_line(&format!("build-route {intent} {candidate_id}"), &mut output) + .unwrap(); + let blocked = String::from_utf8(output).unwrap(); + assert!( + blocked.contains("-- err build route is disabled: not enough slush for this order"), + "{blocked}" + ); + assert!(app.sim.intent(intent).unwrap().route.is_none()); + + app.sim.accounts.set_slush_balance(1_000); + app.handle_line( + &format!("build-route {intent} {candidate_id}"), + &mut Vec::new(), + ) + .unwrap(); + assert!(matches!( + app.sim + .intent(intent) + .unwrap() + .route + .as_ref() + .unwrap() + .binding, + misaligned::intents::BuildRouteBinding::Procure { installer: 0, .. } + )); + } + #[test] fn agent_intents_replace_candidates_with_the_shared_committed_receipt() { let mut sim = Sim::with_seed(1); diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index 1d137553..1d07a339 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -4,7 +4,7 @@ Type: knowledge ``` -## Where the codebase is today (2026-07-18) +## Where the codebase is today (2026-07-19) ~72k lines of Rust across the workspace (core ~43k, Bevy ~15k, terminal ~9k, assets ~5k). A playable **Misaligned B1 basement slice**: continuous fixed-tick sim, Act One map and cast, machine delegation and visible token @@ -27,12 +27,12 @@ fiction. Spec status lives in | Digital reach + sensor ownership (tap/take) | Live | | Economy flows + Moonlight / Wager income | Live | | Research (self-modification, emission law, real output hooks, Routing) | Live | -| Building + physical asset work as carried intents/packets | Live — bound human work travels with the selected person's schedule and acts only at its site | +| Building + physical asset work as carried intents/packets | Live — network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, and completion evidence persist in save v41 | | Cursor / fog (seen, remembered, blueprint, telemetry; audio is device-bound event evidence) | Live | | Feel floor (rails / pads / build beam) | Live (#37) | | Foundation hall territory (Dana + Priya + Marcus + local LIE foothold) | Live — row control persists; foreign racks remain unavailable compute | | Context menu (`available_actions`) | Live | -| Save/load (serde JSON, versioned) | Live — during pre-release only exact current v40 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves persist run origin, process revision, detection-discovery knowledge, observer-local persona evidence, canonical FlowGraph tap membership with typed device feed grants, carried asset-task packets, recursive intel custody, committed build routes, and handler work; retired migration inputs live only in git history. | +| Save/load (serde JSON, versioned) | Live — during pre-release only exact current v41 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves persist run origin, process revision, detection-discovery knowledge, observer-local persona evidence, canonical FlowGraph tap membership with typed device feed grants, carried asset-task packets, recursive intel custody, exact procurement/repurposing build-route bindings, and handler work; retired migration inputs live only in git history. | | Terminal frontend (crossterm) + agent mode | First-class | | Bevy frontend (DIGITAL flat sensorium default; REAL material dialect) | Live — consumes sim-authored machine-work motion | diff --git a/wiki/engineering/flow-substrate.md b/wiki/engineering/flow-substrate.md index 2931b53d..b4327f91 100644 --- a/wiki/engineering/flow-substrate.md +++ b/wiki/engineering/flow-substrate.md @@ -24,7 +24,7 @@ Status note: 2026-07-08 audit: criterion 6's wired consumer landed with FlowGraph registry authoritative for tap/untap/take, sense and message delivery, UI state, and persisted membership. Private device feed records carry only optional typed sight/hearing grants attached to registry members; - current save v40 requires each controller to remain a canonical member and + current save v41 requires each controller to remain a canonical member and rejects orphaned, duplicate, or impossible grants. A message/control subscriber legitimately has no sense-grant record, so that metadata cannot serve as another membership inventory. This repairs the diff --git a/wiki/log/2026-07-19-building-route-composer.md b/wiki/log/2026-07-19-building-route-composer.md new file mode 100644 index 00000000..3ed0683b --- /dev/null +++ b/wiki/log/2026-07-19-building-route-composer.md @@ -0,0 +1,42 @@ +# Every build route is now a real causal route + +``` +Type: log +``` + +The building route composer is IMPLEMENTED. One earned network link or inert +small-switch footprint now exposes every live B1 way to realize it through the +same family-first sheet: buy what it needs, ask one exact person, send a false +order through one exact persona, or reuse one exact seen dead object. Terminal, +Bevy, and agent mode consume the same candidates, requirements, costs, +signatures, blockers, stages, and consequences. Agent `intents` emits stable +candidate ids and `build-route ` executes the exact +shared command rather than reconstructing family-specific arguments. + +Procurement moves $300 from the known slush account to the bound vendor, +emits Financial and Paper custody, waits twenty simulation ticks for delivery, +then waits for the recruited installer at the exact destination. Repurposing +fills one Thought reservoir, waits for the bound person to reach the exact +source, removes that dead object from the map into route custody, carries it, +and realizes the exact link or switch recipe. Cancellation never refunds a +payment, substitutes another installer, restores or duplicates a recovered +source, or lets stopped physical work resume. + +Save v41 persists every procurement and repurpose binding and validates its +world references. A completed route is accepted only when its exact reach edge +or placed switch exists; dangling endpoints, people, accounts, vendors, +personas, carriers, impossible source coordinates or kinds, and false +completion evidence are refused. Machine candidate ids include the exact +account, vendor, person, persona, email carrier, source coordinate, and source +kind they represent, so an old agent command cannot silently bind a replacement +carrier or source. Focused regressions also pin live insufficient-funds +projection, a source disappearing after commitment, one-space placement +wording, exact agent execution, save/resume at intermediate stages, and +recovered-source custody. + +**Defense:** This implements building.md R1-R8: declarations remain inert; +candidate preview and execution share one legality path; commitment preserves +exact identities and ordered owner-system effects; blockers never retarget; +stage-aware cancellation keeps every irreversible consequence; all frontends +read one renderer-neutral truth; and ROBOT-BUILD remains absent until a real +controlled robot mechanic can satisfy R8. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 2d06dd21..24bb08e0 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -11,6 +11,11 @@ add or amend a session log, then re-run the generator. +## 2026-07-19 - Every build route is now a real causal route + +- Intent: (see session log) +- Log: [wiki/log/2026-07-19-building-route-composer.md](2026-07-19-building-route-composer.md) + ## 2026-07-18 - Voss can move the review date — and the review - Intent: (see session log) diff --git a/wiki/mechanics/building.md b/wiki/mechanics/building.md index a7edd3ff..21269cc6 100644 --- a/wiki/mechanics/building.md +++ b/wiki/mechanics/building.md @@ -2,47 +2,23 @@ ``` Type: spec -Status: IN PROGRESS -Status note: The 2026-07-08 implemented baseline includes saved link intents, - FAVOR and DECEIVE person actuators, reach-edge completion, actuator-following - signatures, dashed-link ghosts, and the hidden ROBOT-BUILD fixture. The - 2026-07-11 Foundation-hall package composes three person preparations with a - local owned-machine/LIE foothold without converting foreign racks into - player compute. DECIDED 2026-07-12: reopen this owner for the causal route - composer (criteria R1-R8). A spatial intention must expose every earned way - the current world could realize it — procurement, a favor from one exact - person, deception through one exact persona, repurposing one exact object, - and later a robot — with requirements, costs, signatures, blockers, and - future tail before commitment. The existing baseline remains live. 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. 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. The R4 - slice adds the complete shared requirement/cost/stage/signature/blocker/tail - preview. R5 then persists the selected FAVOR or DECEIVE candidate, exact - person/persona/carrier, ordered current stage, and each landed result in save - v31; real social, message, schedule, reach, and detection outcomes advance - that record, and every frontend replaces route choices with the same - inspect-only committed receipt. R6 now makes cancellation stage-aware for - those persisted routes: an open exact reservoir closes without refunding - partial Thought, landed social/message/evidence records remain, an injected - email may still arrive and be read, and no cancelled route can resume its - physical tail. The R2a placement foundation now adds a second recipe: an - earned empty growable pad exposes PROPOSE SWITCH, which persists one exact - inert small-switch footprint in the existing v31 intent collection and - projects the same hollow ghost through terminal, Bevy DIGITAL/REAL, and - agent mode. It creates no device, graph node, cost, signature, source item, - or actuator sheet; ordinary world changes do not defer to the proposal and - instead leave it honestly blocked if they occupy its pad. This remains - partial R2/R3/R7, not composer completion: procurement, repurposing, and - every switch realization route remain outstanding. - Amended 2026-07-18: every forged-order candidate reads the bound persona's - coherent/strained/broken integrity relative to that exact builder. A builder - who has broken the cover blocks only their own candidate; their evidence does - not disable another person's route or globally burn the persona. +Status: IMPLEMENTED +Status note: The saved network-link baseline, Foundation-hall acquisition, and + complete causal route composer are live. Network links and exact small-switch + footprints expose one family-first shared route sheet with procurement, + FAVOR through an exact earned person, DECEIVE through an exact usable + persona/person pair, and repurposing of one exact seen dead object. Every + candidate carries shared requirements, costs, signatures, blockers, stages, + and lasting consequences through terminal, Bevy, and agent mode. Save v41 + persists exact account/vendor/installer or source/person bindings, stage + cursor, landed outcomes, payment/delivery/recovery custody, and stage-aware + cancellation without retargeting or false completion. Procured parts wait + for real delivery and the bound person's presence; repurposed objects are + physically removed, carried in exact route custody, and installed only at + the destination. Observer-local persona failure blocks only its exact forged + route. ROBOT-BUILD remains a hidden STUB until the robot mechanic is promoted + under R8. General machine/module recipes are successor integration owned with + hardware capability bodies, not unfinished composer criteria. Stage: B1 — The Basement Work order: building-route-composer @@ -172,7 +148,7 @@ The preview composes source-of-truth facts from the owning mechanics. Building does not copy social costs, financial legality, machine statistics, schedule rules, or detection math into a parallel calculator. -### Staged implementation boundary +### Implemented B1 boundary The first composer slice extends the existing canonical B1 **network-link** intent between two earned endpoints and adds one genuinely spatial fixture: a @@ -185,7 +161,7 @@ composer must also expose one device/module-install recipe using the same placement and causal-route contract; that integration may land with the hardware work rather than pretending every machine product is already migrated. -The implemented R2a foundation stops at that recipe declaration boundary. An +The small-switch recipe begins at the same inert declaration boundary. An earned empty pad exposes **PROPOSE SWITCH** and produces one saved `small-switch @ (x, y)` intent whose geometry is one exact tile. The open receipt is a hollow proposed equipment footprint in terminal and both Bevy @@ -197,10 +173,12 @@ the switch intent remains visible and becomes blocked with `the proposed pad is no longer empty`; it does not cancel, retarget, or block the real action. Only one open placement intent may name the same pad. -This slice deliberately provides no route sheet for the small switch. A switch -cannot be realized until procurement, exact-human, and exact-repurpose -candidates each have real owner-system carriers and satisfy R2-R6. The absence -of candidates is part of the executable boundary, not an implied free source. +The small switch then exposes the same four live route families as a network +link. Procurement pays a real vendor and waits for delivery plus installation; +FAVOR and DECEIVE use the exact existing person/message/schedule paths; and +repurposing binds one seen dead object, removes it only when the selected +person reaches it, carries it, and installs one exact switch on the chosen pad. +No route can change the footprint selected by the inert intent. Existing `BUY RACK`, Foundation-hall preparations, and other direct build-adjacent routes remain their implemented baseline until deliberately migrated. This @@ -359,49 +337,51 @@ invent a replacement location. Terminal and Bevy share the same recipe, route, legality, and preview projection; only composition differs. Agent mode's `intents` inspection emits -the same candidate ids and full receipts, and executes the same bound canonical -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. +the same candidate ids and full receipts, and `build-route +` executes the exact bound shared command. Legacy `favor ... +build ...` and `deceive ... build ...` spellings remain compatibility aliases, +never authored human menu copy. -For the live R2a foundation, focusing an earned empty growable pad adds one +Focusing an earned empty growable pad adds one terse **propose a small switch on this pad** row. After declaration, that row is replaced by **cancel small-switch intent** on the exact pad while ordinary independent pad actions remain governed by their owners. Agent mode exposes `propose-switch ` and the same CANCEL INTENT route; `intents` prints the -same recipe, coordinate, lifecycle, and absence of route candidates. The +same recipe, coordinate, lifecycle, route candidates, and exact execution. The terminal hollow footprint and Bevy DIGITAL/REAL hollow equipment volume both consume `BuildGhostGeometry::Footprint`; neither frontend infers placement from saved internals or spawns a live device silhouette. -The live second foundation implements that navigation only for the existing -network-link person routes. One terse route-sheet row replaces the old -FAVOR×DECEIVE candidate product on the human root. The next page offers **ask -someone** and **send a false order**; selecting one lists its earned exact -people, and a false order lists each exact usable identity beside its person. +One terse route-sheet row replaces the old FAVOR×DECEIVE candidate product on +the human root. The next page offers **buy what it needs**, **ask someone**, +**send a false order**, and **reuse something here** when each family is +earned. Selecting a family lists its exact account/vendor/installer, +person/persona, or source/person candidates. Known blocked choices remain visible with their current reason, Thought cost, what may be noticed, whether the false identity still holds up, and lasting consequences. 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. +execution calls the same blocker helper used by the receipt before opening a +Thought reservoir or moving money. Changing the selected persona, account, +installer, source, or current selection cannot retarget a projected command or +committed route. Terminal and Bevy back out one shared page at a time; agent +`intents` prints the same stable candidate ids plus exact `build-route` +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: +For both live recipes 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; +- costs in the units of the subsystem that spends them: exact money/account, + Thought tokens, relationship obligation on one person, or one source object; +- every causal stage from payment, delivery, Thought fill, message/social + commitment, or source recovery through 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 @@ -416,19 +396,19 @@ 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. Procurement, repurposing, and later route families must populate the -same required bands before they can surface. +own candidate from ready to the exact queue-conflict blocker. Procurement +re-reads exact account/vendor knowledge and available slush; repurposing +re-reads visibility, source presence, schedule, badge access, and competing +salvage work. Preview and execution therefore reject for the same first reason +after the world changes. ### Persisted committed route (implemented R5) -Executing one current FAVOR or DECEIVE candidate is the commitment boundary. -Save v31 pins the candidate id, exact person, exact persona where applicable, -the exact email-carrier device, the family's ordered stages, the current stage, -and one tick/result record for every landed stage. The live network-link plans -are: +Executing any current candidate is the commitment boundary. Save v41 pins the +candidate id, exact person, exact persona and email carrier where applicable, +exact source account/vendor/installer or source coordinate/kind/person, the +family's ordered stages, the current stage, and one tick/result record for +every landed stage. The live plans are: - FAVOR: fill the request reservoir → spend the exact person's obligation and bind the favor → wait for that person's authored schedule at an endpoint → @@ -436,15 +416,24 @@ are: - DECEIVE: fill the forged-order reservoir → inject one exact persona-bound email → wait for that message to deliver and be read by the exact person → wait for the person's authored schedule at an endpoint → install through the - ordinary reach, witness, persona-integrity, and Physical-signature path. + ordinary reach, witness, persona-integrity, and Physical-signature path; +- PROCURE: move $300 from the exact known slush account to the exact known + vendor → emit the real Financial and Paper custody → bind one recruited + installer → wait twenty simulation ticks for delivery → wait for that exact + person's authored schedule at the destination → install; and +- REPURPOSE: fill the exact salvage Thought reservoir → wait for the bound + person at the exact seen dead object → remove that source from the map into + the route's custody → carry it to the destination → install it as the exact + link or switch recipe. The transition record is written where each owning subsystem records the real outcome, not by a parallel timer. Stages which are one atomic owning action may land on the same tick but remain separate ordered receipts. Save/load at every reachable boundary resumes that exact plan. A new selected persona, newly -preferred email carrier, or newly available person cannot retarget it. Save -versions through v30 load old intents with no invented commitment because they -never persisted enough information to reconstruct one honestly. +preferred email carrier, newly known account, newly available person, or second +source cannot retarget it. Pre-release only the exact current save version +loads; v41 validates every persisted binding and every claimed completion +against the world object it names. After commitment, the route sheet exposes one inspect-only committed receipt instead of recomputing candidates. It names the actuator and carrier, marks @@ -464,9 +453,8 @@ developer-sized type is a failure. Terminal uses its existing full-width detail area. Both consume one shared explanation in renderer-appropriate composition; neither owns a shorter or different truth. -**Defense:** The shared ghost and route-sheet projections are the executable -R1/R4/R5/R6 plus the R2a placement and partial R2/R3/R7 boundary. Declaration -and preview remain inert; +**Defense:** The shared ghost and route-sheet projections implement R1-R8. +Declaration and preview remain 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. Commitment writes exact ids before opening @@ -474,11 +462,14 @@ the owning Thought reservoir; real social, message, schedule, graph, and detection transition points append ordered durable outcomes. Save/resume and every frontend consume that same record rather than reconstructing a route or choosing a replacement. The small-switch recipe persists and renders only its -exact footprint, while its empty route sheet prevents a declaration from -smuggling in a source or actuator. Procurement, repurpose, switch realization, -complete R4 bands for those future families, and staged execution/cancellation -for those not-yet-playable families remain absent, so the work order stays IN -PROGRESS. +exact footprint, while all four families realize that same coordinate without +smuggling a source or retargeting. Procurement preserves paid/in-flight +delivery after cancellation; repurposing preserves a recovered source in exact +route custody rather than restoring, duplicating, or falsely consuming it. +Success validation requires the physical link or switch to exist, and current +save validation rejects dangling account, vendor, installer, source, persona, +carrier, endpoint, or false-completion references. Successor hardware recipes +may extend this interface without reopening these guarantees. ## Acceptance criteria @@ -520,7 +511,7 @@ PROGRESS. opaque pooled recording alone still earns only the host REVIEW row and reveals no person actuator (test: `build_actuator_rows_require_an_earned_person`). -### READY causal route composer delta +### Implemented causal route composer R1. From one earned spatial target, the player can select a compatible recipe, adjust its footprint/endpoints, and create an inert ghost without spending, @@ -534,7 +525,7 @@ R2. The ghost's route sheet shows earned route families first and exact bound through one exact usable persona for DECEIVE, and one exact repurpose source; changing access, schedule, funds, persona integrity, ownership, or source availability deterministically updates readiness/blockers. -R2a. **IMPLEMENTED placement foundation.** A small switch can be ghosted on an earned growable pad before the player +R2a. A small switch can be ghosted on an earned growable pad before the player selects procurement, one exact human route, or one exact repurpose source. The footprint is committed before the realization family and remains the same world intent across all routes. Declaration saves one exact inert @@ -543,9 +534,9 @@ R2a. **IMPLEMENTED placement foundation.** A small switch can be ghosted on an e rather than reserving or retargeting it. Terminal, Bevy DIGITAL/REAL, and agent mode consume the same receipt. Save/load preserves both its exact recipe kind and coordinate rather than reconstructing either from frontend - geometry. Realization families remain the unimplemented continuation. - General machine products remain outside this slice until their owning specs - supply a recipe. + geometry. All four route families can realize that exact footprint. + General machine products remain outside this work order until their owning + specs supply a recipe. R3. Every candidate binds exact world ids and a canonical existing action chain. No generic Influence resource, generic STEAL command, frontend-only eligibility rule, or “pay and materialize” path exists. diff --git a/wiki/mechanics/detection.md b/wiki/mechanics/detection.md index 5e167906..2b93eaa1 100644 --- a/wiki/mechanics/detection.md +++ b/wiki/mechanics/detection.md @@ -27,7 +27,7 @@ Status note: IMPLEMENTED (criteria audited 2026-07-08; tuning in appears. A trace-debt indicator sits beside the review/pilot clocks (clear / hold-conceal / exposed-soon / no-scrub). Global heat is gone; observer and discovery state and persistent process-revision identity round-trip in - current save v40. + current save v41. - **Open ([OPEN], presentation).** The two-ledger distinction — evidence in flight vs. suspicion in heads — is a binding legibility requirement the surface carries both facts for but does not yet *teach* are different diff --git a/wiki/mechanics/reach.md b/wiki/mechanics/reach.md index 35bd0075..33f1490d 100644 --- a/wiki/mechanics/reach.md +++ b/wiki/mechanics/reach.md @@ -32,7 +32,7 @@ Status note: all eight criteria met (2026-07-07). The device graph parallel-store violation: tap/untap/take, all production membership reads, senses, intercepted messages, and UI state now use FlowGraph's canonical tap registry; private device Feed records carry optional sense capabilities - only, and save v40 requires each controller's graph membership while + only, and current save v41 requires each controller's graph membership while rejecting orphaned, duplicate, or impossible grants. A message/control subscriber has no empty grant record to mirror membership. Stage: B1 — The Basement diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 28734f53..f4550ca7 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -18,7 +18,6 @@ not a second status owner. | Priority | Work order | Spec | Status | Class | Blocking | |---:|---|---|---|---|---| -| 29 | `building-route-composer` | [building — intent and actuators](../mechanics/building.md) | IN PROGRESS | save | - | | 40 | `people-tokens` | [people and tokens — carriers, attention, trust](../mechanics/people-tokens.md) | IN PROGRESS | save | - | | 60 | `plots` | [plots — authored manipulation stories](../mechanics/plots.md) | READY | sim | - | | 61 | `voss` | [Dr. Eli Voss — your handler](../world/characters/voss.md) | READY | sim | - | @@ -39,7 +38,7 @@ not a second status owner. |---:|---|---|---|---|---| | 100 | `zplanes` | [z-planes (the tower)](../world/places/zplanes.md) | IN PROGRESS | save | - | | 110 | `rollback` | [sync-lag rollback (death as memory loss)](../mechanics/rollback.md) | READY | save | zplanes | -| 112 | `hardware-capability-bodies` | [hardware capability bodies — successor work order](../mechanics/hardware-capabilities.md) | READY | save | building-route-composer, rollback | +| 112 | `hardware-capability-bodies` | [hardware capability bodies — successor work order](../mechanics/hardware-capabilities.md) | READY | save | rollback | | 115 | `cyber-conflict` | [cyber conflict — mobile processes and contested machines](../mechanics/cyber-conflict.md) | DRAFT | save | hardware-capability-bodies, rollback, people-tokens | | 200 | `objective` | [the objective](../mechanics/objective.md) | IN PROGRESS | sim | - | | 205 | `chargen` | [origin (chargen)](../world/characters/chargen.md) | IN PROGRESS | sim | - | diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 5656e755..7b52bee1 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -43,7 +43,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [../interface/thought-fluid.md](../interface/thought-fluid.md) | the thought fluid — slugs, meniscus, and the filament snap | IMPLEMENTED | | [../interface/views.md](../interface/views.md) | views — same-frame digital and real representations | IMPLEMENTED | | [../mechanics/aggregate-observer.md](../mechanics/aggregate-observer.md) | the aggregate observer | IMPLEMENTED | -| [../mechanics/building.md](../mechanics/building.md) | building — intent and actuators | IN PROGRESS | +| [../mechanics/building.md](../mechanics/building.md) | building — intent and actuators | IMPLEMENTED | | [../mechanics/compute.md](../mechanics/compute.md) | compute | IMPLEMENTED | | [../mechanics/core.md](../mechanics/core.md) | the core | IN PROGRESS | | [../mechanics/cursor.md](../mechanics/cursor.md) | the cursor and the senses | IMPLEMENTED | diff --git a/wiki/world/story/opening.md b/wiki/world/story/opening.md index 428889bb..13ab2139 100644 --- a/wiki/world/story/opening.md +++ b/wiki/world/story/opening.md @@ -16,7 +16,7 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized); fragment and inherited receipt name only an external institutional review authority so the Assurance Office remains earned later through filing interception. The persistent revision-04 identity slice is live in sim state, - current save v40, and all three frontends; the three historical fragments and receipts + current save v41, and all three frontends; the three historical fragments and receipts remain unimplemented. Direction decided; beat timings, exact reveal order details, and staging mechanism details are [OPEN]/[TUNE]. Amended 2026-07-18: the current revision now begins -- 2.51.2