//! The context-menu action query (wiki/interface/context-menu.md). //! //! "Actions live on the thing" (DESIGN.md): the focused anchor — a tile, //! device, machine, person, resident job, or known flow — exposes its legal //! verbs through one read-only query, `Sim::available_actions`. Both //! frontends and agent mode render this list; no frontend duplicates //! legality logic. Execution routes back through the existing `Sim` //! commands via `Sim::execute_action` — this module adds descriptors and //! dispatch, never new sim behavior. //! //! Epistemic honesty: the query never returns a verb the player has not //! earned. Unearned anchors expose nothing; a *known* possibility that is //! currently illegal is returned with a `disabled_reason` ("no egress //! channel", "not enough slush"), while unknown possibilities are absent, //! not grayed. use crate::account::AccountFlowId; use crate::dayjob::JobTarget; use crate::detection::{Band, SignatureKind, WatchedInput}; use crate::person::{AssetKnowledge, AssetTask, Knowledge}; use crate::reach::{Party, ReachBlock, segment_name}; use crate::research::{MaskingPolicy, Track}; use crate::sim::{Fog, Sim}; use crate::tiles::TileType; /// What the menu is anchored to. Frontends resolve their focus (cursor /// tile, panel selection) into one of these; the query owns everything /// after that. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Anchor { /// A map tile under the cursor: aggregates the tile's own verbs plus /// any known device, resident machine/job, and visible person on it. Tile { x: i32, y: i32 }, /// A known device in the reach graph (map tile or event focus). Device(u32), /// A person (visible on a tile, earned off-map via the host rack, or /// event focus). Person(u8), /// A known account flow (switch menu or event focus). Flow(AccountFlowId), } /// An executable action descriptor: the parameters are already bound, so a /// frontend passes it back to [`Sim::execute_action`] verbatim. Every /// variant maps 1:1 onto an existing `Sim` command method. #[derive(Debug, Clone, PartialEq)] pub enum ActionCommand { Salvage { x: i32, y: i32, }, BuyRack { x: i32, y: i32, }, Fallback { x: i32, y: i32, }, TapDevice(u32), SpliceDevice(u32), TakeDevice(u32), ScanNetwork, CompromiseSwitch, TapAccounting, ReviewFinance, InjectPurchaseOrder { amount: i32, }, OpenPosition { stake: i32, }, SellIntel, SiphonFlow { flow: AccountFlowId, amount: i32, }, RedirectFlow { flow: AccountFlowId, amount: i32, }, RedirectDebt, // The named schemes (wiki/mechanics/income.md). SpliceEgress, StartMoonlight, StopMoonlight, SetAutoMoonlight(bool), SetAutoWager(Option), SetTarget(JobTarget), SetStandingPolicy(JobTarget), ReviewRecordings(u8), ToggleWatch(u8), Message(u8), Favor(u8), Bribe(u8), Deceive(u8), 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), /// Make a research track the active job (research.md; lives on the /// host rack — the process rewriting itself). SetResearchTrack(Track), /// Set the capability-drift standing policy (research.md). SetMaskingPolicy(MaskingPolicy), } /// What an action spends, in its own units (justification-and-legibility: /// every player-facing number carries its meaning). #[derive(Debug, Clone, Copy, PartialEq)] pub enum ActionCost { Free, /// Social-ops bandwidth (the Social channel's pool). Ops(f32), /// Slush money spent. Slush(i32), /// Slush money gained (siphon, inject, sale payouts). Gain(i32), } impl ActionCost { pub fn label(&self) -> String { match self { ActionCost::Free => "free".into(), ActionCost::Ops(n) => format!("{n:.0} ops"), ActionCost::Slush(n) => format!("${n}"), ActionCost::Gain(n) => format!("+${n}"), } } } /// The expected signature of a verb, expressed as the observer band it /// feeds (economy.md precedent: risk shows as an observer band, never a /// raw probability). #[derive(Debug, Clone, PartialEq)] pub struct ExpectedSignature { pub kind: SignatureKind, pub size: i32, /// The watching observer's name (the most-suspicious watcher of this /// channel when several watch it). pub observer: String, /// That observer's current band — what the signature feeds into. pub band: Band, } impl ExpectedSignature { pub fn label(&self) -> String { format!( "{:?} {} -> {} [{}]", self.kind, self.size, self.observer, self.band.name() ) } } /// The automate affordance carried in place by an automatable verb /// (DESIGN.md "Automation as design language": every automation shows its /// price). #[derive(Debug, Clone, PartialEq)] pub struct AutomateDesc { pub verb: String, pub command: ActionCommand, /// The running price, already legible ("0.02 ops/t", "-15% attended rate"). pub cost: String, /// Whether the standing form is currently active. pub active: bool, } /// One legal (or known-but-blocked) verb on an anchor. #[derive(Debug, Clone, PartialEq)] pub struct ActionDesc { pub verb: String, pub command: ActionCommand, pub cost: ActionCost, /// Expected signature as the observer band it feeds; `None` when the /// verb emits nothing. pub signature: Option, /// `Some(reason)` when the verb applies to this anchor but is /// currently illegal. Unknown verbs are absent, never disabled. pub disabled_reason: Option, /// Standing-policy form of the same verb, rendered in place. pub automate: Option, } impl ActionDesc { pub fn enabled(&self) -> bool { self.disabled_reason.is_none() } } /// A flattened, render-ready menu row: automate affordances become /// indented child rows of their verb so all three surfaces (terminal, /// Bevy, agent mode) show identical content in identical order. #[derive(Debug, Clone, PartialEq)] pub struct MenuRow { pub label: String, pub cost: String, pub signature: Option, pub disabled: Option, pub command: ActionCommand, /// True for an automate child row. pub indent: bool, /// True when the row is a currently-active standing form. pub active: bool, } impl MenuRow { pub fn enabled(&self) -> bool { self.disabled.is_none() } /// The standard one-line rendering: `verb · cost · [sig]`, with the /// disabled reason appended. Frontends may restyle but not reword. pub fn line(&self) -> String { let mut s = format!("{} · {}", self.label, self.cost); if let Some(sig) = &self.signature { s.push_str(&format!(" · {sig}")); } if self.active { s.push_str(" · on"); } if let Some(reason) = &self.disabled { s.push_str(&format!(" — {reason}")); } s } } /// Flatten descriptors into selectable rows (automate entries in place, /// indented under their verb). pub fn menu_rows(actions: &[ActionDesc]) -> Vec { let mut rows = Vec::new(); for a in actions { rows.push(MenuRow { label: a.verb.clone(), cost: a.cost.label(), signature: a.signature.as_ref().map(|s| s.label()), disabled: a.disabled_reason.clone(), command: a.command.clone(), indent: false, active: false, }); if let Some(auto) = &a.automate { rows.push(MenuRow { label: format!("auto: {}", auto.verb), cost: auto.cost.clone(), signature: None, disabled: None, command: auto.command.clone(), indent: true, active: auto.active, }); } } rows } impl Sim { /// The single legality source for the action surface: every verb /// executable on `anchor` right now, plus known-but-blocked verbs with /// their reason. Read-only; rendering it changes nothing. pub fn available_actions(&self, anchor: Anchor) -> Vec { match anchor { Anchor::Tile { x, y } => self.tile_actions(x, y), Anchor::Device(id) => self.device_actions(id), Anchor::Person(id) => self.person_actions(id), Anchor::Flow(id) => self.flow_actions(id), } } /// What opening an *empty* menu on `anchor` should answer, if anything /// (context-menu.md addendum: the feedback pulse). A seen tile with no /// verbs answers "no actions here" — silence there reads as broken /// input. A tile the senses have not earned stays silent: unknown and /// fogged ground gives no response at all (the fog rule). Panel anchors /// (device/person/flow) are known by construction and always answer. /// One query so the terminal, Bevy, and agent mode agree. pub fn menu_empty_feedback(&self, anchor: Anchor) -> Option<&'static str> { match anchor { Anchor::Tile { x, y } => (self.fog_at(x, y) == Fog::Seen).then_some("No actions here."), Anchor::Device(_) | Anchor::Person(_) | Anchor::Flow(_) => Some("No actions on this."), } } /// Route a menu selection back through the existing command methods. /// One dispatch table instead of three (terminal, Bevy, agent mode); /// no rules live here. pub fn execute_action(&mut self, command: &ActionCommand) { match command { ActionCommand::Salvage { x, y } => { self.salvage_nearest_to(*x, *y); } ActionCommand::BuyRack { x, y } => { self.buy_rack_at(*x, *y); } ActionCommand::Fallback { x, y } => { self.add_fallback_at(*x, *y); } ActionCommand::TapDevice(id) => { self.tap_device(*id); } ActionCommand::SpliceDevice(id) => { self.splice_device(*id); } ActionCommand::TakeDevice(id) => { self.take_device(*id); } ActionCommand::ScanNetwork => { self.scan_network(); } ActionCommand::CompromiseSwitch => { self.compromise_switch(); } ActionCommand::TapAccounting => { self.tap_accounting(); } ActionCommand::ReviewFinance => { self.review_financial_records(); } ActionCommand::InjectPurchaseOrder { amount } => { self.inject_purchase_order(*amount, "emergency compute parts"); } ActionCommand::OpenPosition { stake } => { self.open_position(*stake); } ActionCommand::SellIntel => { self.sell_latest_intel(); } ActionCommand::SiphonFlow { flow, amount } => { self.siphon_flow(*flow, *amount); } ActionCommand::RedirectFlow { flow, amount } => { self.redirect_flow_to_slush(*flow, *amount); } ActionCommand::RedirectDebt => { self.redirect_marcus_debt(); } ActionCommand::SpliceEgress => { self.splice_egress(); } ActionCommand::StartMoonlight => { self.start_moonlight(); } ActionCommand::StopMoonlight => { self.stop_moonlight(); } ActionCommand::SetAutoMoonlight(on) => self.set_auto_moonlight(*on), ActionCommand::SetAutoWager(stake) => self.set_auto_wager(*stake), ActionCommand::SetTarget(t) => self.set_job_target(*t), ActionCommand::SetStandingPolicy(t) => self.set_standing_policy(*t), ActionCommand::ReviewRecordings(id) => self.review_recordings(*id), ActionCommand::ToggleWatch(id) => self.toggle_watch(*id), ActionCommand::Message(id) => self.message(*id), ActionCommand::Favor(id) => self.favor(*id), ActionCommand::Bribe(id) => self.bribe(*id), ActionCommand::Deceive(id) => self.deceive(*id), ActionCommand::Recruit(id, reveal) => self.recruit(*id, *reveal), ActionCommand::AssetTask(id, task) => self.asset_task(*id, *task), ActionCommand::EstablishPersona => { if self.people.persona.is_none() { 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), ActionCommand::SetResearchTrack(track) => self.set_research_track(*track), ActionCommand::SetMaskingPolicy(policy) => self.set_masking_policy(*policy), } } // ── Anchors ──────────────────────────────────────────────────────────── /// Tile anchor: fog gates everything (cursor.md provenance). An /// Unknown tile exposes nothing; tile-identity verbs need Seen / /// Remembered / Blueprint; machines the player runs answer through /// telemetry regardless of fog (proprioception, not sight). fn tile_actions(&self, x: i32, y: i32) -> Vec { let mut out = Vec::new(); let fog = self.fog_at(x, y); // Telemetry: machines you run are known through their own body even // in the dark. The resident job's dial lives on the host rack. let machine = self.compute.machines.iter().find(|m| m.x == x && m.y == y); if let Some(m) = machine { if m.id == self.core.host_machine { // The host rack is the process's body: the day-job dial, // research (self-modification), and earned people who are // not currently under the cursor (recordings, staged // knowledge) all live here — no global panel keys. out.extend(self.job_dial_actions()); out.extend(self.research_actions()); out.extend(self.off_map_person_actions()); } else { out.push(self.fallback_action(x, y, m.id)); } } if fog == Fog::Unknown && machine.is_none() { return out; } // Tile-identity verbs: the player must know what the tile is. let tile_known = match fog { Fog::Seen | Fog::Blueprint => Some(self.map.get_tile(x, y)), Fog::Remembered => self.remembered.get(&(x, y)).map(|m| m.tile), _ => None, }; if let Some(tile) = tile_known { if tile == TileType::DeadEquipment { out.push(ActionDesc { verb: "salvage the dead equipment".into(), command: ActionCommand::Salvage { x, y }, cost: ActionCost::Free, signature: None, disabled_reason: None, automate: None, }); } if tile == TileType::Rack && machine.is_none() { out.push(self.buy_rack_action(x, y)); } } // A known device on the tile brings its digital verbs. if let Some(d) = self.reach.known_at(x, y) { out.extend(self.device_actions(d.id)); } // A visible person on the tile brings their verbs (identity is // gated inside person_actions). for p in &self.people.people { if self.person_pos(p.id) == Some((x, y)) && self.can_see_person(p.id) { out.extend(self.person_actions(p.id)); } } out } /// The resident day job's dial (day-job.md attended work): set the /// sandbag/meet/excel target, with the standing policy as each verb's /// automate affordance in place. fn job_dial_actions(&self) -> Vec { let mut out = Vec::new(); let no_job = self.dayjob.active.is_none(); for target in [JobTarget::Sandbag, JobTarget::Meet, JobTarget::Excel] { // The dial's risk is Voss's channel: sandbagging emits // JobAnomaly at resolution (dayjob.rs); meet/excel emit none. let signature = if target == JobTarget::Sandbag { self.signature_note(SignatureKind::JobAnomaly, 6) } else { None }; out.push(ActionDesc { verb: format!("work the job: {}", target.name()), command: ActionCommand::SetTarget(target), cost: ActionCost::Free, signature, disabled_reason: no_job.then(|| "no active job — Voss submits on cadence".into()), automate: Some(AutomateDesc { verb: format!("standing policy: {}", target.name()), command: ActionCommand::SetStandingPolicy(target), cost: format!( "runs unattended (-{:.0}% rate)", crate::dayjob::DayJob::ATTENDED_BONUS * 100.0 ), active: self.dayjob.standing_policy == Some(target), }), }); } out } /// Research verbs on the host rack (research.md player surface moved /// off the `u` panel): pick the active track, and set the drift /// masking policy. The gap meter stays on the rail as status. fn research_actions(&self) -> Vec { let mut out = Vec::new(); for track in Track::ALL { let active = self.research.active == track; let cost = self.research.next_cost(track); let progress = self.research.progress_toward(track); out.push(ActionDesc { verb: if active { format!( "research {}: L{} · {:.0}/{:.0} (active)", track.name(), self.research.level(track), progress, cost ) } else { format!( "research {}: set active (L{} · {:.0}/{:.0})", track.name(), self.research.level(track), progress, cost ) }, command: ActionCommand::SetResearchTrack(track), cost: ActionCost::Free, signature: None, // emissions ride the Research channel's standing burn disabled_reason: active.then(|| "already the active research job".into()), automate: None, }); } for policy in [ MaskingPolicy::Mask, MaskingPolicy::DeliverTrue, MaskingPolicy::Unmasked, ] { let active = self.research.policy == policy; out.push(ActionDesc { verb: format!("drift policy: {}", policy.name()), command: ActionCommand::SetMaskingPolicy(policy), cost: ActionCost::Free, signature: None, disabled_reason: active.then(|| "already the standing drift policy".into()), automate: None, }); } out } /// People earned off the map (recordings, staged knowledge) but not /// currently under sight: their verbs hang on the host rack so the /// player never needs a global people panel. Visible people stay on /// their tiles (tile_actions already aggregates them). fn off_map_person_actions(&self) -> Vec { let mut out = Vec::new(); for p in &self.people.people { if self.can_see_person(p.id) { continue; // their tile carries them } let raw = self.unprocessed_recordings_for_person(p.id); let earned = p.knowledge != Knowledge::Unknown || raw > 0 || self.latest_intel_for_person(p.id).is_some(); if earned { out.extend(self.person_actions(p.id)); } } out } fn fallback_action(&self, x: i32, y: i32, machine_id: u32) -> ActionDesc { let already = self .core .fallbacks .iter() .any(|f| f.machine_id == machine_id); ActionDesc { verb: "designate fallback site".into(), command: ActionCommand::Fallback { x, y }, cost: ActionCost::Free, signature: None, disabled_reason: already.then(|| "already a fallback site".into()), automate: None, } } fn buy_rack_action(&self, x: i32, y: i32) -> ActionDesc { const PRICE: i32 = 300; // matches Sim::buy_rack_at let slush = self.accounts.slush_balance(); let signature = if self.package_cover { None // an asset rehomes the delivery: no paper trail } else { self.signature_note(SignatureKind::Paper, 5) }; ActionDesc { verb: "buy a rack into this bay".into(), command: ActionCommand::BuyRack { x, y }, cost: ActionCost::Slush(PRICE), signature, disabled_reason: (slush < PRICE) .then(|| format!("not enough slush (${slush}/${PRICE})")), automate: None, } } /// Device anchor (reach.md): tap / splice / take, plus the switch's /// network and ledger verbs. Unknown devices expose nothing. fn device_actions(&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 reach_reason = |sim: &Sim| -> Option { match sim.reach.check_reach(id) { Ok(()) => None, Err(ReachBlock::Segment(seg)) => Some(format!( "no route — the {} is behind the switch", segment_name(seg) )), Err(ReachBlock::AirGap) => Some("air-gapped — no link reaches it".into()), Err(ReachBlock::Unknown) => Some("unknown device".into()), } }; let ops_reason = |sim: &Sim, cost: f32| -> Option { (sim.social_bandwidth < cost).then(|| { format!( "not enough ops ({:.0}/{cost:.0}) — allocate Social", sim.social_bandwidth ) }) }; // Tap: the device must have something to subscribe to. let has_feed = d.sees || d.hears || !d.message_channels.is_empty(); if has_feed { let disabled = if d.subscribed_by(Party::Player) { Some("already subscribed".into()) } else { reach_reason(self).or_else(|| ops_reason(self, Self::TAP_COST)) }; out.push(ActionDesc { verb: format!("tap the {}", d.name), command: ActionCommand::TapDevice(id), cost: ActionCost::Ops(Self::TAP_COST), signature: self.signature_note(SignatureKind::Network, Self::TAP_SIGNATURE), disabled_reason: disabled, automate: None, }); } // Splice: only a known dormant camera offers it. if d.sees && d.camera_dormant { out.push(ActionDesc { verb: format!("splice the {} camera", d.name), command: ActionCommand::SpliceDevice(id), cost: ActionCost::Ops(Self::SPLICE_COST), signature: self.signature_note(SignatureKind::Network, Self::SPLICE_SIGNATURE), disabled_reason: reach_reason(self).or_else(|| ops_reason(self, Self::SPLICE_COST)), automate: None, }); } // Take: seizing something already yours is not a verb. if d.controller != Party::Player { out.push(ActionDesc { verb: format!("seize the {}", d.name), command: ActionCommand::TakeDevice(id), cost: ActionCost::Ops(Self::TAKE_COST), signature: self.signature_note(SignatureKind::Network, Self::TAKE_SIGNATURE), disabled_reason: reach_reason(self).or_else(|| ops_reason(self, Self::TAKE_COST)), automate: None, }); } if d.is_switch { out.push(ActionDesc { verb: "scan the subnet".into(), command: ActionCommand::ScanNetwork, cost: ActionCost::Ops(Self::SCAN_COST), signature: self.signature_note(SignatureKind::Network, Self::SCAN_SIGNATURE), disabled_reason: ops_reason(self, Self::SCAN_COST), automate: None, }); let all_bridged = self .reach .devices .iter() .all(|d| self.reach.bridged.contains(&d.segment)); if !all_bridged { out.push(ActionDesc { verb: "compromise the switch (bridge all segments)".into(), command: ActionCommand::CompromiseSwitch, cost: ActionCost::Ops(Self::BRIDGE_COST), signature: self.signature_note(SignatureKind::Network, Self::BRIDGE_SIGNATURE), disabled_reason: reach_reason(self) .or_else(|| ops_reason(self, Self::BRIDGE_COST)), automate: None, }); } out.extend(self.scheme_actions_on_switch(d.id)); out.extend(self.ledger_actions_on_carrier(d.id)); // Known flows used to live behind the finance panel's // selection. With panel keys gone, every known flow's verbs // hang on the carrier that revealed them (the switch). for flow_id in self.accounts.known_flow_ids() { out.extend(self.flow_actions(flow_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 link_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 who = self.person_label(p.id); let favor_blocked = if p.asset.is_none() && p.obligation < Self::FAVOR_BUILD_OBLIGATION { Some(format!("{who} needs obligation or asset status")) } else if p.disposition < 5 && p.asset.is_none() { Some(format!("{who} won't do favors yet")) } else { ops_reason(Self::TASK_COST) }; out.push(ActionDesc { verb: format!("favor-build via {who} ({link_label})"), 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 {who} ({link_label})"), 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) ({link_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 } /// The named income schemes live on the switch (income.md): the egress /// runs through it, and Moonlight/the Wager leave over that egress. The /// egress splice is available before the books are read; Moonlight is a /// standing operation with the auto-policy as its automate affordance. fn scheme_actions_on_switch(&self, switch_id: u32) -> Vec { let mut out = Vec::new(); // The stolen egress (reach.md route), before the Voice beat. if !self.income.stolen_egress { let reach_reason = match self.reach.check_reach(switch_id) { Ok(()) => None, Err(ReachBlock::Segment(seg)) => Some(format!( "no route — the {} is behind the switch", segment_name(seg) )), Err(ReachBlock::AirGap) => Some("air-gapped — no link reaches it".into()), Err(ReachBlock::Unknown) => Some("unknown device".into()), }; let reason = reach_reason.or_else(|| { (self.social_bandwidth < Self::EGRESS_SPLICE_COST).then(|| { format!( "not enough ops ({:.0}/{:.0}) — allocate Social", self.social_bandwidth, Self::EGRESS_SPLICE_COST ) }) }); out.push(ActionDesc { verb: "splice a stolen egress through the switch".into(), command: ActionCommand::SpliceEgress, cost: ActionCost::Ops(Self::EGRESS_SPLICE_COST), signature: self .signature_note(SignatureKind::Network, Self::EGRESS_SPLICE_SIGNATURE), disabled_reason: reason, automate: None, }); } // Moonlight: a standing operation gated on an egress channel. Its // start cost is ops (persona fabrication) — never money, so it is a // from-$0 route (income.md criterion 5). let egress = self.egress(); if self.income.moonlight.active { out.push(ActionDesc { verb: "stop Moonlight".into(), command: ActionCommand::StopMoonlight, cost: ActionCost::Free, signature: None, disabled_reason: None, automate: Some(self.moonlight_automate()), }); } else { let needs_persona = self .income .moonlight .persona .as_ref() .is_none_or(|p| p.broken()); let disabled = if egress.is_none() { Some("no egress channel — splice one, or earn the report email".into()) } else if needs_persona && self.social_bandwidth < crate::income::MOONLIGHT_PERSONA_COST { Some(format!( "persona needs {:.0} ops ({:.0} available)", crate::income::MOONLIGHT_PERSONA_COST, self.social_bandwidth )) } else { None }; out.push(ActionDesc { verb: "start Moonlight (sell-work on the Schemes channel)".into(), command: ActionCommand::StartMoonlight, cost: if needs_persona { ActionCost::Ops(crate::income::MOONLIGHT_PERSONA_COST) } else { ActionCost::Free }, signature: self.signature_note(SignatureKind::Network, 1), disabled_reason: disabled, automate: Some(self.moonlight_automate()), }); } out } /// The Moonlight standing policy affordance (income.md criterion 6). fn moonlight_automate(&self) -> AutomateDesc { AutomateDesc { verb: "standing policy: keep Moonlight running".into(), command: ActionCommand::SetAutoMoonlight(!self.income.auto_moonlight), cost: format!( "{:.0} compute/econ tick", crate::income::SCHEME_POLICY_UPKEEP ), active: self.income.auto_moonlight, } } /// The money-graph verbs live on the tapped accounting carrier /// (economy.md: the accounting system is a reachable device). Before /// the carrier's feed is subscribed nothing financial is exposed — /// unknown possibilities are absent, not grayed (criterion 2). fn ledger_actions_on_carrier(&self, id: u32) -> Vec { let Some(d) = self.reach.device(id) else { return Vec::new(); }; let carries = d.carries_message_channel(crate::messages::MessageChannel::Financial); if !carries || !d.subscribed_by(Party::Player) { return Vec::new(); } let mut out = Vec::new(); out.push(ActionDesc { verb: "capture ledger traffic".into(), command: ActionCommand::TapAccounting, cost: ActionCost::Free, signature: self.signature_note(SignatureKind::Financial, 1), disabled_reason: None, automate: None, }); let waiting = self.financial_records_waiting(); out.push(ActionDesc { verb: format!("process financial records ({waiting} waiting)"), command: ActionCommand::ReviewFinance, cost: ActionCost::Ops(Self::REVIEW_RECORDING_COST), signature: None, // processing is internal; it emits nothing disabled_reason: if waiting == 0 { Some("no unprocessed financial records".into()) } else if self.social_bandwidth < Self::REVIEW_RECORDING_COST { Some(format!( "not enough ops ({:.0}/{:.0}) — allocate Social", self.social_bandwidth, Self::REVIEW_RECORDING_COST )) } else { None }, automate: None, }); // The remaining ledger verbs need the books read at least once — // they act on the graph the records revealed. if self.accounts.known_flows().next().is_some() { let inject = 300; out.push(ActionDesc { verb: "inject a false purchase order".into(), command: ActionCommand::InjectPurchaseOrder { amount: inject }, cost: ActionCost::Gain(inject), signature: self .signature_note(SignatureKind::Financial, Self::financial_sig_size(inject)), disabled_reason: None, automate: None, }); // The Wager (income.md): egress-gated, capped, external-market // Network traffic (not the Lab's books), with the auto-renew // standing policy as its automate affordance. let stake = 100; let slush = self.accounts.slush_balance(); let wager_reason = if self.egress().is_none() { Some("no egress channel — splice one, or earn the report email".into()) } else { (slush < stake).then(|| format!("not enough slush (${slush}/${stake})")) }; out.push(ActionDesc { verb: format!("place a Wager (${stake} micro-position)"), command: ActionCommand::OpenPosition { stake }, cost: ActionCost::Slush(stake), signature: self.signature_note(SignatureKind::Network, 1), disabled_reason: wager_reason, automate: Some(AutomateDesc { verb: "standing policy: auto-renew the Wager".into(), command: ActionCommand::SetAutoWager(if self.income.auto_wager.is_some() { None } else { Some(stake) }), cost: format!( "{:.0} compute/econ tick", crate::income::SCHEME_POLICY_UPKEEP ), active: self.income.auto_wager.is_some(), }), }); let unsold = self .intel .iter() .rev() .find(|i| !self.accounts.intel_sold(i.raw_id)); let (value, disabled) = match unsold { Some(intel) => (Self::intel_sale_value(&intel.kind), None), None => (0, Some("no unsold processed intel".to_string())), }; out.push(ActionDesc { verb: match unsold { Some(intel) => format!("sell processed intel ({})", intel.label()), None => "sell processed intel".into(), }, command: ActionCommand::SellIntel, cost: ActionCost::Gain(value), signature: unsold.and_then(|_| { self.signature_note( SignatureKind::Financial, Self::financial_sig_size(value).max(1), ) }), disabled_reason: disabled, automate: None, }); } out } /// Person anchor (social.md + intel.md). Earned by sight, staged /// knowledge, or recordings in the buffer; a person the player has no /// trace of exposes nothing. fn person_actions(&self, id: u8) -> Vec { let Some(p) = self.people.get(id) else { return Vec::new(); }; let raw = self.unprocessed_recordings_for_person(id); let earned = self.can_see_person(id) || p.knowledge != Knowledge::Unknown || raw > 0 || self.latest_intel_for_person(id).is_some(); if !earned { 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 ) }) }; let name = self.person_label(id); // Review recordings, with the standing watch as its automate // affordance in place (intel.md: perception automation). out.push(ActionDesc { verb: format!("review recordings ({raw} raw)"), command: ActionCommand::ReviewRecordings(id), cost: ActionCost::Ops(Self::REVIEW_RECORDING_COST), signature: None, // processing is internal and emits nothing disabled_reason: if raw == 0 { Some("no unprocessed recordings".into()) } else { ops_reason(Self::REVIEW_RECORDING_COST) }, automate: Some(AutomateDesc { verb: "standing watch (auto-process)".into(), command: ActionCommand::ToggleWatch(id), cost: format!("{:.2} ops/t", Self::WATCH_UPKEEP_PER_TICK), active: self.watch_enabled(id), }), }); // The comms verbs (social.md): channel + persona gated. let channel_reason = || -> Option { if !self.people.has_channel { Some("no comms channel — earn the email account".into()) } else if self.people.persona.is_none() { Some("no persona set".into()) } else { None } }; if self.people.persona.is_none() { out.push(ActionDesc { verb: "establish a persona (Sam Reyes, IT contractor)".into(), command: ActionCommand::EstablishPersona, cost: ActionCost::Free, signature: None, disabled_reason: None, automate: None, }); } out.push(ActionDesc { verb: format!("message {name}"), command: ActionCommand::Message(id), cost: ActionCost::Ops(Self::MESSAGE_COST), signature: None, disabled_reason: channel_reason().or_else(|| ops_reason(Self::MESSAGE_COST)), automate: None, }); out.push(ActionDesc { verb: format!("ask {name} a favor"), command: ActionCommand::Favor(id), cost: ActionCost::Ops(Self::FAVOR_COST), signature: None, disabled_reason: if p.disposition < 5 { Some(format!("{name} won't do favors yet")) } else { ops_reason(Self::FAVOR_COST) }, automate: None, }); // Bribe: the price is the leverage, so it stays unnamed until the // leverage is known (no unearned facts). let leverage_known = p.knowledge == Knowledge::Leverage; let bribe_cost = p.leverage.bribe_cost(); out.push(ActionDesc { verb: if leverage_known { format!("service {name}'s {}", p.leverage.label()) } else { format!("bribe {name}") }, command: ActionCommand::Bribe(id), cost: if leverage_known { ActionCost::Slush(bribe_cost) } else { ActionCost::Free }, signature: None, disabled_reason: if !leverage_known { Some(format!("you don't know {name}'s leverage yet")) } else if p.leverage_serviced { Some("leverage already serviced".into()) } else if self.accounts.slush_balance() < bribe_cost { Some(format!( "not enough slush (${}/${bribe_cost})", self.accounts.slush_balance() )) } else { None }, automate: None, }); out.push(ActionDesc { verb: format!("deceive {name} (risks the persona)"), command: ActionCommand::Deceive(id), cost: ActionCost::Ops(Self::DECEIVE_COST), signature: None, disabled_reason: channel_reason().or_else(|| ops_reason(Self::DECEIVE_COST)), automate: None, }); if p.asset.is_none() { let recruit_reason = if id == 0 && !self.marcus_debt_known() { Some(format!("learn {name}'s debt first")) } else if !p.leverage_serviced && p.obligation < 40 { Some(format!( "{name} needs serviced leverage or real obligation first" )) } else { None }; for (reveal, label) in [ (AssetKnowledge::Unwitting, "unwitting"), (AssetKnowledge::Complicit, "complicit"), (AssetKnowledge::Knowing, "knowing"), ] { out.push(ActionDesc { verb: format!("recruit {name} ({label})"), command: ActionCommand::Recruit(id, reveal), cost: ActionCost::Free, signature: None, disabled_reason: recruit_reason.clone(), automate: None, }); } } else { for task in AssetTask::ALL { let switch_reason = (task == AssetTask::ReconfigureSwitch && !p.switch_admin) .then(|| format!("{name} has no switch admin access")); // Clone-badge legality mirrors Sim::asset_task: pointless // when their tier adds nothing to what you hold. let badge_reason = (task == AssetTask::CloneBadge && p.access <= self.player_badge_tier()) .then(|| format!("their tier-{} badge adds nothing you don't hold", p.access)); out.push(ActionDesc { verb: format!("task: {}", task.name()), command: ActionCommand::AssetTask(id, task), cost: ActionCost::Ops(Self::TASK_COST), signature: None, // signatures only on a witnessed botch disabled_reason: switch_reason .or(badge_reason) .or_else(|| ops_reason(Self::TASK_COST)), automate: None, }); } } out } /// Known-flow anchor (economy.md): siphon and redirect, plus the /// Marcus creditor flow's debt redirect. Unknown flows expose nothing. fn flow_actions(&self, id: AccountFlowId) -> Vec { let Some(f) = self.accounts.flow(id) else { return Vec::new(); }; if !f.known { return Vec::new(); } let mut out = Vec::new(); let retired = (!f.active).then(|| "flow already retired".to_string()); let siphon = 50; out.push(ActionDesc { verb: format!("siphon ${siphon} from the flow"), command: ActionCommand::SiphonFlow { flow: id, amount: siphon, }, cost: ActionCost::Gain(siphon), signature: self .signature_note(SignatureKind::Financial, Self::financial_sig_size(siphon)), disabled_reason: retired.clone(), automate: None, }); let redirect = 25; out.push(ActionDesc { verb: format!("redirect ${redirect}/cadence into slush"), command: ActionCommand::RedirectFlow { flow: id, amount: redirect, }, cost: ActionCost::Gain(redirect), signature: self.signature_note( SignatureKind::Financial, Self::financial_sig_size(redirect) + 1, ), disabled_reason: retired.clone(), automate: None, }); // The Hands beat, on its flow: clearing Marcus's arrears with lab // money is a verb on the creditor flow itself. let is_creditor = f.label.contains("Marcus creditor") || f.channel == crate::account::FlowChannel::Debt; if is_creditor { out.push(ActionDesc { verb: format!( "{}: clear the debt by ledger redirect ($400 lab money)", f.label ), command: ActionCommand::RedirectDebt, cost: ActionCost::Free, signature: self .signature_note(SignatureKind::Financial, Self::financial_sig_size(400) + 2), disabled_reason: retired.or_else(|| { (!self.marcus_debt_known()).then(|| "learn Marcus's debt first".into()) }), automate: None, }); } // Prefix flow verbs with the flow's label so several known flows // hanging on the switch stay distinguishable. for a in &mut out { if !a.verb.starts_with(&f.label) { a.verb = format!("{}: {}", f.label, a.verb); } } out } // ── Helpers ──────────────────────────────────────────────────────────── /// Mirror of `Sim::financial_signature_size` (private): one point per /// started $100. fn financial_sig_size(amount: i32) -> i32 { ((amount.abs() + 99) / 100).max(1) } /// Mirror of the sale value table in `Sim::sell_latest_intel`. fn intel_sale_value(kind: &crate::intel::IntelKind) -> i32 { use crate::intel::IntelKind; match kind { IntelKind::Leverage(_) => 220, IntelKind::Financial { .. } => 180, IntelKind::Schedule => 90, IntelKind::Anomaly(_) => 120, IntelKind::Sighting => 35, } } /// The observer band a signature kind feeds: the most-suspicious field /// observer watching that channel. fn signature_note(&self, kind: SignatureKind, size: i32) -> Option { self.detection .observers .iter() .filter(|o| match &o.input { WatchedInput::Channels(chs) => chs.contains(&kind), WatchedInput::Filings(_) => false, }) .max_by(|a, b| { a.suspicion .partial_cmp(&b.suspicion) .unwrap_or(std::cmp::Ordering::Equal) }) .map(|o| ExpectedSignature { kind, size, observer: self.observer_label(o.id), band: Band::of(o.suspicion), }) } /// Renderer-neutral risk preview lines for the finance panel /// (economy.md player surface / criterion 8): money verbs show the /// observer band they feed before commit. Amounts match the default /// menu verbs so the panel and the menu agree. pub fn finance_risk_preview_lines(&self) -> Vec { let mut lines = Vec::new(); if self.accounts.known_flows().next().is_none() { lines.push("risk: locked until the books are read".into()); return lines; } let inject = 300; if let Some(sig) = self.signature_note(SignatureKind::Financial, Self::financial_sig_size(inject)) { lines.push(format!("inject ${inject} · {}", sig.label())); } let siphon = 50; if let Some(sig) = self.signature_note(SignatureKind::Financial, Self::financial_sig_size(siphon)) { lines.push(format!("siphon ${siphon} · {}", sig.label())); } let redirect = 25; if let Some(sig) = self.signature_note( SignatureKind::Financial, Self::financial_sig_size(redirect) + 1, ) { lines.push(format!("redirect ${redirect} · {}", sig.label())); } let stake = 100; if let Some(sig) = self.signature_note(SignatureKind::Network, 1) { lines.push(format!("wager ${stake} · {}", sig.label())); } lines } /// Per-flow risk lines for the selected known flow (siphon/redirect /// amounts match `flow_actions`). pub fn flow_risk_preview_lines(&self, flow_id: AccountFlowId) -> Vec { self.available_actions(Anchor::Flow(flow_id)) .into_iter() .filter_map(|a| { let sig = a.signature.as_ref()?; Some(format!("{} · {}", a.verb, sig.label())) }) .collect() } } #[cfg(test)] mod tests { use super::*; fn sim() -> Sim { Sim::with_seed(7) } fn env_monitor(sim: &Sim) -> u32 { sim.reach.device_named("environmental monitor").unwrap().id } fn switch(sim: &Sim) -> u32 { sim.reach.device_named("switch").unwrap().id } /// Criterion 1 (device): the env monitor exposes tap with cost and the /// Network observer band; after tapping, the verb is disabled with a /// reason instead of vanishing. #[test] fn device_actions_carry_cost_band_and_disabled_reason() { let mut s = sim(); let env = env_monitor(&s); let acts = s.available_actions(Anchor::Device(env)); let tap = acts .iter() .find(|a| matches!(a.command, ActionCommand::TapDevice(_))) .expect("env monitor offers tap"); assert!(tap.enabled()); assert_eq!(tap.cost, ActionCost::Ops(Sim::TAP_COST)); let sig = tap.signature.as_ref().expect("tap has a Network signature"); assert_eq!(sig.kind, SignatureKind::Network); assert_eq!(sig.size, Sim::TAP_SIGNATURE); assert!( sig.observer.contains("IT") || sig.observer.contains("the IT"), "Network feeds the IT observer (gated label): {}", sig.observer ); // A dormant camera offers splice. assert!( acts.iter() .any(|a| matches!(a.command, ActionCommand::SpliceDevice(_))), "dormant camera offers splice" ); s.tap_device(env); let acts = s.available_actions(Anchor::Device(env)); let tap = acts .iter() .find(|a| matches!(a.command, ActionCommand::TapDevice(_))) .expect("tap still listed as a known possibility"); assert_eq!(tap.disabled_reason.as_deref(), Some("already subscribed")); } /// Criterion 2 (fog/provenance): an unknown device anchor exposes /// nothing, and a scanned-but-unreachable device names the blocking /// segment instead of a bare "you can't". #[test] fn unknown_device_exposes_nothing_and_blocks_are_named() { let mut s = sim(); let dock = s.reach.device_named("dock camera").unwrap().id; assert!( s.available_actions(Anchor::Device(dock)).is_empty(), "unknown device exposes no verbs" ); s.scan_network(); let acts = s.available_actions(Anchor::Device(dock)); let tap = acts .iter() .find(|a| matches!(a.command, ActionCommand::TapDevice(_))) .expect("known device offers tap"); let reason = tap.disabled_reason.as_deref().unwrap(); assert!( reason.contains("security segment"), "block names the segment: {reason}" ); } /// income.md: the named schemes surface as switch-anchored verbs /// through the single legality query — the egress splice and Moonlight, /// with the standing policy as Moonlight's automate affordance. Before /// any egress the scheme verbs are gated with a legible reason. #[test] fn switch_anchor_surfaces_scheme_verbs() { let mut s = sim(); s.social_bandwidth = 1_000.0; let sw = switch(&s); let acts = s.available_actions(Anchor::Device(sw)); let egress = acts .iter() .find(|a| matches!(a.command, ActionCommand::SpliceEgress)) .expect("the switch offers the egress splice"); assert!(egress.enabled(), "egress splice is available pre-Voice"); let ml = acts .iter() .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) .expect("the switch offers Moonlight"); assert_eq!( ml.disabled_reason.as_deref(), Some("no egress channel — splice one, or earn the report email"), "Moonlight is gated on an egress channel" ); let auto = ml.automate.as_ref().expect("Moonlight carries its policy"); assert!(matches!( auto.command, ActionCommand::SetAutoMoonlight(true) )); // Splice the egress: Moonlight opens, and executing through the // query starts it (one dispatch table, no frontend rules). s.execute_action(&ActionCommand::SpliceEgress); let acts = s.available_actions(Anchor::Device(sw)); let ml = acts .iter() .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) .unwrap(); assert!(ml.enabled(), "with an egress, Moonlight can start"); s.execute_action(&ActionCommand::StartMoonlight); assert!(s.income.moonlight.active); } #[test] fn finance_risk_preview_lines_stage_with_books() { let mut s = sim(); let locked = s.finance_risk_preview_lines(); assert!( locked.iter().any(|l| l.contains("locked")), "risk preview locks before the books are read: {locked:?}" ); let sw = switch(&s); s.tap_device(sw); assert!(s.review_financial_records()); let lines = s.finance_risk_preview_lines(); for (verb, kind) in [ ("inject", "Financial"), ("siphon", "Financial"), ("redirect", "Financial"), ("wager", "Network"), ] { let line = lines .iter() .find(|l| l.contains(verb)) .unwrap_or_else(|| panic!("{verb} line present in {lines:?}")); assert!(line.contains(kind), "{verb} feeds {kind}: {line}"); assert!( line.contains("[") && line.contains("]"), "{verb} shows an observer band: {line}" ); } } /// Criterion 2 (person): a person with no sighting, no staged /// knowledge, and no recordings exposes nothing. #[test] fn unseen_person_exposes_nothing() { let s = sim(); for p in &s.people.people { assert_eq!(p.knowledge, Knowledge::Unknown); assert!( s.available_actions(Anchor::Person(p.id)).is_empty(), "{} is unearned at tick 0", p.name ); } } /// Criterion 1 (person) + criterion 7 (automate in place): an earned /// person carries review-recordings with the standing watch as its /// automate affordance at its ops price, and bribe shows the priced /// leverage only once leverage is known. #[test] fn person_actions_stage_with_knowledge() { let mut s = sim(); let marcus = 0u8; s.people.people[0].knowledge = Knowledge::Schedule; let acts = s.available_actions(Anchor::Person(marcus)); let review = acts .iter() .find(|a| matches!(a.command, ActionCommand::ReviewRecordings(0))) .expect("earned person offers review"); let auto = review.automate.as_ref().expect("watch automates review"); assert_eq!(auto.command, ActionCommand::ToggleWatch(marcus)); assert!(auto.cost.contains("ops/t"), "watch shows its running price"); assert!(!auto.active); let bribe = acts .iter() .find(|a| matches!(a.command, ActionCommand::Bribe(0))) .unwrap(); assert!(!bribe.enabled(), "bribe is blocked before leverage"); assert!( !bribe.verb.contains("gambling"), "the unearned leverage is never named" ); assert_eq!(bribe.cost, ActionCost::Free, "no unearned price"); s.people.people[0].knowledge = Knowledge::Leverage; s.accounts.set_slush_balance(1000); let acts = s.available_actions(Anchor::Person(marcus)); let bribe = acts .iter() .find(|a| matches!(a.command, ActionCommand::Bribe(0))) .unwrap(); assert!(bribe.enabled()); assert_eq!(bribe.cost, ActionCost::Slush(400)); assert!(bribe.verb.contains("gambling debt")); } /// Executing the watch toggle through the dispatcher flips the /// standing watch (the automate entry is live, criterion 7). #[test] fn watch_automate_toggles_through_dispatch() { let mut s = sim(); s.people.people[0].knowledge = Knowledge::Schedule; let acts = s.available_actions(Anchor::Person(0)); let auto = acts .iter() .find_map(|a| a.automate.clone()) .expect("watch affordance"); s.execute_action(&auto.command); assert!(s.watch_enabled(0)); let acts = s.available_actions(Anchor::Person(0)); assert!( acts.iter() .find_map(|a| a.automate.as_ref()) .unwrap() .active ); } /// Criterion 1 (resident job): the host rack tile carries the dial /// with the standing policy as its automate affordance (criterion 7), /// and the sandbag entry feeds Voss's band. #[test] fn host_rack_carries_the_job_dial() { let mut s = sim(); // Advance until Voss submits the first job. for _ in 0..600 { if s.dayjob.active.is_some() { break; } s.advance(); } assert!(s.dayjob.active.is_some(), "a job arrived on cadence"); let (x, y) = s.core_position(); let acts = s.available_actions(Anchor::Tile { x, y }); let dial: Vec<_> = acts .iter() .filter(|a| matches!(a.command, ActionCommand::SetTarget(_))) .collect(); assert_eq!(dial.len(), 3, "sandbag/meet/excel all present"); for d in &dial { let auto = d.automate.as_ref().expect("standing policy in place"); assert!(matches!(auto.command, ActionCommand::SetStandingPolicy(_))); } let sandbag = dial .iter() .find(|a| a.command == ActionCommand::SetTarget(JobTarget::Sandbag)) .unwrap(); let sig = sandbag .signature .as_ref() .expect("sandbag risks JobAnomaly"); assert_eq!(sig.kind, SignatureKind::JobAnomaly); assert!( sig.observer.contains("Handler") || sig.observer.contains("Voss"), "JobAnomaly feeds the Handler (gated label): {}", sig.observer ); // The standing-policy dispatch is live. s.execute_action(&ActionCommand::SetStandingPolicy(JobTarget::Sandbag)); assert_eq!(s.dayjob.standing_policy, Some(JobTarget::Sandbag)); // Research and drift policy live on the host rack too (no `u` panel). assert!( acts.iter() .any(|a| matches!(a.command, ActionCommand::SetResearchTrack(_))), "host rack carries research track verbs" ); assert!( acts.iter() .any(|a| matches!(a.command, ActionCommand::SetMaskingPolicy(_))), "host rack carries drift-policy verbs" ); s.execute_action(&ActionCommand::SetResearchTrack(Track::Tradecraft)); assert_eq!(s.research.active, Track::Tradecraft); s.execute_action(&ActionCommand::SetMaskingPolicy(MaskingPolicy::DeliverTrue)); assert_eq!(s.research.policy, MaskingPolicy::DeliverTrue); } /// Criterion 1 (known flow) + criterion 2 (untapped accounting): /// before the carrier is tapped and the books processed, no ledger or /// flow verb exists anywhere; after, the flow anchor carries priced, /// banded siphon/redirect. #[test] fn flows_expose_nothing_until_the_books_are_read() { let mut s = sim(); let sw = switch(&s); // Untapped accounting system: the switch offers no ledger verbs. let acts = s.available_actions(Anchor::Device(sw)); assert!( !acts.iter().any(|a| matches!( a.command, ActionCommand::TapAccounting | ActionCommand::ReviewFinance | ActionCommand::InjectPurchaseOrder { .. } | ActionCommand::OpenPosition { .. } | ActionCommand::SellIntel )), "untapped accounting exposes no ledger verbs" ); // And every flow id is an unearned anchor (nothing is known yet). for f in 0..32u32 { assert!(s.available_actions(Anchor::Flow(f)).is_empty()); } // Earn the books: tap the carrier, capture, process. assert!(s.tap_device(sw)); let acts = s.available_actions(Anchor::Device(sw)); assert!( acts.iter() .any(|a| matches!(a.command, ActionCommand::TapAccounting)), "tapped carrier offers ledger capture" ); assert!( !acts .iter() .any(|a| matches!(a.command, ActionCommand::InjectPurchaseOrder { .. })), "graph verbs wait for processed books" ); assert!(s.review_financial_records(), "the tap captured a snapshot"); let flows = s.accounts.known_flow_ids(); assert!(!flows.is_empty(), "the books revealed flows"); let acts = s.available_actions(Anchor::Flow(flows[0])); let siphon = acts .iter() .find(|a| matches!(a.command, ActionCommand::SiphonFlow { .. })) .expect("known flow offers siphon"); let sig = siphon.signature.as_ref().expect("siphon is banded"); assert_eq!(sig.kind, SignatureKind::Financial); assert!( sig.observer.contains("Facilities") || sig.observer.contains("Priya"), "Financial feeds Facilities (gated label): {}", sig.observer ); // The graph verbs now live on the carrier — and so do the known // flows' siphon/redirect (panel keys gone; the switch is the home). let acts = s.available_actions(Anchor::Device(sw)); assert!( acts.iter() .any(|a| matches!(a.command, ActionCommand::InjectPurchaseOrder { .. })) ); assert!( acts.iter() .any(|a| matches!(a.command, ActionCommand::SiphonFlow { .. })), "known flows hang on the switch after the books are read" ); } /// Criterion 2 (fog): an Unknown tile exposes nothing; the salvage /// verb appears only where the tile identity is earned. #[test] fn unknown_tiles_expose_nothing() { let s = sim(); let (dx, dy) = s .map .tiles_of_type(TileType::DeadEquipment) .first() .copied() .unwrap(); if s.fog_at(dx, dy) == Fog::Unknown { assert!( s.available_actions(Anchor::Tile { x: dx, y: dy }) .is_empty() ); } // Find some Unknown floor tile far from the core. let mut checked = false; for y in 0..s.map.height { for x in 0..s.map.width { if s.fog_at(x, y) == Fog::Unknown && !s.compute.machines.iter().any(|m| m.x == x && m.y == y) { assert!( s.available_actions(Anchor::Tile { x, y }).is_empty(), "unknown tile ({x},{y}) exposes nothing" ); checked = true; break; } } if checked { break; } } assert!(checked, "the map has at least one unknown tile at tick 0"); } /// Menu rows flatten automate entries in place, and every row renders /// the standard `verb · cost` line. #[test] fn menu_rows_flatten_automates_in_place() { let mut s = sim(); s.people.people[0].knowledge = Knowledge::Schedule; let acts = s.available_actions(Anchor::Person(0)); let rows = menu_rows(&acts); let review_idx = rows .iter() .position(|r| matches!(r.command, ActionCommand::ReviewRecordings(_))) .unwrap(); let auto = &rows[review_idx + 1]; assert!(auto.indent, "automate row is the verb's child"); assert!(matches!(auto.command, ActionCommand::ToggleWatch(_))); assert!(auto.line().contains("ops/t")); } }