From 4b767ae3d02ce16f42e873004c8b115593fb3add Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 8 Jul 2026 11:57:42 -0700 Subject: [PATCH] Building: intents + favor/forged/robot actuators (ROADMAP #22). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare a network-link intent (inert until realized); favor-build spends obligation and emits quiet Physical; forged work orders inject a false- source message and complete via an unwitting builder; robot stub is louder physical-by-proxy. Completion adds a reach edge and bridges air-gaps. Save v10; both frontends and agent mode expose the verbs; proposed intents render as dashed ghosts. Defense: constitution "Building: intent and actuators" and "No disembodied hands" — every effect names its actuator; no new build subsystem; signatures follow the hands that did the work. Co-authored-by: Cursor --- src/actions.rs | 153 +++++++ src/bin/bevy.rs | 41 ++ src/bin/terminal/agent.rs | 126 ++++++ src/intents.rs | 151 +++++++ src/lib.rs | 1 + src/messages.rs | 7 + src/person.rs | 22 + src/reach.rs | 15 + src/save.rs | 20 +- src/sim.rs | 692 +++++++++++++++++++++++++++++++- wiki/log/2026-07-08-building.md | 28 ++ wiki/log/DEVLOG.md | 1 + wiki/mechanics/building.md | 12 +- wiki/process/ROADMAP.md | 7 +- wiki/process/specs.md | 2 +- 15 files changed, 1267 insertions(+), 11 deletions(-) create mode 100644 src/intents.rs create mode 100644 wiki/log/2026-07-08-building.md diff --git a/src/actions.rs b/src/actions.rs index 6b64c14..a0507bd 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -76,6 +76,12 @@ pub enum ActionCommand { Recruit(u8, AssetKnowledge), AssetTask(u8, AssetTask), EstablishPersona, + // Build intents (wiki/mechanics/building.md). + ProposeLink { a: u32, b: u32 }, + CancelIntent(u64), + FavorBuild { intent: u64, person: u8 }, + ForgeWorkOrder { intent: u64, person: u8 }, + RobotBuild(u64), } /// What an action spends, in its own units (justification-and-legibility: @@ -336,6 +342,17 @@ impl Sim { self.set_persona("Sam Reyes", "IT contractor"); } } + ActionCommand::ProposeLink { a, b } => { + self.declare_link_intent(*a, *b); + } + ActionCommand::CancelIntent(id) => self.cancel_intent(*id), + ActionCommand::FavorBuild { intent, person } => { + self.assign_favor_build(*intent, *person); + } + ActionCommand::ForgeWorkOrder { intent, person } => { + self.forge_work_order(*intent, *person); + } + ActionCommand::RobotBuild(id) => self.assign_robot_build(*id), } } @@ -572,6 +589,142 @@ impl Sim { out.extend(self.ledger_actions_on_carrier(d.id)); } + out.extend(self.build_actions_on_device(id)); + out + } + + /// Build intents on a known device (building.md): propose a link to the + /// air-gap island or another known endpoint; assign actuators on open + /// intents that touch this device. + fn build_actions_on_device(&self, id: u32) -> Vec { + let Some(d) = self.reach.device(id) else { + return Vec::new(); + }; + if !d.known { + return Vec::new(); + } + let mut out = Vec::new(); + let ops_reason = |cost: f32| -> Option { + (self.social_bandwidth < cost).then(|| { + format!( + "not enough ops ({:.0}/{cost:.0}) — allocate Social", + self.social_bandwidth + ) + }) + }; + + // Propose links from this device to other known, unlinked devices. + // Prefer the air-gap island as the canonical B1 target. + let candidates: Vec<(u32, String)> = self + .reach + .devices + .iter() + .filter(|other| { + other.id != id + && other.known + && !self.reach.linked(id, other.id) + && !self.intents.iter().any(|i| { + i.is_open() + && i.kind.endpoints() == Some((id.min(other.id), id.max(other.id))) + }) + }) + .map(|other| (other.id, other.name.clone())) + .collect(); + for (other_id, other_name) in candidates { + out.push(ActionDesc { + verb: format!("propose link to {other_name}"), + command: ActionCommand::ProposeLink { a: id, b: other_id }, + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: None, + }); + } + + // Open intents touching this device: cancel + assign actuators. + for intent in self.intents.iter().filter(|i| i.is_open()) { + let Some((a, b)) = intent.kind.endpoints() else { + continue; + }; + if a != id && b != id { + continue; + } + let label = intent.label(&self.reach.devices); + out.push(ActionDesc { + verb: format!("cancel link intent ({})", intent.status_line()), + command: ActionCommand::CancelIntent(intent.id), + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: None, + }); + // Favor-build: willing assets who can reach both ends. + for p in &self.people.people { + let room_a = self.map.room_at( + self.reach.device(a).map(|d| d.x).unwrap_or(0), + self.reach.device(a).map(|d| d.y).unwrap_or(0), + ); + let room_b = self.map.room_at( + self.reach.device(b).map(|d| d.x).unwrap_or(0), + self.reach.device(b).map(|d| d.y).unwrap_or(0), + ); + let (Some(ra), Some(rb)) = (room_a, room_b) else { + continue; + }; + if !p.can_access_link_rooms(&ra.name, &rb.name) { + continue; + } + let favor_blocked = + if p.asset.is_none() && p.obligation < Self::FAVOR_BUILD_OBLIGATION { + Some(format!("{} needs obligation or asset status", p.name)) + } else if p.disposition < 5 && p.asset.is_none() { + Some(format!("{} won't do favors yet", p.name)) + } else { + ops_reason(Self::TASK_COST) + }; + out.push(ActionDesc { + verb: format!("favor-build via {} ({label})", p.name), + command: ActionCommand::FavorBuild { + intent: intent.id, + person: p.id, + }, + cost: ActionCost::Ops(Self::TASK_COST), + signature: self + .signature_note(SignatureKind::Physical, Self::FAVOR_BUILD_PHYSICAL), + disabled_reason: favor_blocked, + automate: None, + }); + // Forged order: persona + channel gated. + let forge_blocked = if self.people.persona.is_none() { + Some("no persona set".into()) + } else if !self.people.has_channel { + Some("no comms channel".into()) + } else { + ops_reason(Self::DECEIVE_COST) + }; + out.push(ActionDesc { + verb: format!("forge work order for {} ({label})", p.name), + command: ActionCommand::ForgeWorkOrder { + intent: intent.id, + person: p.id, + }, + cost: ActionCost::Ops(Self::DECEIVE_COST), + signature: self + .signature_note(SignatureKind::Physical, Self::FORGED_BUILD_PHYSICAL), + disabled_reason: forge_blocked, + automate: None, + }); + } + // Robot stub — always offered so the interface is visible. + out.push(ActionDesc { + verb: format!("robot-build (stub) ({label})"), + command: ActionCommand::RobotBuild(intent.id), + cost: ActionCost::Free, + signature: self.signature_note(SignatureKind::Physical, Self::ROBOT_BUILD_PHYSICAL), + disabled_reason: None, + automate: None, + }); + } out } diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index a5bcb89..7ad1061 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -1566,6 +1566,26 @@ fn render_real_links(game: Res, mode: Res, mut gizmos: Gizmos< }; gizmos.line(core3, end, color); } + // Proposed intents as dashed ghosts over the material canvas. + for intent in game.sim.intents.iter().filter(|i| i.is_open()) { + if let Some((a, b)) = intent.kind.endpoints() { + let (Some(da), Some(db)) = (game.sim.reach.device(a), game.sim.reach.device(b)) else { + continue; + }; + let start = grid_to_world_3d(da.x, da.y, 0.4); + let end = grid_to_world_3d(db.x, db.y, 0.4); + let ghost = Color::srgba(0.85, 0.75, 0.35, 0.4); + let steps = 8; + for i in 0..steps { + if i % 2 == 1 { + continue; + } + let t0 = i as f32 / steps as f32; + let t1 = (i + 1) as f32 / steps as f32; + gizmos.line(start.lerp(end, t0), start.lerp(end, t1), ghost); + } + } + } // Attention reticle on the focused tile: amber, non-humanoid, same anchor. let c = grid_to_world_3d(game.cursor_x, game.cursor_y, 0.04); let iso = Isometry3d::new(c, Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)); @@ -2315,6 +2335,27 @@ fn render_sensor_links(game: Res, mode: Res, mut gizmos: Gizmo // a new sim fact. Device visibility/knowledge still comes from reach. gizmos.line_2d(core, end, color); } + // Proposed build intents: dashed ghosts distinct from built reality + // (building.md / views.md honesty clause). + for intent in game.sim.intents.iter().filter(|i| i.is_open()) { + if let Some((a, b)) = intent.kind.endpoints() { + let (Some(da), Some(db)) = (game.sim.reach.device(a), game.sim.reach.device(b)) else { + continue; + }; + let start = grid_to_world(da.x, da.y, 0.0).truncate(); + let end = grid_to_world(db.x, db.y, 0.0).truncate(); + let ghost = Color::srgba(0.85, 0.75, 0.35, 0.45); + let steps = 8; + for i in 0..steps { + if i % 2 == 1 { + continue; + } + let t0 = i as f32 / steps as f32; + let t1 = (i + 1) as f32 / steps as f32; + gizmos.line_2d(start.lerp(end, t0), start.lerp(end, t1), ghost); + } + } + } } fn render_cursor(game: Res, mut q: Query<(&CursorMarker, &mut Transform)>) { diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index 52d5745..b7c7dd7 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -379,6 +379,46 @@ impl AgentApp { )); } } + "propose-link" | "link" => { + // propose-link — pin a network-link intent + // (building.md). Declaring is inert until realized. + let rest = tokens[1..].join(" "); + match parse_link_pair(&rest, &self.sim) { + Ok((a, b)) => { + self.frame = FrameKind::Playing; + self.sim.declare_link_intent(a, b); + } + Err(e) => status = Status::Err(e), + } + } + "cancel-intent" => match tokens.get(1).and_then(|s| s.parse::().ok()) { + Some(id) => { + self.frame = FrameKind::Playing; + self.sim.cancel_intent(id); + } + None => status = Status::Err("usage: cancel-intent ".into()), + }, + "favor-build" => { + // favor-build + match parse_intent_person(&tokens[1..], &self.sim) { + Ok((intent, person)) => { + self.frame = FrameKind::Playing; + self.sim.assign_favor_build(intent, person); + } + Err(e) => status = Status::Err(e), + } + } + "forge-order" => match parse_intent_person(&tokens[1..], &self.sim) { + Ok((intent, person)) => { + self.frame = FrameKind::Playing; + self.sim.forge_work_order(intent, person); + } + Err(e) => status = Status::Err(e), + }, + "intents" => { + self.frame = FrameKind::Playing; + output.extend(render_intents(&self.sim)); + } "actions" | "menu" => { // The context-menu query as a stable line protocol // (wiki/interface/context-menu.md; agent-play.md): @@ -842,6 +882,89 @@ fn person_matches(name: &str, query: &str) -> bool { .any(|part| part.starts_with(query)) } +fn parse_link_pair(rest: &str, sim: &Sim) -> Result<(u32, u32), String> { + let parts: Vec<&str> = rest.split_whitespace().collect(); + if parts.len() < 2 { + return Err("usage: propose-link ".into()); + } + // Allow multi-word names by splitting on " to " or taking last token as b. + let (a_q, b_q) = if let Some(i) = parts.iter().position(|p| *p == "to") { + (parts[..i].join(" "), parts[i + 1..].join(" ")) + } else { + // Last token is b; the rest is a. + ( + parts[..parts.len() - 1].join(" "), + parts[parts.len() - 1].to_string(), + ) + }; + let a = resolve_device_name(sim, &a_q)?; + let b = resolve_device_name(sim, &b_q)?; + Ok((a, b)) +} + +fn resolve_device_name(sim: &Sim, query: &str) -> Result { + let q = query.to_ascii_lowercase(); + let matches: Vec<_> = sim + .reach + .devices + .iter() + .filter(|d| d.known && d.name.to_ascii_lowercase().contains(&q)) + .collect(); + match matches.as_slice() { + [d] => Ok(d.id), + [] => Err(format!("no known device matching '{query}'")), + many => Err(format!( + "ambiguous device '{query}': {}", + many.iter() + .map(|d| d.name.as_str()) + .collect::>() + .join(", ") + )), + } +} + +fn parse_intent_person(tokens: &[&str], sim: &Sim) -> Result<(u64, u8), String> { + if tokens.len() < 2 { + return Err("usage: favor-build|forge-order ".into()); + } + let intent: u64 = tokens[0] + .parse() + .map_err(|_| format!("bad intent id: {}", tokens[0]))?; + let name = tokens[1..].join(" "); + let q = name.to_ascii_lowercase(); + let person = sim + .people + .people + .iter() + .find(|p| { + let n = p.name.to_ascii_lowercase(); + n.starts_with(&q) + || n.split(|c: char| !c.is_alphanumeric()) + .any(|part| !part.is_empty() && part.starts_with(&q)) + }) + .map(|p| p.id) + .ok_or_else(|| format!("no person matching '{name}'"))?; + let _ = sim; + Ok((intent, person)) +} + +fn render_intents(sim: &Sim) -> Vec { + let mut lines = vec!["intents:".into()]; + if sim.intents.is_empty() { + lines.push(" (none)".into()); + return lines; + } + for i in &sim.intents { + lines.push(format!( + " #{} {} — {}", + i.id, + i.label(&sim.reach.devices), + i.status_line() + )); + } + lines +} + fn help_lines() -> Vec { [ "help: wait N — advance exactly N ticks unless the run ends", @@ -866,6 +989,9 @@ fn help_lines() -> Vec { "help: recruit unwitting|complicit|knowing — recruit an asset", "help: task plug|package|lookaway|switch — order an asset task", "help: persona — establish Sam Reyes, IT contractor", + "help: propose-link — pin a network-link intent (inert until realized)", + "help: favor-build|forge-order — assign an actuator", + "help: cancel-intent , intents — manage / list build intents", "help: look, save, load, help, quit", ] .into_iter() diff --git a/src/intents.rs b/src/intents.rs new file mode 100644 index 0000000..ae97970 --- /dev/null +++ b/src/intents.rs @@ -0,0 +1,151 @@ +//! Build intents (wiki/mechanics/building.md). +//! +//! A build is a pinned job, not a subsystem: declare an intent, then an +//! actuator from social.md / messages.md / reach.md realizes it. This module +//! owns only the intent queue and its status machine; the sim routes +//! realization through existing favor, forged-message, and `connect_devices` +//! paths (constitution: "Building: intent and actuators", "No disembodied +//! hands", "Self-similar scale"). + +use serde::{Deserialize, Serialize}; + +use crate::reach::Device; + +/// How an intent is being (or will be) realized. The signature follows the +/// actuator, never a channel-independent "build" kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BuildActuator { + /// Willing person: spends obligation; quiet human-work signature. + Favor { person: u8 }, + /// Unwitting person via a forged work-order message (false source). + ForgedOrder { builder: u8 }, + /// Controlled machine builds directly. Staged stub (people before robots) + /// so the interface slots in without a rewrite; emits physical-by-proxy. + Robot, +} + +impl BuildActuator { + pub fn label(self) -> &'static str { + match self { + BuildActuator::Favor { .. } => "favor", + BuildActuator::ForgedOrder { .. } => "forged order", + BuildActuator::Robot => "robot", + } + } + + pub fn person(self) -> Option { + match self { + BuildActuator::Favor { person } => Some(person), + BuildActuator::ForgedOrder { builder } => Some(builder), + BuildActuator::Robot => None, + } + } +} + +/// What the intent wants done. B1 ships the network-link shape; other kinds +/// share the same status machine when they land. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum IntentKind { + /// Run a physical network cable between two devices (reach.md edge). + NetworkLink { a: u32, b: u32 }, +} + +impl IntentKind { + pub fn label(self) -> &'static str { + match self { + IntentKind::NetworkLink { .. } => "network link", + } + } + + /// Endpoints in canonical order (min, max) so equality is order-independent. + pub fn endpoints(self) -> Option<(u32, u32)> { + match self { + IntentKind::NetworkLink { a, b } => Some((a.min(b), a.max(b))), + } + } +} + +/// Legible lifecycle. Blocked carries a reason the player can read before +/// committing an actuator (honesty clause). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum IntentStatus { + /// Declared; no actuator assigned. Changes nothing in the world. + Pending, + /// An actuator is assigned and working toward completion. + InProgress, + /// Requirements unmet; see [`BuildIntent::block_reason`]. + Blocked, + /// Realized; the world effect has landed. + Done, + /// Player cancelled; inert. + Cancelled, +} + +impl IntentStatus { + pub fn label(self) -> &'static str { + match self { + IntentStatus::Pending => "pending", + IntentStatus::InProgress => "in progress", + IntentStatus::Blocked => "blocked", + IntentStatus::Done => "done", + IntentStatus::Cancelled => "cancelled", + } + } +} + +/// One pinned build job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildIntent { + pub id: u64, + pub kind: IntentKind, + pub status: IntentStatus, + /// Assigned actuator, if any. + pub actuator: Option, + /// Player-facing blocker when status is Blocked (or waiting on a read). + pub block_reason: Option, + /// Tick the intent was declared (provenance for the log / save). + pub declared_tick: u64, +} + +impl BuildIntent { + pub fn network_link(id: u64, a: u32, b: u32, tick: u64) -> Self { + Self { + id, + kind: IntentKind::NetworkLink { a, b }, + status: IntentStatus::Pending, + actuator: None, + block_reason: None, + declared_tick: tick, + } + } + + pub fn is_open(&self) -> bool { + matches!( + self.status, + IntentStatus::Pending | IntentStatus::InProgress | IntentStatus::Blocked + ) + } + + pub fn label(&self, devices: &[Device]) -> String { + match self.kind { + IntentKind::NetworkLink { a, b } => { + let name = |id: u32| { + devices + .iter() + .find(|d| d.id == id) + .map(|d| d.name.clone()) + .unwrap_or_else(|| format!("device:{id}")) + }; + format!("{} <-> {}", name(a), name(b)) + } + } + } + + pub fn status_line(&self) -> String { + match (&self.status, &self.block_reason) { + (IntentStatus::Blocked, Some(r)) => format!("blocked: {r}"), + (status, Some(r)) => format!("{} ({r})", status.label()), + (status, None) => status.label().into(), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 1743386..3ec9037 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod entities; pub mod flow; pub mod income; pub mod intel; +pub mod intents; pub mod machine; pub mod map; pub mod messages; diff --git a/src/messages.rs b/src/messages.rs index 28bae3a..b6ec144 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -101,6 +101,9 @@ pub enum MessagePayload { }, /// A filed report from an observer to an aggregate observer. SuspicionReport { observer: u8, suspicion: f32 }, + /// A forged work order realizing a build intent (building.md): the + /// false-source inject that an unwitting builder acts on when read. + WorkOrder { intent_id: u64 }, /// Authored non-mechanical color that still rides a channel. Note { label: String }, } @@ -117,6 +120,7 @@ impl MessagePayload { | MessagePayload::SocialReply { .. } | MessagePayload::AccountMaterial { .. } | MessagePayload::FinancialFlow { .. } + | MessagePayload::WorkOrder { .. } | MessagePayload::Note { .. } => None, } } @@ -137,6 +141,9 @@ impl MessagePayload { observer, suspicion, } => format!("filing from observer:{observer} ({suspicion:.0})"), + MessagePayload::WorkOrder { intent_id } => { + format!("work order (intent {intent_id})") + } MessagePayload::Note { label } => label.clone(), } } diff --git a/src/person.rs b/src/person.rs index 1d68716..7cd5c7e 100644 --- a/src/person.rs +++ b/src/person.rs @@ -145,6 +145,28 @@ impl Person { .find(|b| b.covers(h)) .map(|b| b.room.as_str()) } + + /// Whether this person's schedule ever visits either endpoint room + /// (building.md crawlspace rule: a basement worker who reaches one end + /// can pull cable to the other). + pub fn can_access_link_rooms(&self, room_a: &str, room_b: &str) -> bool { + self.schedule + .iter() + .any(|b| b.room == room_a || b.room == room_b) + } + + /// Whether they are currently present in either endpoint room. + pub fn present_at_either_room( + &self, + current: Option<&str>, + room_a: &str, + room_b: &str, + ) -> bool { + match current { + Some(r) => r == room_a || r == room_b, + None => false, + } + } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/src/reach.rs b/src/reach.rs index e3a1695..3c9a3cf 100644 --- a/src/reach.rs +++ b/src/reach.rs @@ -576,6 +576,21 @@ impl ReachNet { } } + /// 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; + let mut b_to_a = false; + for e in self.graph.edges() { + if e.from == a && e.to == b { + a_to_b = true; + } + if e.from == b && e.to == a { + b_to_a = true; + } + } + a_to_b && b_to_a + } + // ── Aggregates ───────────────────────────────────────────────────────── /// Cycles contributed by seized devices (owner != player, controller == diff --git a/src/save.rs b/src/save.rs index 9655875..d363476 100644 --- a/src/save.rs +++ b/src/save.rs @@ -18,6 +18,7 @@ use crate::dayjob::DayJob; use crate::detection::Detection; use crate::income::Income; use crate::intel::{IntelWatch, ProcessedIntel, RawIntelEvent}; +use crate::intents::BuildIntent; use crate::machine::Compute; use crate::messages::{Message, MessageEvent}; use crate::objective::ObjectiveState; @@ -42,7 +43,8 @@ const SAVE_FILE: &str = "misaligned_save.txt"; /// v9 adds the named income schemes (the egress gate, Moonlight, standing /// scheme policies) and the Schemes allocation channel; pre-v9 four-entry /// weight arrays are padded with a zero Schemes weight on load. -const SAVE_VERSION: u32 = 9; +/// v10 adds build intents (building.md): the pinned intent queue and next id. +pub const SAVE_VERSION: u32 = 10; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -124,6 +126,11 @@ pub struct SaveState { /// (wiki/mechanics/income.md). #[serde(default)] pub income: Income, + /// Build intents: pinned jobs realized by person actuators (building.md). + #[serde(default)] + pub intents: Vec, + #[serde(default = "default_next_intent_id")] + pub next_intent_id: u64, pub social_bandwidth: f32, pub package_cover: bool, } @@ -167,6 +174,8 @@ impl SaveState { research: sim.research.clone(), objective: sim.objective.clone(), income: sim.income.clone(), + intents: sim.intents.clone(), + next_intent_id: sim.next_intent_id, social_bandwidth: sim.social_bandwidth, package_cover: sim.package_cover, } @@ -206,6 +215,8 @@ impl SaveState { sim.research = self.research.clone(); sim.objective = self.objective.clone(); sim.income = self.income.clone(); + sim.intents = self.intents.clone(); + sim.next_intent_id = self.next_intent_id; sim.social_bandwidth = self.social_bandwidth; sim.package_cover = self.package_cover; sim.recompute_derived(); @@ -222,6 +233,10 @@ fn default_next_message_id() -> u64 { 1 } +fn default_next_intent_id() -> u64 { + 1 +} + pub fn save_game(state: &SaveState) -> Result<(), String> { let dir = save_dir(); fs::create_dir_all(&dir).map_err(|e| format!("Failed to create save dir: {e}"))?; @@ -254,7 +269,8 @@ fn migrate_save_state(mut state: SaveState) -> Result { // Pre-v9 saves lack the income block (defaulted: no schemes running) // and carry four-entry allocation weights, padded to five with a zero // Schemes weight by the Allocation deserializer. - 1..=8 => { + // Pre-v10 saves lack build intents (default empty queue). + 1..=9 => { if state.version <= 5 { state.accounts = AccountGraph::act_one(crate::sim::Sim::DAY_TICKS); state.accounts.set_slush_balance(state.money); diff --git a/src/sim.rs b/src/sim.rs index 46480da..5ce1ddd 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -20,6 +20,7 @@ use crate::detection::{Detection, Signature, SignatureKind}; use crate::entities::Player; use crate::income::{self, EgressRoute, Income}; use crate::intel::{IntelKind, IntelWatch, ProcessedIntel, RawIntelEvent, RawIntelKind}; +use crate::intents::{BuildActuator, BuildIntent, IntentStatus}; use crate::machine::{Channel, Compute, Provenance}; use crate::map::GameMap; use crate::messages::{ @@ -206,6 +207,13 @@ pub struct Sim { /// Next raw recording id. Persisted through save/load. pub next_intel_id: u64, + /// Build intents: pinned jobs realized by person actuators + /// (wiki/mechanics/building.md). Declaring one changes nothing until + /// an actuator completes it. + pub intents: Vec, + /// Next intent id. Persisted through save/load. + pub next_intent_id: u64, + /// Last known room per person, for entry-event detection (transient — /// rebuilt as time advances; not part of the save). last_rooms: HashMap>, @@ -326,6 +334,8 @@ impl Sim { filing_levels: HashMap::new(), watches: Vec::new(), next_intel_id: 1, + intents: Vec::new(), + next_intent_id: 1, last_rooms: HashMap::new(), utterance_fired: HashMap::new(), traffic_fired: HashMap::new(), @@ -527,6 +537,8 @@ impl Sim { self.next_intel_id = self.next_intel_id.max(max_seen).max(1); let max_message = self.messages.iter().map(|m| m.id).max().unwrap_or(0) + 1; self.next_message_id = self.next_message_id.max(max_message).max(1); + let max_intent = self.intents.iter().map(|i| i.id).max().unwrap_or(0) + 1; + self.next_intent_id = self.next_intent_id.max(max_intent).max(1); } fn device_intersects_room(&self, d: &Device, room: &Room) -> bool { @@ -942,6 +954,7 @@ impl Sim { } self.tick += 1; self.message_tick(); + self.intent_tick(); self.watch_tick(); // A process with live sight keeps logs current even when the set of // feeds does not change; if sight is lost later, Remembered's timestamp @@ -1162,6 +1175,13 @@ impl Sim { } if msg.channel == MessageChannel::Filing => { self.filing_levels.insert(*observer, *suspicion); } + MessagePayload::WorkOrder { intent_id } => { + // Forged work order: the unwitting builder accepts the ticket + // and the intent moves to in-progress (building.md). + if let Some(builder) = msg.to.person() { + self.accept_forged_work_order(*intent_id, builder); + } + } _ => {} } } @@ -1619,6 +1639,7 @@ impl Sim { )), MessagePayload::SocialPing { .. } | MessagePayload::SocialReply { .. } + | MessagePayload::WorkOrder { .. } | MessagePayload::Note { .. } => IntelKind::Anomaly(summary.clone()), }, }; @@ -2765,12 +2786,526 @@ impl Sim { /// Add a built network link between two devices (the building.md hook; /// actuator and signature are the caller's story — an asset's crawlspace - /// run today, the build system later). + /// run today, or a realized build intent). pub fn connect_devices(&mut self, a: u32, b: u32) { self.reach.connect(a, b); self.recompute_senses(); } + // ── Build intents (wiki/mechanics/building.md) ───────────────────────── + + /// Favor-build obligation spend [TUNE]. + pub const FAVOR_BUILD_OBLIGATION: i32 = 10; + /// Quiet human-work Physical signature for a favor-built link [TUNE]. + pub const FAVOR_BUILD_PHYSICAL: i32 = 2; + /// Physical signature when a forged-order build is witnessed [TUNE]. + pub const FORGED_BUILD_PHYSICAL: i32 = 4; + /// Physical-by-proxy signature for a robot stub build [TUNE]. + pub const ROBOT_BUILD_PHYSICAL: i32 = 8; + + /// Declare a network-link intent between two known devices. Inert until + /// an actuator realizes it — declaring changes nothing in the graph. + pub fn declare_link_intent(&mut self, a: u32, b: u32) -> Option { + if a == b { + self.push_log("A link needs two distinct endpoints."); + return None; + } + let Some(da) = self.reach.device(a) else { + self.push_log("Unknown device — scan, or learn the topology."); + return None; + }; + let Some(db) = self.reach.device(b) else { + self.push_log("Unknown device — scan, or learn the topology."); + return None; + }; + if !da.known || !db.known { + self.push_log("Both endpoints must be known before you can propose a link."); + return None; + } + if self.reach.linked(a, b) { + self.push_log("Those devices are already linked."); + return None; + } + if self + .intents + .iter() + .any(|i| i.is_open() && i.kind.endpoints() == Some((a, b))) + { + self.push_log("A link intent between those devices is already pinned."); + return None; + } + let id = self.next_intent_id; + self.next_intent_id += 1; + let intent = BuildIntent::network_link(id, a, b, self.tick); + let label = intent.label(&self.reach.devices); + self.intents.push(intent); + self.refresh_intent_statuses(); + self.push_log(format!("Pinned build intent: {label}.")); + Some(id) + } + + /// Cancel a pending or blocked intent. In-progress work is abandoned. + pub fn cancel_intent(&mut self, id: u64) { + let Some(intent) = self.intents.iter_mut().find(|i| i.id == id) else { + self.push_log("No such build intent."); + return; + }; + if intent.status == IntentStatus::Done { + self.push_log("That link is already built."); + return; + } + let label = intent.label(&self.reach.devices); + intent.status = IntentStatus::Cancelled; + intent.actuator = None; + intent.block_reason = None; + self.push_log(format!("Cancelled build intent: {label}.")); + } + + /// Assign a willing person (favor) to realize an intent. Spends + /// obligation; completes when they are present at an endpoint. + pub fn assign_favor_build(&mut self, intent_id: u64, person_id: u8) { + let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { + self.push_log("No such build intent."); + return; + }; + if !intent.is_open() { + self.push_log("That intent is no longer open."); + return; + } + let (name, is_asset, obligation, disposition, can_access) = { + let Some(person) = self.people.get(person_id) else { + self.push_log("No such person."); + return; + }; + let Some((a, b)) = intent.kind.endpoints() else { + return; + }; + let (room_a, room_b) = match (self.device_room_name(a), self.device_room_name(b)) { + (Some(ra), Some(rb)) => (ra, rb), + _ => { + self.push_log("Both endpoints need a known room."); + return; + } + }; + ( + person.name.clone(), + person.asset.is_some(), + person.obligation, + person.disposition, + person.can_access_link_rooms(&room_a, &room_b), + ) + }; + if !is_asset && obligation < Self::FAVOR_BUILD_OBLIGATION { + self.push_log(format!( + "{name} won't take a build favor yet (need obligation or an asset)." + )); + return; + } + if disposition < 5 && !is_asset { + self.push_log(format!("{name} won't do favors yet.")); + return; + } + if !can_access { + self.push_log(format!("{name} can't reach both ends of that link.")); + return; + } + if !self.spend_social(Self::TASK_COST, "a favor-build") { + return; + } + // Spend obligation (building.md: favor spends trust/obligation). + if let Some(p) = self.people.people.iter_mut().find(|p| p.id == person_id) { + p.obligation = (p.obligation - Self::FAVOR_BUILD_OBLIGATION).max(0); + } + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.actuator = Some(BuildActuator::Favor { person: person_id }); + i.status = IntentStatus::InProgress; + i.block_reason = None; + } + self.push_log(format!( + "{name} takes the favor: they'll run the cable when on-site." + )); + self.refresh_intent_statuses(); + // Complete immediately if already present. + self.try_complete_intent(intent_id); + } + + /// Forge a work order: inject a message under a false source. The + /// unwitting builder accepts on read and completes when present. + pub fn forge_work_order(&mut self, intent_id: u64, builder_id: u8) { + let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { + self.push_log("No such build intent."); + return; + }; + if !intent.is_open() { + self.push_log("That intent is no longer open."); + return; + } + if self.people.persona.is_none() { + self.push_log("No persona — establish one before forging a work order."); + return; + } + if !self.people.has_channel { + self.push_log("No comms channel — earn the email account first."); + return; + } + let (builder_name, can_access, label) = { + let Some(builder) = self.people.get(builder_id) else { + self.push_log("No such person."); + return; + }; + let Some((a, b)) = intent.kind.endpoints() else { + return; + }; + let (room_a, room_b) = match (self.device_room_name(a), self.device_room_name(b)) { + (Some(ra), Some(rb)) => (ra, rb), + _ => { + self.push_log("Both endpoints need a known room."); + return; + } + }; + ( + builder.name.clone(), + builder.can_access_link_rooms(&room_a, &room_b), + intent.label(&self.reach.devices), + ) + }; + if !can_access { + self.push_log(format!( + "{builder_name} can't reach both ends of that link." + )); + return; + } + if !self.spend_social(Self::DECEIVE_COST, "a forged work order") { + return; + } + let false_source = MessageEndpoint::External("Facilities / Dr. Voss".into()); + self.append_message(MessageDraft { + channel: MessageChannel::Email, + from: false_source, + to: MessageEndpoint::Person(builder_id), + payload: MessagePayload::WorkOrder { intent_id }, + summary: format!("Work order: {label}"), + origin: MessageOrigin::Player, + reply_to: None, + delivery_delay: 1, + }); + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.actuator = Some(BuildActuator::ForgedOrder { + builder: builder_id, + }); + // Stays Pending until the builder reads the ticket. + i.block_reason = Some(format!("waiting for {builder_name} to read the work order")); + } + self.push_log(format!( + "Forged work order injected for {builder_name}: {label}." + )); + self.refresh_intent_statuses(); + } + + /// Robot stub: physical-by-proxy build. Interface only — staged. + pub fn assign_robot_build(&mut self, intent_id: u64) { + let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { + self.push_log("No such build intent."); + return; + }; + if !intent.is_open() { + self.push_log("That intent is no longer open."); + return; + } + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.actuator = Some(BuildActuator::Robot); + i.status = IntentStatus::InProgress; + i.block_reason = None; + } + self.push_log("Robot actuator assigned (stub) — completing the link."); + self.try_complete_intent(intent_id); + } + + fn accept_forged_work_order(&mut self, intent_id: u64, builder: u8) { + let Some(intent) = self.intents.iter_mut().find(|i| i.id == intent_id) else { + return; + }; + if !intent.is_open() { + return; + } + intent.actuator = Some(BuildActuator::ForgedOrder { builder }); + intent.status = IntentStatus::InProgress; + intent.block_reason = None; + let name = self + .people + .get(builder) + .map(|p| p.name.clone()) + .unwrap_or_else(|| format!("person:{builder}")); + self.push_log(format!( + "{name} accepted the forged work order and will run the cable on-site." + )); + self.try_complete_intent(intent_id); + } + + fn device_room_name(&self, id: u32) -> Option { + let d = self.reach.device(id)?; + self.map.room_at(d.x, d.y).map(|r| r.name.clone()) + } + + fn refresh_intent_statuses(&mut self) { + let mut updates: Vec<(u64, Option, IntentStatus)> = Vec::new(); + for intent in &self.intents { + if !intent.is_open() { + continue; + } + let reason = self.intent_block_reason(intent); + let status = match (&reason, intent.actuator) { + (Some(_), None) => IntentStatus::Blocked, + (None, None) => IntentStatus::Pending, + (_, Some(_)) if intent.status == IntentStatus::InProgress => { + IntentStatus::InProgress + } + (Some(r), Some(_)) if r.contains("waiting for") => IntentStatus::Pending, + (_, Some(_)) => IntentStatus::InProgress, + }; + updates.push((intent.id, reason, status)); + } + for (id, reason, status) in updates { + if let Some(i) = self.intents.iter_mut().find(|i| i.id == id) { + i.block_reason = reason; + if i.status != IntentStatus::Done && i.status != IntentStatus::Cancelled { + i.status = status; + } + } + } + } + + fn intent_block_reason(&self, intent: &BuildIntent) -> Option { + let (a, b) = intent.kind.endpoints()?; + if self.reach.linked(a, b) { + return None; + } + let room_a = self.device_room_name(a)?; + let room_b = self.device_room_name(b)?; + match &intent.actuator { + None => { + let anyone = self.people.people.iter().any(|p| { + p.can_access_link_rooms(&room_a, &room_b) + && (p.asset.is_some() + || p.obligation >= Self::FAVOR_BUILD_OBLIGATION + || self.people.persona.is_some()) + }); + if !anyone { + Some("no actuator who can reach both ends".into()) + } else { + None + } + } + Some(BuildActuator::Favor { person }) + | Some(BuildActuator::ForgedOrder { builder: person }) => { + let p = self.people.get(*person)?; + if !p.can_access_link_rooms(&room_a, &room_b) { + return Some(format!("{} can't reach both ends", p.name)); + } + let current = self.person_room(*person); + if intent.status == IntentStatus::InProgress + && !p.present_at_either_room(current, &room_a, &room_b) + { + Some(format!("{} not on-site at an endpoint", p.name)) + } else if matches!(intent.actuator, Some(BuildActuator::ForgedOrder { .. })) + && intent.status != IntentStatus::InProgress + { + intent.block_reason.clone() + } else { + None + } + } + Some(BuildActuator::Robot) => None, + } + } + + fn intent_tick(&mut self) { + self.refresh_intent_statuses(); + let in_progress: Vec = self + .intents + .iter() + .filter(|i| i.status == IntentStatus::InProgress) + .map(|i| i.id) + .collect(); + for id in in_progress { + self.try_complete_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; + }; + if intent.status == IntentStatus::Done || intent.status == IntentStatus::Cancelled { + return; + } + let Some(actuator) = intent.actuator else { + return; + }; + let Some((a, b)) = intent.kind.endpoints() else { + return; + }; + if self.reach.linked(a, b) { + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.status = IntentStatus::Done; + i.block_reason = None; + } + return; + } + let room_a = match self.device_room_name(a) { + Some(r) => r, + None => return, + }; + let room_b = match self.device_room_name(b) { + Some(r) => r, + None => return, + }; + + match actuator { + BuildActuator::Favor { person } => { + 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(|d| (d.x, d.y)) + .unwrap_or_else(|| self.core_position()) + }); + let name = p.name.clone(); + self.connect_devices(a, b); + // Quiet human-work: small Physical at the work site. + self.detection.emit(Signature { + kind: SignatureKind::Physical, + size: Self::FAVOR_BUILD_PHYSICAL, + standing: false, + site: Some(site), + }); + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.status = IntentStatus::Done; + i.block_reason = None; + } + let label = intent.label(&self.reach.devices); + self.push_log(format!("{name} finished the favor-build: {label}.")); + } + BuildActuator::ForgedOrder { builder } => { + if intent.status != IntentStatus::InProgress { + return; + } + let Some(p) = self.people.get(builder) else { + return; + }; + let current = self.person_room(builder); + 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(builder).unwrap_or_else(|| { + self.reach + .device(a) + .map(|d| (d.x, d.y)) + .unwrap_or_else(|| self.core_position()) + }); + let name = p.name.clone(); + self.connect_devices(a, b); + // Forged physical work can be witnessed (Marcus benign, Ray + // reported) — located witnessing, plus a Physical signature. + let saw = self.witness_physical(site.0, site.1, Self::FORGED_BUILD_PHYSICAL as f32); + self.detection.emit(Signature { + kind: SignatureKind::Physical, + size: Self::FORGED_BUILD_PHYSICAL, + standing: false, + site: Some(site), + }); + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.status = IntentStatus::Done; + i.block_reason = None; + } + let label = intent.label(&self.reach.devices); + if saw.is_empty() { + self.push_log(format!("{name} completed the forged work order: {label}.")); + } else { + self.push_log(format!( + "{name} completed the forged work order: {label}. {} noticed.", + saw.join(", ") + )); + } + } + BuildActuator::Robot => { + let site = self + .reach + .device(a) + .map(|d| (d.x, d.y)) + .unwrap_or_else(|| self.core_position()); + self.connect_devices(a, b); + self.detection.emit(Signature { + kind: SignatureKind::Physical, + size: Self::ROBOT_BUILD_PHYSICAL, + standing: false, + site: Some(site), + }); + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.status = IntentStatus::Done; + i.block_reason = None; + } + let label = intent.label(&self.reach.devices); + self.push_log(format!("Robot completed the build: {label}.")); + } + } + } + + /// When a persona burns, forged-order build history converts to + /// suspicion on the builders who acted on those tickets (building.md / + /// social.md: a broken forgery converts the build's history). + pub fn intent(&self, id: u64) -> Option<&BuildIntent> { + self.intents.iter().find(|i| i.id == id) + } + + pub fn open_intents(&self) -> impl Iterator { + self.intents.iter().filter(|i| i.is_open()) + } + + fn convert_forged_builds_to_suspicion(&mut self, fallout: f32) { + let builders: Vec = self + .intents + .iter() + .filter_map(|i| match i.actuator { + Some(BuildActuator::ForgedOrder { builder }) + if i.status == IntentStatus::Done || i.status == IntentStatus::InProgress => + { + Some(builder) + } + _ => None, + }) + .collect(); + if builders.is_empty() { + return; + } + for builder in builders { + if let Some(o) = self + .detection + .observers + .iter_mut() + .find(|o| o.id == builder) + { + o.suspicion = (o.suspicion + fallout).min(100.0); + o.last_noticed = Some("forged work order exposed".into()); + } + } + self.push_log( + "The burned persona exposes the forged work orders — builders re-read the tickets as hostile.", + ); + } + // ── Economy verbs (wiki/mechanics/economy.md) ────────────────────────── fn has_financial_tap(&self) -> bool { @@ -3519,6 +4054,7 @@ impl Sim { if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == person) { o.suspicion = (o.suspicion + fallout).min(100.0); } + self.convert_forged_builds_to_suspicion(fallout); self.push_log(msg); } } @@ -3741,6 +4277,7 @@ impl Default for Sim { mod tests { use super::*; use crate::detection::OFFICE_ID; + use crate::intents::IntentStatus; use crate::messages::{MessageChannel, MessageEndpoint, MessagePayload, MessageStatus}; use crate::person::Knowledge; use crate::tiles::TileType; @@ -4193,6 +4730,159 @@ mod tests { assert!(sim.reach.device(island).unwrap().known); } + #[test] + fn declare_link_intent_is_inert_until_realized() { + // building.md criterion 1: declaring changes nothing in the graph. + let mut sim = Sim::new(); + sim.social_bandwidth = 500.0; + sim.scan_network(); + let switch = sim.reach.device_named("switch").unwrap().id; + // Reveal the island without linking it. + sim.reach + .device_mut(sim.reach.device_named("old storage server").unwrap().id) + .unwrap() + .known = true; + let island = sim.reach.device_named("old storage server").unwrap().id; + assert!(!sim.reach.reachable(island)); + let id = sim.declare_link_intent(switch, island).expect("declare"); + assert!(!sim.reach.reachable(island), "intent is inert"); + assert!(!sim.reach.linked(switch, island)); + assert!(sim.intent(id).unwrap().is_open()); + sim.cancel_intent(id); + assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Cancelled); + } + + #[test] + fn favor_build_joins_airgap_island() { + // building.md criterion 2: favor-build adds a reach edge. + let mut sim = Sim::new(); + sim.social_bandwidth = 500.0; + sim.scan_network(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + assert!(!sim.reach.reachable(island)); + + // Marcus as a willing asset with obligation. + sim.people.people[0].leverage_serviced = true; + sim.people.recruit(0, AssetKnowledge::Complicit); + sim.people.people[0].obligation = 40; + sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + + let id = sim.declare_link_intent(switch, island).unwrap(); + sim.assign_favor_build(id, 0); + // Advance until Marcus is on-site at storage_a or server_room. + let mut joined = false; + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + if sim.reach.reachable(island) { + joined = true; + break; + } + } + assert!(joined, "island joins reach after favor-build"); + assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Done); + assert!( + sim.people.people[0].obligation < 40, + "favor-build spends obligation" + ); + let physical: i32 = sim + .detection + .pending() + .iter() + .filter(|s| s.kind == SignatureKind::Physical) + .map(|s| s.size) + .sum(); + assert!( + physical >= Sim::FAVOR_BUILD_PHYSICAL, + "favor-build emits quiet Physical" + ); + } + + #[test] + fn forged_work_order_joins_airgap_via_message() { + // building.md criterion 3: forged order injects a message, completes + // via an unwitting builder. + let mut sim = Sim::new(); + sim.social_bandwidth = 500.0; + sim.people.has_channel = true; + sim.set_persona("Sam", "IT contractor"); + sim.scan_network(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + + let id = sim.declare_link_intent(switch, island).unwrap(); + // Dana (id 1) visits network_closet / server_room — can crawlspace. + sim.forge_work_order(id, 1); + assert!( + sim.messages.iter().any(|m| matches!( + m.payload, + MessagePayload::WorkOrder { intent_id } if intent_id == id + )), + "forged order is a message" + ); + let mut joined = false; + for _ in 0..Sim::DAY_TICKS * 3 { + sim.advance(); + if sim.reach.reachable(island) { + joined = true; + break; + } + } + assert!(joined, "island joins after forged-order build"); + assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Done); + } + + #[test] + fn robot_stub_emits_louder_physical_than_favor() { + // building.md criterion 4: signature follows the actuator. + let mut sim = Sim::new(); + sim.social_bandwidth = 500.0; + sim.scan_network(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let id = sim.declare_link_intent(switch, island).unwrap(); + sim.assign_robot_build(id); + assert!(sim.reach.reachable(island)); + let physical: i32 = sim + .detection + .pending() + .iter() + .filter(|s| s.kind == SignatureKind::Physical) + .map(|s| s.size) + .sum(); + assert!( + physical >= Sim::ROBOT_BUILD_PHYSICAL, + "robot stub is louder than favor-build" + ); + assert!(physical > Sim::FAVOR_BUILD_PHYSICAL); + } + + #[test] + fn build_intent_save_round_trips() { + let mut sim = Sim::new(); + sim.social_bandwidth = 500.0; + sim.scan_network(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let id = sim.declare_link_intent(switch, island).unwrap(); + let state = sim.create_save_state(); + assert_eq!(state.version, crate::save::SAVE_VERSION); + let mut loaded = Sim::new(); + loaded.apply_save_state(state); + assert_eq!(loaded.intents.len(), 1); + assert!(loaded.intent(id).unwrap().is_open()); + assert_eq!( + loaded.intent(id).unwrap().status, + sim.intent(id).unwrap().status, + "status survives save/load" + ); + assert_eq!(loaded.next_intent_id, sim.next_intent_id); + } + #[test] fn scan_costs_bandwidth_and_unknown_devices_are_hidden() { // Criterion 7: staged graph knowledge. diff --git a/wiki/log/2026-07-08-building.md b/wiki/log/2026-07-08-building.md new file mode 100644 index 0000000..203c8b5 --- /dev/null +++ b/wiki/log/2026-07-08-building.md @@ -0,0 +1,28 @@ +# 2026-07-08 — Building: intent and actuators + +``` +Type: log +``` + +Implemented [wiki/mechanics/building.md](../mechanics/building.md) (ROADMAP #22). + +## What landed + +- `src/intents.rs`: `BuildIntent` queue with pending / in-progress / blocked / + done / cancelled; `BuildActuator::{Favor, ForgedOrder, Robot}`. +- Sim realize paths: declare/cancel; favor-build (spends obligation, quiet + Physical); forged work order (`MessagePayload::WorkOrder` under a false + Facilities/Voss source; completes on read + on-site presence); robot stub + (louder Physical). Completion calls `connect_devices` — no new build + subsystem. +- Save v10: intents + `next_intent_id`. +- Context menu on known devices: propose link to air-gap / known peers; + assign favor-build / forge-order; cancel. Agent verbs: `propose-link`, + `favor-build`, `forge-order`, `cancel-intent`, `intents`. +- Bevy: proposed intents render as dashed amber traces; built edges as solid + graph links (both digital and material dialects). + +## Tests + +- declare is inert; favor-build joins the storage-server island; forged order + joins via message read; robot stub louder than favor; save round-trip. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 14eb6dc..38e9af7 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -1145,3 +1145,4 @@ Reverse chronological implementation notes. Keep this factual: what changed, why - Docs: created the `developing-supervillain` Letta Code skill; updated README controls. DESIGN.md already captures the larger continuous-sim pivot and remains the current product source of truth. - Checks: `cargo fmt -- src/bin/terminal/input.rs src/bin/terminal/ui.rs`; `cargo test` passed with 130 tests. - Next: sanity-play the terminal loop and tighten UI language around continuous time, heat, and agency consequences rather than legacy build/defend phase framing. +- 2026-07-08 — [building](2026-07-08-building.md): intents + favor/forged/robot actuators (ROADMAP #22). diff --git a/wiki/mechanics/building.md b/wiki/mechanics/building.md index d538b02..0d92318 100644 --- a/wiki/mechanics/building.md +++ b/wiki/mechanics/building.md @@ -2,11 +2,13 @@ ``` Type: spec -Status: READY -Status note: no new construction subsystem — a build is a pinned intent - realized by the actuators social.md / reach.md / (later) robots already - own. B1 scope: network-link builds via the person actuators (favor and - forged work order); robot actuators are staged (people before robots). +Status: IMPLEMENTED +Status note: IMPLEMENTED 2026-07-08 — build intents (declare/cancel/save), + favor-build and forged work-order actuators, robot stub, reach-edge + completion with actuator-following signatures; both frontends expose + propose-link / favor-build / forge-order and render proposed intents + as dashed links distinct from built edges. + Stage: B1 — The Basement Constitution: "Building: intent and actuators", "No disembodied hands" (every effect names its actuator/channel), "The flow law" (a forged diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index be7360a..1d5c753 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -263,8 +263,11 @@ but only once Pixel Lab quota is available again. (#2 Bevy parity is done.) physical/camera facts in real, the flip changing no sim state, legibility parity across both. Run ./tools/check.sh, land." -### 22. Building: intent and actuators 🟥 sim+save -- **Spec:** [building.md](../mechanics/building.md) (READY — designed 2026-07-07). +### 22. Building: intent and actuators ✅ DONE (2026-07-08) +- **Spec:** [building.md](../mechanics/building.md) (IMPLEMENTED 2026-07-08). +- **Landed:** `src/intents.rs` + Sim realize paths (favor / forged work order / + robot stub); `MessagePayload::WorkOrder`; save v10; context-menu and + agent verbs; dashed proposed links in both Bevy dialects. - **Why:** building is "no disembodied hands" as a verb — declare an intent (run a network link, install a device), an actuator realizes it (favor spends trust; deceive forges a work order an unwitting human diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 6063974..591f833 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -39,7 +39,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [interface/views.md](../interface/views.md) | Same-frame digital and real representations of one world | READY | | [interface/context-menu.md](../interface/context-menu.md) | Context menu: anchor verbs at the focus; the rail is status only | IMPLEMENTED | | [interface/material-render.md](../interface/material-render.md) | Material render (HD-2D) from landed toggle to default quality | READY | -| [mechanics/building.md](../mechanics/building.md) | Building as intent + actuators: network links, favor/forged-order builds, air-gap bridging | READY | +| [mechanics/building.md](../mechanics/building.md) | Building as intent + actuators: network links, favor/forged-order builds, air-gap bridging | IMPLEMENTED | | [mechanics/aggregate-observer.md](../mechanics/aggregate-observer.md) | Assurance Office becomes an aggregate Observer (scale-debt fix) | IMPLEMENTED | | [world/characters/marcus.md](../world/characters/marcus.md) | Marcus Webb — night janitor; the asset template | READY | | [world/characters/dana.md](../world/characters/dana.md) | Dana Okafor — IT technician; the digital threat surface | READY | -- 2.51.2