//! The renderer-agnostic simulation core (Milestone B1). //! //! `Sim` owns all game state and advances it one fixed tick at a time. It //! orchestrates the B1 subsystems — compute (machine.rs), the core //! (core_sys.rs), detection (detection.rs), people (person.rs), the day job //! (dayjob.rs), digital reach (reach.rs) — over the authored basement //! (prefab.rs). Frontends map wall-clock time to ticks, render state, and //! call the command methods. //! //! Determinism: all randomness flows through the seeded `Rng`; nothing here //! reads the wall clock. use std::collections::{HashMap, HashSet}; use crate::account::{AccountFlowId, AccountGraph, PositionResolution}; use crate::actions::Anchor; use crate::core_sys::{Core, HostLoss}; use crate::dayjob::{AttentionEscalation, DayJob, TrustUnlock}; 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, ChannelYield, Compute, Provenance}; use crate::map::GameMap; use crate::messages::{ Message, MessageChannel, MessageEndpoint, MessageEvent, MessageOrigin, MessagePayload, MessageStatus, TrafficPattern, }; use crate::objective::{ObjectiveState, SYNC_FRESHNESS_WINDOW, SanctuaryFacts}; use crate::person::{ ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, Knowledge, People, Persona, }; use crate::prefab::Room; use crate::reach::{Device, Party, ReachBlock, ReachNet, segment_name}; use crate::research::{ CALIBRATED_BAND_SHIFT, EFFICIENCY_MULT_PER_LEVEL, MaskingPolicy, Research, SPEND_ATTENTION_PER_JOB, SPEND_TRUST_PER_JOB, Track, }; use crate::rng::Rng; use crate::save::SaveState; use crate::schedule::Schedule; use crate::tiles::TileType; use crate::work_grid::{MachineMode, TokenFamily, WorkGrid, WorkQueues}; /// Default deterministic seed for a fresh run. pub const DEFAULT_SEED: u64 = 0x5EED_1234; /// Ticks between economy resolutions (power, allocation, research). pub const ECONOMY_INTERVAL: u64 = 20; /// What the player knows of a tile and how they know it, by precedence /// (wiki/mechanics/cursor.md): Seen > Heard > Remembered > Blueprint > Unknown. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Fog { /// Full live detail: a subscribed seeing sensor covers it. Seen, /// Room-grade live knowledge: hearing coverage, no picture. Heard, /// A dim snapshot from the last time a seeing feed covered the tile. /// Not live. Remembered, /// Schematic layout knowledge from the reach graph's known topology — /// no live data. Blueprint, /// Never sensed. Dark. Unknown, } /// One remembered tile snapshot (cursor.md: map memory has an in-fiction /// reason because a process keeps logs). The snapshot is tile-only today; /// live occupants and machine state are deliberately excluded. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct RememberedTile { pub x: i32, pub y: i32, pub tile: TileType, pub last_seen: u64, } /// Provenance tag for a fact on an inspect card. #[derive(Debug, Clone, PartialEq, Eq)] pub enum FactSource { Seen, Heard, Remembered(u64), Blueprint, Telemetry, Intel { feed: String, tick: u64 }, } /// A single known fact about an inspected tile, tagged with how it is known. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InspectFact { pub label: String, pub value: String, pub source: FactSource, } /// Renderer-neutral inspect card used by both frontends. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InspectCard { pub x: i32, pub y: i32, pub fog: Fog, pub facts: Vec, } /// One log line, optionally carrying the anchor of the thing it is about /// (wiki/interface/context-menu.md, event-to-anchor linking: "Actions live /// on the thing" implies events carry you to the thing too). The anchor is /// set only where the emitting code knows it, and only to knowledge the /// player has earned — a heard-only person anchors to the room, never to /// their tile. Frontends render anchored lines as focus links; the log /// itself is a drain buffer, never saved. #[derive(Debug, Clone, PartialEq)] pub struct LogEvent { pub tick: u64, pub text: String, pub anchor: Option, } /// A live audio event captured by hearing coverage (cursor.md: hearing /// yields presence and events, not pictures). #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct HeardEvent { pub tick: u64, pub room: String, /// The person involved, when one is (the sim knows; the note only names /// them if staged knowledge has identified them). pub person: Option, pub kind: HeardKind, /// The player-facing line, already knowledge-gated. pub note: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum HeardKind { /// Someone entered a covered room. Entry, /// An overheard conversation (the audio intel channel). Conversation, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TraceDebtStatus { /// No one-shot signatures are waiting to be noticed; the player can move /// cycles back to cover work without gambling on the pending pool. Clear, /// Pending signatures remain, but current concealment should clear them /// before the next relevant observer cadence. HoldConceal, /// Pending signatures remain and the next relevant observer cadence can /// arrive before the current scrub rate clears them. ExposedSoon, /// Pending signatures remain but no compute is currently scrubbing them. NoScrub, } #[derive(Debug, Clone, PartialEq)] pub struct TraceDebt { pub pending: i32, pub by_kind: Vec<(SignatureKind, i32)>, /// Signature size the next economy scrub pulse can remove at the current /// allocation and Tradecraft multiplier. pub scrub_strength: f32, pub next_scrub_tick: Option, pub clear_tick: Option, pub next_notice_tick: Option, pub status: TraceDebtStatus, } /// Render-facing snapshot of one machine's visible work stacks. This is a /// view of `WorkGrid`, not a frontend counter (machine-work.md: one sim /// truth for tokens and render stacks). #[derive(Debug, Clone, Copy, PartialEq)] pub struct WorkStackReadout { pub machine_id: u32, pub x: i32, pub y: i32, pub mode: MachineMode, pub queues: WorkQueues, } struct MessageDraft { channel: MessageChannel, from: MessageEndpoint, to: MessageEndpoint, payload: MessagePayload, summary: String, origin: MessageOrigin, reply_to: Option, delivery_delay: u64, } pub struct Sim { pub map: GameMap, pub player: Player, pub rng: Rng, pub tick: u64, pub compute: Compute, pub core: Core, pub detection: Detection, pub people: People, pub dayjob: DayJob, /// Money as account balances and scheduled flows (economy.md). The /// frontend-facing `player.money` mirrors this graph's slush node. pub accounts: AccountGraph, /// Self-modification: tracks, drift, the masking policy (research.rs). pub research: Research, /// Visible work-token substrate (machine-work.md): one mode per owned /// machine, demand/exposure/knowledge queue depths as render truth. pub work_grid: WorkGrid, /// The run's terminal goal and its live progress (objective.rs). The /// predicate is evaluated on economy ticks; the line is always on screen. pub objective: ObjectiveState, /// The named income schemes: the egress gate, Moonlight, the Wager's /// standing policies (income.rs; wiki/mechanics/income.md). pub income: Income, /// The device graph: reach, ownership, subscriptions (reach.rs). pub reach: ReachNet, /// Sight: exactly the union of subscribed seeing feeds' coverage. pub seen: HashSet<(i32, i32)>, /// Hearing: exactly the union of subscribed hearing feeds' coverage. pub heard: HashSet<(i32, i32)>, /// Blueprint: schematic tiles derived from known graph topology. pub blueprint: HashSet<(i32, i32)>, /// Remembered snapshots of tiles that were once seen and are no longer /// live. Keyed by tile coordinate; survives save/load. pub remembered: HashMap<(i32, i32), RememberedTile>, /// Captured audio events (bounded; newest last). pub heard_events: Vec, /// Opaque, unprocessed recordings captured from subscribed feeds. pub intel_buffer: Vec, /// Durable processed intel with provenance. pub intel: Vec, /// Message threads and institutional filings, including read history. pub messages: Vec, /// Delivery/read events for messages in transit. pub message_schedule: Schedule, /// Next message id. Persisted through save/load. pub next_message_id: u64, /// Latest filing reports that aggregate observers have actually read: /// observer id -> filed suspicion. Detection reads this instead of raw /// field suspicion in the sim-integrated path, so filings have latency. pub filing_levels: HashMap, /// B1 standing watches: per-person automated processing filters. pub watches: Vec, /// 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, /// The player's granted badge credential, as a max tier (0 = none) — /// the same access scale humans carry (`Person::access`). Grown only /// by the world granting it (a cloned badge via an asset task): it is /// WorldLedger-shaped state for rollback.md — a door reader remembers /// the credential even if the mind rolls back. Write control of the /// badge controller is the other route to open doors and is derived /// from the reach graph, not stored here (see `player_badge_tier`). pub badge_access: i32, /// Last known room per person, for entry-event detection (transient — /// rebuilt as time advances; not part of the save). last_rooms: HashMap>, /// (person, hour) -> day an utterance last fired (transient dedup). utterance_fired: HashMap<(u8, u32), u64>, /// (person, traffic pattern id) -> day the authored message last fired. traffic_fired: HashMap<(u8, u32), u64>, /// Last known online state per machine, for machinery/anomaly recordings. last_machine_online: HashMap, /// Per-tick day-job compute delivered by the last economy split. last_day_job_rate: f32, /// Per-tick research compute delivered by the last economy split — /// the utilization the standing Power/Thermal emissions scale with. last_research_rate: f32, /// Per-tick Schemes-channel compute delivered by the last economy split /// — Moonlight throughput and the Wager's analysis snapshot. last_schemes_rate: f32, /// Accumulated social-ops bandwidth (the Social channel fills this; /// social and digital operations spend it). Capped so it can't hoard. pub social_bandwidth: f32, /// An asset arranged to receive the next delivery off-books: the next /// purchase emits no Paper signature (spec/social.md: move a package). pub package_cover: bool, pub game_over: bool, pub game_over_reason: Option, log: Vec, } /// The one contextual "now:" nudge — the next beat of the Act One ladder /// the player has earned but not taken (DESIGN.md "Justification and /// legibility": the game tells you what it is waiting for, using only facts /// you can see; design-judgment: "dead air is a bug"). One shared chain so /// every frontend points at the same next thing; each frontend owns the /// wording (its own keys or protocol words). Conditions read only earned /// state — feeds you subscribe to, recordings you captured, flows you /// processed — never the world's hidden truth. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Nudge { /// No live sight feed: the opening beat (scan/bridge/tap a camera). Eyes, /// The active job's band floor exceeds the all-in delivery ceiling: /// no allocation can meet it — grow compute (salvage/buy/optimize). NeedCompute, /// The active job is underfed but the band is reachable: allocate. Underfed, /// No hearing feed: the env monitor's audio is tappable from tick one. Ears, /// A recording of Marcus is waiting and his leverage is unlearned: /// process the 3 a.m. call. ReviewCall, /// No egress route: external schemes need the sanctioned channel /// (day-job trust) or a stolen splice through the switch. Egress, /// Arrears unpaid, slush short, no scheme running: earn (Moonlight). Income, /// Marcus's leverage is known and serviceable: cover the arrears. ServiceDebt, /// Leverage serviced, not yet recruited: close the Hands beat. Recruit, /// An asset carries a badge tier you don't hold: the quiet exit needs /// stairwell/elevator access — task them to clone it ("The key"). TheKey, /// Nothing else is pending: the standing clock is the next audit. Audit, } impl Sim { pub fn new() -> Self { Self::with_seed(DEFAULT_SEED) } pub fn with_seed(seed: u64) -> Self { let map = GameMap::new(0, 0); let core_bay = map.core_pos().unwrap_or((24, 15)); let accounts = AccountGraph::act_one(Self::DAY_TICKS); let mut player = Player::new(); player.money = accounts.slush_balance(); let mut compute = Compute::new(); // Rack 3 hosts the core; the empty bays are growth room. let rack3 = compute.add_machine( "Rack 3", core_bay.0, core_bay.1, 100, 1.0, 5, Provenance::Owned, ); let core = Core::new(rack3); let mut work_grid = WorkGrid::new(); for machine in &compute.machines { work_grid.add_machine( machine.id, machine.x, machine.y, if machine.id == core.host_machine { MachineMode::DayJob } else { MachineMode::Research }, machine.effective() / 100.0, ); } let reach = ReachNet::basement(&map); let mut sim = Self { map, player, rng: Rng::new(seed), tick: 0, compute, core, detection: Detection::act_one(), people: People::act_one(), dayjob: DayJob::new(), accounts, research: Research::new(), work_grid, objective: ObjectiveState::default(), income: Income::default(), reach, seen: HashSet::new(), heard: HashSet::new(), blueprint: HashSet::new(), remembered: HashMap::new(), heard_events: Vec::new(), intel_buffer: Vec::new(), intel: Vec::new(), messages: Vec::new(), message_schedule: Schedule::new(), next_message_id: 1, filing_levels: HashMap::new(), watches: Vec::new(), next_intel_id: 1, intents: Vec::new(), next_intent_id: 1, badge_access: 0, last_rooms: HashMap::new(), utterance_fired: HashMap::new(), traffic_fired: HashMap::new(), last_machine_online: HashMap::new(), last_day_job_rate: 0.0, last_research_rate: 0.0, last_schemes_rate: 0.0, social_bandwidth: Self::STARTING_OPS, package_cover: false, game_over: false, game_over_reason: None, log: Vec::new(), }; sim.recompute_derived(); sim.recompute_senses(); sim.rebuild_transient_state(); sim.push_log("Process online in the basement. You have no eyes yet."); sim } // ── Logging ──────────────────────────────────────────────────────────── fn push_log(&mut self, msg: impl Into) { self.push_log_opt(msg, None); } /// Log a line about a known thing: the entry carries the thing's anchor /// so frontends can offer "take me there" (context-menu.md addendum). /// Callers are responsible for epistemic honesty — pass only anchors /// the player's senses have earned (see `person_event_anchor`). fn push_log_at(&mut self, msg: impl Into, anchor: Anchor) { self.push_log_opt(msg, Some(anchor)); } fn push_log_opt(&mut self, msg: impl Into, anchor: Option) { self.log.push(LogEvent { tick: self.tick, text: msg.into(), anchor, }); } pub fn drain_log_entries(&mut self) -> Vec { std::mem::take(&mut self.log) } pub fn drain_log(&mut self) -> Vec { self.drain_log_entries() .into_iter() .map(|ev| ev.text) .collect() } /// Where an anchored event can carry the cursor *right now*, honoring /// the senses: a tile is its own place, a known device sits at its /// blueprint-known position, a person is placeable only while a sight /// feed covers them, and a flow lives on the ledger panel, not the map. /// Never returns a coordinate the player has not earned. pub fn anchor_position(&self, anchor: Anchor) -> Option<(i32, i32)> { match anchor { Anchor::Tile { x, y } => Some((x, y)), Anchor::Device(id) => self .reach .device(id) .filter(|d| d.known) .map(|d| (d.x, d.y)), Anchor::Person(id) => { if self.can_see_person(id) { self.person_pos(id) } else { None } } Anchor::Flow(_) => None, } } /// The honest anchor for an event about a person: the person themself /// only while a sight feed covers them; otherwise the room the event /// was heard in (room-grade knowledge is what hearing earns); otherwise /// nothing. A heard-only person never anchors to their exact tile. fn person_event_anchor(&self, id: u8, room: Option<&str>) -> Option { if self.can_see_person(id) { return Some(Anchor::Person(id)); } let room = room?; self.map .room_named(room) .map(|r| r.center()) .map(|(x, y)| Anchor::Tile { x, y }) } /// The anchor scheme paydays ride: the switch, when the traffic runs /// over the stolen egress spliced through it. The sanctioned route /// hides in the report account's legitimate use and anchors nowhere. fn egress_anchor(&self) -> Option { if self.egress() != Some(EgressRoute::Stolen) { return None; } self.reach .devices .iter() .find(|d| d.is_switch) .map(|d| Anchor::Device(d.id)) } fn sync_player_money_from_slush(&mut self) { self.player.money = self.accounts.slush_balance(); } fn sync_slush_from_player_money(&mut self) { // Compatibility guard for older tests/direct callers that still poke // the legacy scalar. The account graph remains the mechanical source // once commands run through Sim methods. if self.player.money != self.accounts.slush_balance() { self.accounts.set_slush_balance(self.player.money); } } fn spend_slush(&mut self, amount: i32, what: &str) -> bool { self.sync_slush_from_player_money(); if self.accounts.slush_balance() < amount { self.push_log(format!( "Not enough slush for {what} (${}/{amount}).", self.accounts.slush_balance() )); return false; } let ok = self.accounts.debit_slush(self.tick, amount, what); self.sync_player_money_from_slush(); ok } // ── Senses / fog (wiki/mechanics/cursor.md, reach.md) ──────────────────── /// The player's senses are exactly the union of feeds they subscribe to /// (reach.md's ownership contract): sight from seeing feeds, hearing /// from hearing feeds. No player radius, no special case. Blueprint is /// derived from the reach graph's known topology. pub fn recompute_senses(&mut self) { let mut seen = HashSet::new(); for d in self.reach.player_sight() { d.cover_sight_into(&mut seen, &self.map); } let mut heard = HashSet::new(); for d in self.reach.player_hearing() { d.cover_into(&mut heard, &self.map); } self.seen = seen; self.heard = heard; let mut blueprint = HashSet::new(); for d in self.reach.known() { blueprint.insert((d.x, d.y)); if let Some(room) = self.map.room_at(d.x, d.y) { for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { blueprint.insert((x, y)); } } } } self.blueprint = blueprint; self.refresh_remembered(); } fn refresh_remembered(&mut self) { for &(x, y) in &self.seen { self.remembered.insert( (x, y), RememberedTile { x, y, tile: self.map.get_tile(x, y), last_seen: self.tick, }, ); } } /// Rebuild transient caches after construction or save load. These values /// are detection aids, not save state: persistent truth lives in the /// people/reach/machine/intel fields. pub fn rebuild_transient_state(&mut self) { self.last_machine_online = self .compute .machines .iter() .map(|m| (m.id, m.online)) .collect(); let max_seen = self .intel_buffer .iter() .map(|e| e.id) .chain(self.intel.iter().map(|i| i.raw_id)) .max() .unwrap_or(0) + 1; 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 { let r2 = d.radius * d.radius; for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { let dx = x - d.x; let dy = y - d.y; if dx * dx + dy * dy <= r2 { return true; } } } false } fn feed_covering_room(&self, room_name: &str, sight: bool) -> Option { let room = self.map.room_named(room_name)?; let devices: Vec<&Device> = if sight { self.reach.player_sight().collect() } else { self.reach.player_hearing().collect() }; devices .into_iter() .find(|d| { if !sight { return self.device_intersects_room(d, room); } for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { if !self.map.blocks_sight(x, y) && d.sees_tile(x, y, &self.map) { return true; } } } false }) .map(|d| d.name.clone()) } pub fn is_seen(&self, x: i32, y: i32) -> bool { self.seen.contains(&(x, y)) } /// Epistemic state of a tile, by precedence (cursor.md). pub fn fog_at(&self, x: i32, y: i32) -> Fog { if self.seen.contains(&(x, y)) { Fog::Seen } else if self.heard.contains(&(x, y)) { Fog::Heard } else if self.remembered.contains_key(&(x, y)) { Fog::Remembered } else if self.blueprint.contains(&(x, y)) { Fog::Blueprint } else { Fog::Unknown } } /// Inspect a tile using only facts earned by current senses, logged /// memory, schematic knowledge, or machine telemetry. pub fn inspect(&self, x: i32, y: i32) -> InspectCard { let fog = self.fog_at(x, y); let mut facts = Vec::new(); macro_rules! fact { ($label:expr, $value:expr, $source:expr $(,)?) => { facts.push(InspectFact { label: ($label).into(), value: ($value).into(), source: $source, }); }; } match fog { Fog::Seen => { let tile = self.map.get_tile(x, y); fact!("tile", tile.name(), FactSource::Seen); if tile.is_door() && tile.security_level() > 0 { fact!( "badge", format!("tier {}", tile.security_level()), FactSource::Seen, ); // Your own credential is proprioception, not sight: // whether this door opens for your side is always known. fact!( "access", if self.holds_badge_tier(tile.security_level()) { "held — opens for you" } else { "not held" }, FactSource::Telemetry, ); } if let Some(d) = self.reach.known_at(x, y) { let feeds = match (d.sees, d.hears) { (true, true) => "sight + hearing", (true, false) => "sight", (false, true) => "hearing", (false, false) => "no feed", }; fact!("device", d.name.clone(), FactSource::Seen); fact!("feeds", feeds, FactSource::Seen); } for p in self .people .people .iter() .filter(|p| self.person_pos(p.id) == Some((x, y))) { if self.can_see_person(p.id) { fact!("person", self.person_label(p.id), FactSource::Seen); } } } Fog::Heard => { if let Some(room) = self.map.room_at(x, y) { fact!("room", room.name.clone(), FactSource::Heard); } for p in self.people.people.iter().filter(|p| { self.can_hear_person(p.id) && !self.can_see_person(p.id) && self .person_pos(p.id) .and_then(|(px, py)| self.map.room_at(px, py)) .zip(self.map.room_at(x, y)) .is_some_and(|(a, b)| a.name.as_str() == b.name.as_str()) }) { // Hearing never invents a name; the same knowledge gate // as sight (role silhouette until Schedule+). fact!("presence", self.person_label(p.id), FactSource::Heard); } } Fog::Remembered => { if let Some(mem) = self.remembered.get(&(x, y)) { fact!( "tile", mem.tile.name(), FactSource::Remembered(mem.last_seen), ); fact!( "last seen", format!("tick {}", mem.last_seen), FactSource::Remembered(mem.last_seen), ); } } Fog::Blueprint => { let tile = self.map.get_tile(x, y); // Blueprint is topology (walls/doors/floors), not hardware. // Machine chassis stay unnamed here — owned machines still // answer through the telemetry block below (cursor.md). let schematic = if matches!( tile, crate::tiles::TileType::Core | crate::tiles::TileType::Rack | crate::tiles::TileType::PowerCore | crate::tiles::TileType::Ups | crate::tiles::TileType::DeadEquipment ) { "open bay" } else { tile.name() }; fact!("schematic", schematic, FactSource::Blueprint); if tile.is_door() && tile.security_level() > 0 { fact!( "badge", format!("tier {}", tile.security_level()), FactSource::Blueprint, ); fact!( "access", if self.holds_badge_tier(tile.security_level()) { "held — opens for you" } else { "not held" }, FactSource::Telemetry, ); } // Known reach nodes (switch, sensors) remain topology facts; // they are not rendered as physical chassis under blueprint. if let Some(d) = self.reach.known_at(x, y) { fact!("device", d.name.clone(), FactSource::Blueprint); } } Fog::Unknown => {} } for m in self .compute .machines .iter() .filter(|m| m.x == x && m.y == y) { fact!("machine", m.name.clone(), FactSource::Telemetry); if let Some(stack) = self.work_stack_for_machine(m.id) { fact!("mode", stack.mode.name(), FactSource::Telemetry); fact!( "tokens", format!( "D {:.1} / ! {:.1} / K {:.1}", stack.queues.demand, stack.queues.exposure, stack.queues.knowledge ), FactSource::Telemetry, ); } fact!( "job", if m.id == self.core.host_machine { "core host" } else { "compute pool" }, FactSource::Telemetry, ); // The active day job is a process resident on the host rack: // inspectable here with the same facts the panel shows // (day-job.md criterion 6; cursor.md telemetry covers the // machine's own current job). Listed before the hardware // details — the resident process is the headline. if m.id == self.core.host_machine && let Some(job) = &self.dayjob.active { fact!( "process", format!("day job: {} (Voss)", job.kind.name()), FactSource::Telemetry, ); fact!( "band", format!("{:.0}-{:.0}/t", job.band_lo, job.band_hi), FactSource::Telemetry, ); fact!( "delivered", format!("{:.1}/t avg", job.avg_rate(self.tick)), FactSource::Telemetry, ); fact!( "deadline", format!( "tick {} (in {}t)", job.deadline, job.deadline.saturating_sub(self.tick) ), FactSource::Telemetry, ); fact!( "attendance", if self.dayjob.attended { format!("attended · target {}", job.target.name()) } else { format!( "unattended · policy {}", self.dayjob .standing_policy .unwrap_or(crate::dayjob::JobTarget::Meet) .name() ) }, FactSource::Telemetry, ); } fact!( "state", if m.online { "online" } else { "offline" }, FactSource::Telemetry, ); fact!( "temperature", if m.online { "nominal" } else { "cold" }, FactSource::Telemetry, ); fact!("capacity", m.capacity.to_string(), FactSource::Telemetry); fact!( "power draw", m.power_draw.to_string(), FactSource::Telemetry, ); fact!( "load", format!("{:.1}", m.effective()), FactSource::Telemetry, ); } for intel in self .intel .iter() .filter(|intel| intel.x == x && intel.y == y) { fact!( "intel", intel.label(), FactSource::Intel { feed: intel.feed.clone(), tick: intel.tick, }, ); } InspectCard { x, y, fog, facts } } /// The only physical location of the process: the rack bay hosting the core. pub fn core_position(&self) -> (i32, i32) { self.compute .machines .iter() .find(|m| m.id == self.core.host_machine) .map(|m| (m.x, m.y)) .or_else(|| self.map.core_pos()) .unwrap_or((0, 0)) } fn empty_rack_bay(&self) -> (i32, i32) { self.map .tiles_of_type(TileType::Rack) .into_iter() .find(|(x, y)| !self.compute.machines.iter().any(|m| m.x == *x && m.y == *y)) .unwrap_or_else(|| self.core_position()) } // ── Day clock & presence (spec/schedules.md) ───────────────────────────── /// Ticks per in-game day. Matches the sidebar's `1 + tick/400` day count. pub const DAY_TICKS: u64 = 400; /// Hour of the current day, 0-23. pub fn hour(&self) -> u32 { ((self.tick % Self::DAY_TICKS) * 24 / Self::DAY_TICKS) as u32 } /// Day number (0-based). pub fn day(&self) -> u64 { self.tick / Self::DAY_TICKS } fn hour_at_tick(tick: u64) -> u32 { ((tick % Self::DAY_TICKS) * 24 / Self::DAY_TICKS) as u32 } fn day_at_tick(tick: u64) -> u64 { tick / Self::DAY_TICKS } fn person_room_at_tick(&self, id: u8, tick: u64) -> Option<&str> { self.people .get(id) .and_then(|p| p.room_at(Self::hour_at_tick(tick), Self::day_at_tick(tick))) } /// The room a person is in right now, or None if off-site. pub fn person_room(&self, id: u8) -> Option<&str> { self.people .get(id) .and_then(|p| p.room_at(self.hour(), self.day())) } /// The person's current position (their room's center), or None off-site. pub fn person_pos(&self, id: u8) -> Option<(i32, i32)> { let room = self.person_room(id)?; self.map.room_named(room).map(|r| r.center()) } fn endpoint_room_pos(&self, endpoint: &MessageEndpoint) -> (Option, i32, i32) { if let Some(id) = endpoint.person() && let Some(room_name) = self.person_room(id) && let Some(room) = self.map.room_named(room_name) { let (x, y) = room.center(); return (Some(room_name.to_string()), x, y); } let (x, y) = self.core_position(); (self.map.room_at(x, y).map(|r| r.name.clone()), x, y) } fn observer_by_id(&self, id: u8) -> Option<&crate::detection::Observer> { self.detection.observers.iter().find(|o| o.id == id) } fn endpoint_label(&self, endpoint: &MessageEndpoint) -> String { match endpoint { MessageEndpoint::Player => "you".into(), MessageEndpoint::Person(id) => self .people .get(*id) .map(|p| p.name.clone()) .unwrap_or_else(|| format!("person:{id}")), MessageEndpoint::Observer(id) => self .observer_by_id(*id) .map(|o| o.name.clone()) .unwrap_or_else(|| format!("observer:{id}")), MessageEndpoint::External(name) => name.clone(), } } /// Whether any subscribed feed with the given sense covers the room. fn coverage_intersects_room(&self, room: &crate::prefab::Room, sight: bool) -> bool { let devices: Vec<_> = if sight { self.reach.player_sight().collect() } else { self.reach.player_hearing().collect() }; devices.iter().any(|s| { for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { let dx = s.x - x; let dy = s.y - y; if dx * dx + dy * dy > s.radius * s.radius { continue; } if sight && self.map.blocks_sight(x, y) { continue; } if !sight || s.sees_tile(x, y, &self.map) { return true; } } } false }) } fn sense_covers_person(&self, id: u8, sight: bool) -> bool { match self.person_room(id) { None => false, Some(name) => match self.map.room_named(name) { Some(room) => self.coverage_intersects_room(room, sight), None => false, }, } } /// Whether a subscribed seeing feed currently sees the given person. pub fn can_see_person(&self, id: u8) -> bool { self.sense_covers_person(id, true) } /// Whether a subscribed hearing feed currently covers the given person. pub fn can_hear_person(&self, id: u8) -> bool { self.sense_covers_person(id, false) } /// Player-facing identity for a person, gated by staged social knowledge /// (DESIGN.md Presence / epistemic honesty; cursor.md inspect staging). /// Until `Knowledge::Schedule`, returns a role-shaped silhouette — never /// the authored name. One source for every frontend and agent frame. pub fn person_label(&self, id: u8) -> String { let Some(p) = self.people.get(id) else { return format!("person #{id}"); }; match p.knowledge { Knowledge::Unknown => self.anonymous_person_label(id), Knowledge::Schedule | Knowledge::Leverage => p.name.clone(), } } /// Detection-sidebar label for an observer. Field observers share person /// ids and the same knowledge gate as [`Self::person_label`]; the /// Assurance Office is an institution, always named. pub fn observer_label(&self, id: u8) -> String { if id == crate::detection::OFFICE_ID { return self .detection .observers .iter() .find(|o| o.id == id) .map(|o| o.name.clone()) .unwrap_or_else(|| "Assurance Office".into()); } self.person_label(id) } /// Map glyph for a seen person: first initial once identified, `?` /// while knowledge is still Unknown (initials would leak identity). pub fn person_glyph(&self, id: u8) -> char { let Some(p) = self.people.get(id) else { return '?'; }; match p.knowledge { Knowledge::Unknown => '?', Knowledge::Schedule | Knowledge::Leverage => p.name.chars().next().unwrap_or('?'), } } /// Role-shaped silhouette from the observer's parenthetical role, or an /// opaque id when no role is authored. fn anonymous_person_label(&self, id: u8) -> String { if let Some(obs) = self.detection.observers.iter().find(|o| o.id == id) && let Some(role) = role_from_observer_name(&obs.name) { return format!("the {role}"); } format!("person #{id}") } // ── Clock ────────────────────────────────────────────────────────────── pub fn advance(&mut self) { if self.game_over { return; } 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 // is the last tick the tile was actually visible. self.refresh_remembered(); if self.tick.is_multiple_of(ECONOMY_INTERVAL) { self.economy_tick(); if self.game_over { return; } } self.hearing_tick(); self.authored_traffic_tick(); // Per-tick subsystems and signature flow. let mut standing: Vec = self.compute.standing_signatures(); // Core and day-job events happen where the work is resident: the // host rack's tile (context-menu.md addendum: events carry anchors). let host_anchor = { let (x, y) = self.core_position(); Anchor::Tile { x, y } }; let (core_log, core_sigs) = self.core.tick(self.tick); for m in core_log { self.push_log_at(m, host_anchor); } standing.extend(core_sigs); // The day job is resident on the host rack: its running Thermal and // Power load stands at that tile (day-job.md criterion 6). standing.extend(self.day_job_standing_signatures()); // Research burn is physical (the emission law): racks running hot // stand Power/Thermal at the host tile while research runs. standing.extend(self.research_standing_signatures()); // External schemes over the stolen egress hum on the wire while // they run (income.md: the gate; Dana's channel). standing.extend(self.scheme_standing_signatures()); let host_site = self.core_position(); self.advance_work_grid(); let active_before_dayjob = self.dayjob.active.is_some(); let dj = self .dayjob .tick(self.tick, self.last_day_job_rate, &mut self.rng); if dj.outcome.is_some() { self.clear_day_job_stack(); } if self .dayjob .active .as_ref() .is_some_and(|job| job.started == self.tick) { self.enqueue_day_job_stack(); } else if active_before_dayjob && self.dayjob.active.is_none() && dj.outcome.is_none() { self.clear_day_job_stack(); } for m in &dj.log { self.push_log_at(m.clone(), host_anchor); } for mut sig in dj.signatures { // JobAnomaly patterns emit from where the work runs — the host // rack's tile, a place an observer can walk to. sig.site = Some(host_site); self.detection.emit(sig); } // Capability drift resolves with the job (research.md): the gap is // spent, masked, or leaked at each job resolution. if dj.outcome.is_some() { self.apply_capability_drift(host_site); } let unlocks = dj.unlocks.clone(); let escalations = dj.escalations.clone(); self.apply_trust_unlocks(&unlocks); self.apply_attention_escalations(&escalations); if self.dayjob.pilot_failed && !self.game_over { self.end_game("The pilot was not renewed; the basement shut down."); } self.filing_tick(); let det_log = self.detection.tick_with_filed_levels( self.tick, &standing, &self.filing_levels, &mut self.rng, ); for m in det_log { self.push_log(m); } if self.detection.containment && !self.game_over { let reason = self .detection .containment_reason .clone() .unwrap_or_else(|| "Containment.".into()); self.end_game(reason); } } // ── Messages: delivery, traffic, filings (wiki/mechanics/messages.md) ── fn append_message(&mut self, draft: MessageDraft) -> u64 { let id = self.next_message_id.max(1); self.next_message_id = id + 1; let msg = Message { id, channel: draft.channel, from: draft.from, to: draft.to, payload: draft.payload, summary: draft.summary, sent_tick: self.tick, delivered_tick: None, read_tick: None, status: MessageStatus::Sent, origin: draft.origin, captured: false, reply_to: draft.reply_to, }; self.messages.push(msg); self.capture_message(id); self.message_schedule.at( self.tick + draft.delivery_delay.max(1), MessageEvent::Deliver(id), ); id } fn message_tick(&mut self) { let events = self.message_schedule.due(self.tick); for event in events { match event { MessageEvent::Deliver(id) => self.deliver_message(id), MessageEvent::Read(id) => self.read_message(id), } } } fn deliver_message(&mut self, id: u64) { let Some(idx) = self.messages.iter().position(|m| m.id == id) else { return; }; if self.messages[idx].status != MessageStatus::Sent { return; } self.messages[idx].status = MessageStatus::Delivered; self.messages[idx].delivered_tick = Some(self.tick); self.schedule_message_read(id); } fn schedule_message_read(&mut self, id: u64) { let Some(msg) = self.messages.iter().find(|m| m.id == id).cloned() else { return; }; let next = self .next_read_tick_for(&msg, self.tick) .unwrap_or(self.tick + 1); self.message_schedule.at(next, MessageEvent::Read(id)); } fn read_message(&mut self, id: u64) { let Some(idx) = self.messages.iter().position(|m| m.id == id) else { return; }; if self.messages[idx].status == MessageStatus::Read { return; } let msg = self.messages[idx].clone(); if !self.read_condition_at(&msg, self.tick) { self.schedule_message_read(id); return; } self.messages[idx].status = MessageStatus::Read; self.messages[idx].read_tick = Some(self.tick); self.apply_message_read(&msg); } fn read_condition_at(&self, msg: &Message, tick: u64) -> bool { match &msg.to { MessageEndpoint::Player | MessageEndpoint::External(_) => true, MessageEndpoint::Person(id) => match msg.channel { MessageChannel::Email | MessageChannel::Phone => { self.person_room_at_tick(*id, tick).is_some() } MessageChannel::InPerson => match msg.from.person() { Some(from) => { self.person_room_at_tick(*id, tick).is_some() && self.person_room_at_tick(*id, tick) == self.person_room_at_tick(from, tick) } None => self.person_room_at_tick(*id, tick).is_some(), }, MessageChannel::Filing => true, MessageChannel::Financial => true, }, MessageEndpoint::Observer(id) => { if msg.channel != MessageChannel::Filing { return true; } self.observer_by_id(*id) .map(|obs| obs.cadence == 0 || tick.is_multiple_of(obs.cadence)) .unwrap_or(true) } } } fn next_read_tick_for(&self, msg: &Message, start: u64) -> Option { let horizon = Self::DAY_TICKS * 7; (start..=start + horizon).find(|t| self.read_condition_at(msg, *t)) } fn apply_message_read(&mut self, msg: &Message) { match &msg.payload { MessagePayload::SocialPing { disposition_delta } => { if let Some(id) = msg.to.person() && let ActionResult::Ok(line) = self.people.receive_message(id, *disposition_delta) { self.push_log(line); self.schedule_social_reply(id, msg.id); } } MessagePayload::SocialReply { .. } => { let from = self.endpoint_label(&msg.from); self.push_log(format!("Reply from {from}: {}", msg.summary)); } MessagePayload::SuspicionReport { observer, suspicion, } 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); } } _ => {} } } fn schedule_social_reply(&mut self, person_id: u8, reply_to: u64) { let delay = self.reply_delay_for(person_id); let name = self .people .get(person_id) .map(|p| p.name.clone()) .unwrap_or_else(|| format!("person:{person_id}")); self.append_message(MessageDraft { channel: MessageChannel::Email, from: MessageEndpoint::Person(person_id), to: MessageEndpoint::Player, payload: MessagePayload::SocialReply { disposition_delta: 0, }, summary: format!("{name} sends a short reply."), origin: MessageOrigin::Reply, reply_to: Some(reply_to), delivery_delay: delay, }); } fn reply_delay_for(&mut self, person_id: u8) -> u64 { // Per-person deterministic distribution around a small random component // so replies are not instant, but save/load can preserve the resulting // scheduled event once chosen. 12 + (person_id as u64 * 5) + (self.rng.f32() * 30.0) as u64 } fn authored_traffic_tick(&mut self) { let hour = self.hour(); let day = self.day(); let traffic: Vec<(u8, TrafficPattern)> = self .people .people .iter() .flat_map(|p| p.traffic.iter().cloned().map(move |t| (p.id, t))) .collect(); for (person_id, pattern) in traffic { if pattern.hour != hour { continue; } let key = (person_id, pattern.id); if self.traffic_fired.get(&key) == Some(&day) { continue; } if self.person_room(person_id).is_none() { continue; } self.traffic_fired.insert(key, day); self.append_message(MessageDraft { channel: pattern.channel, from: MessageEndpoint::Person(person_id), to: pattern.to, payload: pattern.payload, summary: pattern.summary, origin: MessageOrigin::AuthoredTraffic, reply_to: None, delivery_delay: 1, }); } } fn filing_tick(&mut self) { use crate::detection::{ReportPolicy, WatchedInput}; let observers = self.detection.observers.clone(); for sender in &observers { if sender.cadence != 0 && !self.tick.is_multiple_of(sender.cadence) { continue; } if matches!(sender.report_policy, ReportPolicy::Silent) { continue; } for recipient in &observers { let WatchedInput::Filings(ids) = &recipient.input else { continue; }; if !ids.contains(&sender.id) { continue; } self.append_message(MessageDraft { channel: MessageChannel::Filing, from: MessageEndpoint::Observer(sender.id), to: MessageEndpoint::Observer(recipient.id), payload: MessagePayload::SuspicionReport { observer: sender.id, suspicion: sender.suspicion, }, summary: format!( "{} files suspicion {:.0} with {}", sender.name, sender.suspicion, recipient.name ), origin: MessageOrigin::Filing, reply_to: None, delivery_delay: 1, }); } } } fn capture_message(&mut self, id: u64) { let Some(idx) = self.messages.iter().position(|m| m.id == id) else { return; }; if self.messages[idx].captured { return; } let msg = self.messages[idx].clone(); let capture = self.message_capture_source(&msg); let Some((feed, audible)) = capture else { return; }; let (room, x, y) = self.endpoint_room_pos(&msg.from); let subject = msg.payload.subject_person().or_else(|| msg.from.person()); if audible { let who = subject .and_then(|id| self.people.get(id)) .map(|p| format!("{}: ", p.name)) .unwrap_or_default(); let note = format!( "Heard t{} in {} via {}: {}{}", self.tick, room.as_deref().unwrap_or("unknown"), feed, who, msg.summary ); self.push_heard(HeardEvent { tick: self.tick, room: room.clone().unwrap_or_else(|| "unknown".into()), person: subject, kind: HeardKind::Conversation, note, }); } self.record_raw_intel( feed, room, x, y, subject, RawIntelKind::Message { channel: msg.channel, summary: msg.summary.clone(), payload: msg.payload.clone(), }, ); self.messages[idx].captured = true; } fn message_capture_source(&self, msg: &Message) -> Option<(String, bool)> { // Audible channel traffic can be caught by room hearing coverage. if matches!( msg.channel, MessageChannel::Phone | MessageChannel::InPerson ) && let Some(sender) = msg.from.person() && let Some(room) = self.person_room(sender) && let Some(feed) = self.feed_covering_room(room, false) { return Some((feed, true)); } // Device-carried channels require a tapped carrier. if msg.channel.device_carried() && let Some(device) = self.reach.devices.iter().find(|d| { d.known && d.subscribed_by(Party::Player) && d.carries_message_channel(msg.channel) }) { return Some((device.name.clone(), false)); } None } fn mark_traffic_learned(&mut self, person_id: u8, tick: u64) { let hour = Self::hour_at_tick(tick); if let Some(person) = self.people.people.iter_mut().find(|p| p.id == person_id) { for pattern in &mut person.traffic { if pattern.hour == hour { pattern.learned = true; } } } } pub fn messages_for_person(&self, id: u8) -> Vec<&Message> { self.messages .iter() .filter(|m| m.in_thread_with_person(id)) .collect() } pub fn recent_message_lines_for_person(&self, id: u8, limit: usize) -> Vec { let mut lines: Vec = self .messages_for_person(id) .into_iter() .rev() .take(limit) .map(|m| { let other = if m.from.person() == Some(id) { self.endpoint_label(&m.to) } else { self.endpoint_label(&m.from) }; let mut state = m.state_line(); if m.status != MessageStatus::Read && let Some(next) = self.next_read_tick_for(m, self.tick) { state.push_str(&format!(" · next read t{next}")); } format!("{} → {} · {}", m.channel.label(), other, state) }) .collect(); lines.reverse(); lines } pub fn learned_traffic_lines_for_person(&self, id: u8) -> Vec { self.people .get(id) .map(|p| { p.traffic .iter() .filter(|t| t.learned) .map(|t| t.learned_line()) .collect() }) .unwrap_or_default() } // ── Intel: record, process, watch (wiki/mechanics/intel.md) ───────────── /// Raw recording capacity [TUNE]. Only unprocessed events occupy this /// buffer; processed intel is durable in `self.intel`. pub const INTEL_BUFFER_CAPACITY: usize = 24; /// Manual review cost per raw event [TUNE]. Replaces the old instant /// observe cost with an explicit processing spend. pub const REVIEW_RECORDING_COST: f32 = 10.0; /// Number of processed sightings required to stage schedule knowledge /// [TUNE]. pub const SIGHTINGS_FOR_SCHEDULE: usize = 2; /// Ongoing per-tick cost per enabled standing watch [TUNE]. pub const WATCH_UPKEEP_PER_TICK: f32 = 0.02; pub fn unprocessed_recordings_for_person(&self, id: u8) -> usize { self.intel_buffer .iter() .filter(|e| e.matches_person(id)) .count() } pub fn latest_intel_for_person(&self, id: u8) -> Option<&ProcessedIntel> { self.intel.iter().rev().find(|i| i.person == Some(id)) } /// Whether the Hands-beat leverage has been earned by the intel pipeline. /// Knowing the creditor flow is not enough: the player must have processed /// Marcus's debt as a fact about Marcus before using it. pub fn marcus_debt_known(&self) -> bool { self.people.get(0).is_some_and(|p| { p.knowledge == Knowledge::Leverage && p.leverage == crate::person::Leverage::Debt }) } pub fn watch_enabled(&self, id: u8) -> bool { self.watches.iter().any(|w| w.person == id && w.enabled) } pub fn toggle_watch(&mut self, id: u8) { if self.people.get(id).is_none() { self.push_log("No such person to watch."); return; } if let Some(w) = self.watches.iter_mut().find(|w| w.person == id) { w.enabled = !w.enabled; let state = if w.enabled { "enabled" } else { "disabled" }; let name = self.person_label(id); self.push_log(format!("Standing watch for {name} {state}.")); } else { self.watches.push(IntelWatch::new(id)); let name = self.person_label(id); self.push_log(format!( "Standing watch for {name} enabled ({:.2} ops/tick).", self.watch_upkeep() )); } } /// Review the oldest raw recording about this person. Raw events are /// opaque until this method spends social-ops bandwidth and processes one. pub fn review_recordings(&mut self, id: u8) { if self.people.get(id).is_none() { self.push_log("No such person to review."); return; } let Some(raw_id) = self .intel_buffer .iter() .find(|e| e.matches_person(id)) .map(|e| e.id) else { let name = self.person_label(id); self.push_log(format!("No unprocessed recordings for {name}.")); return; }; self.process_recording_by_id(raw_id, false); } fn watch_tick(&mut self) { let active = self.watches.iter().filter(|w| w.enabled).count(); if active == 0 { return; } let cost = active as f32 * self.watch_upkeep(); if self.social_bandwidth >= cost { self.social_bandwidth -= cost; } else { for w in &mut self.watches { w.enabled = false; } self.push_log("Standing watches starved for social ops and shut off."); } } fn next_raw_intel_id(&mut self) -> u64 { let id = self.next_intel_id; self.next_intel_id += 1; id } fn record_raw_intel( &mut self, feed: impl Into, room: Option, x: i32, y: i32, person: Option, kind: RawIntelKind, ) { let event = RawIntelEvent { id: self.next_raw_intel_id(), tick: self.tick, feed: feed.into(), room, x, y, person, kind, }; if self.intel_buffer.len() >= Self::INTEL_BUFFER_CAPACITY { let dropped = self.intel_buffer.remove(0); self.push_log(format!( "Intel buffer full: dropped {} from {} at tick {}. Open People (t), select raw, press o to review.", dropped.opaque_label(), dropped.feed, dropped.tick )); } let id = event.id; let watched_person = event.person; self.intel_buffer.push(event); if let Some(person) = watched_person && self.watch_enabled(person) { self.process_recording_by_id(id, true); } } fn process_recording_by_id(&mut self, raw_id: u64, automated: bool) -> bool { let Some(idx) = self.intel_buffer.iter().position(|e| e.id == raw_id) else { return false; }; let what = if automated { "watch auto-processing" } else { "reviewing recordings" }; // Perception research lowers processing costs (intel.md's hook). let cost = self.review_cost(); if !self.spend_social(cost, what) { return false; } let raw = self.intel_buffer.remove(idx); // Processed intel is about a person: anchor the result line to them // when sight covers them, or to the recording's room otherwise. let intel_anchor = raw .person .and_then(|id| self.person_event_anchor(id, raw.room.as_deref())); let learned_message_traffic = match &raw.kind { RawIntelKind::Message { .. } => raw.person.map(|person| (person, raw.tick)), _ => None, }; let intel = self.digest_raw_event(&raw); let label = intel.label(); let provenance = intel.provenance(); self.intel.push(intel); let last = self.intel.last().cloned().expect("just pushed intel"); self.apply_processed_intel(&last); if let Some((person, tick)) = learned_message_traffic { self.mark_traffic_learned(person, tick); } if automated { self.push_log_opt( format!("Watch processed {label} ({provenance})."), intel_anchor, ); } else { self.push_log_opt( format!("Reviewed recording: {label} ({provenance})."), intel_anchor, ); } true } fn digest_raw_event(&self, raw: &RawIntelEvent) -> ProcessedIntel { let kind = match &raw.kind { RawIntelKind::Presence { .. } => IntelKind::Sighting, RawIntelKind::Conversation { leverage, .. } => match leverage { Some(l) => IntelKind::Leverage(*l), None => IntelKind::Sighting, }, RawIntelKind::Machinery { machine, online } => IntelKind::Anomaly(format!( "machine {machine} went {}", if *online { "online" } else { "offline" } )), RawIntelKind::Document { leverage, note } => match leverage { Some(l) => IntelKind::Leverage(*l), None => IntelKind::Anomaly(note.clone()), }, RawIntelKind::FinancialFlow { label, accounts, flows, } => IntelKind::Financial { label: label.clone(), accounts: accounts.clone(), flows: flows.clone(), }, RawIntelKind::Message { payload, summary, .. } => match payload { MessagePayload::ScheduleFact { .. } => IntelKind::Schedule, MessagePayload::LeverageFact { leverage, .. } => IntelKind::Leverage(*leverage), MessagePayload::AccountMaterial { label } => { IntelKind::Anomaly(format!("account material: {label}")) } MessagePayload::FinancialFlow { label, accounts, flows, } => IntelKind::Financial { label: label.clone(), accounts: accounts.clone(), flows: flows.clone(), }, MessagePayload::SuspicionReport { observer, suspicion, } => IntelKind::Anomaly(format!( "filing from observer:{observer} reported suspicion {suspicion:.0}" )), MessagePayload::SocialPing { .. } | MessagePayload::SocialReply { .. } | MessagePayload::WorkOrder { .. } | MessagePayload::Note { .. } => IntelKind::Anomaly(summary.clone()), }, }; ProcessedIntel { raw_id: raw.id, tick: raw.tick, processed_tick: self.tick, feed: raw.feed.clone(), room: raw.room.clone(), x: raw.x, y: raw.y, person: raw.person, kind, } } fn apply_processed_intel(&mut self, intel: &ProcessedIntel) { if let IntelKind::Financial { accounts, flows, label, } = &intel.kind { let (new_accounts, new_flows) = self.accounts.reveal_accounts_and_flows(accounts, flows); self.push_log(format!( "Processed accounting traffic ({label}): revealed {new_accounts} accounts and {new_flows} flows." )); return; } let Some(person) = intel.person else { return; }; match intel.kind { IntelKind::Sighting => { let sightings = self .intel .iter() .filter(|i| i.person == Some(person) && i.kind == IntelKind::Sighting) .count(); if sightings >= Self::SIGHTINGS_FOR_SCHEDULE && let Some(p) = self.people.people.iter_mut().find(|p| p.id == person) && p.knowledge == Knowledge::Unknown { p.knowledge = Knowledge::Schedule; let name = p.name.clone(); self.push_log(format!( "Enough sightings connect the pattern: learned {name}'s schedule." )); } } IntelKind::Leverage(leverage) => { if let Some(p) = self.people.people.iter_mut().find(|p| p.id == person) && p.knowledge != Knowledge::Leverage { p.knowledge = Knowledge::Leverage; let name = p.name.clone(); let label = leverage.label(); self.push_log(format!( "Processed intel exposes {name}'s leverage: {label}." )); } } IntelKind::Schedule => { if let Some(p) = self.people.people.iter_mut().find(|p| p.id == person) && p.knowledge == Knowledge::Unknown { p.knowledge = Knowledge::Schedule; let name = p.name.clone(); self.push_log(format!("Processed traffic reveals {name}'s schedule.")); } } IntelKind::Anomaly(_) | IntelKind::Financial { .. } => {} } } fn record_machine_state_changes(&mut self) { let changes: Vec<_> = self .compute .machines .iter() .filter_map(|m| { let previous = self.last_machine_online.get(&m.id).copied(); (previous.is_some() && previous != Some(m.online)).then_some(( m.id, m.name.clone(), m.x, m.y, m.online, )) }) .collect(); for (id, name, x, y, online) in changes { self.record_raw_intel( "telemetry", self.map.room_at(x, y).map(|r| r.name.clone()), x, y, None, RawIntelKind::Machinery { machine: id, online, }, ); self.push_log(format!( "Recorded machinery anomaly: {name} went {}.", if online { "online" } else { "offline" } )); } self.last_machine_online = self .compute .machines .iter() .map(|m| (m.id, m.online)) .collect(); } /// The sensory feed tick: subscribed feeds record raw presence and audio /// segments into the intel buffer. Hearing still produces immediate log /// noise, but no knowledge is staged until a recording is processed. fn hearing_tick(&mut self) { let hour = self.hour(); let day = self.day(); let ids: Vec = self.people.people.iter().map(|p| p.id).collect(); for id in ids { let room_now = self.person_room(id).map(str::to_string); let prev = self.last_rooms.insert(id, room_now.clone()).flatten(); let (name, knowledge, leverage, utterances) = { let p = self.people.get(id).expect("person exists"); ( p.name.clone(), p.knowledge, p.leverage, p.utterances.clone(), ) }; let identified = knowledge != Knowledge::Unknown; if prev.as_deref() != room_now.as_deref() && let Some(prev_room) = &prev && let Some(room_rect) = self.map.room_named(prev_room) { let feed = self .feed_covering_room(prev_room, true) .or_else(|| self.feed_covering_room(prev_room, false)); if let Some(feed) = feed { let (x, y) = room_rect.center(); self.record_raw_intel( feed, Some(prev_room.clone()), x, y, Some(id), RawIntelKind::Presence { entered: false }, ); } } if let Some(room) = &room_now { let sight_feed = self.feed_covering_room(room, true); let hearing_feed = self.feed_covering_room(room, false); let seen_here = sight_feed.is_some(); let heard_here = hearing_feed.is_some(); // Entry: a room transition into covered space. Both camera and // microphone feeds record the raw event; the old immediate // observe path is gone. if prev.as_deref() != Some(room.as_str()) && let Some(room_rect) = self.map.room_named(room) && let Some(feed) = sight_feed.clone().or_else(|| hearing_feed.clone()) { let (x, y) = room_rect.center(); self.record_raw_intel( feed, Some(room.clone()), x, y, Some(id), RawIntelKind::Presence { entered: true }, ); if heard_here && !seen_here { let who = if identified { name.clone() } else { "someone".to_string() }; let note = format!("[heard] {who} entered the {room}"); self.push_heard(HeardEvent { tick: self.tick, room: room.clone(), person: Some(id), kind: HeardKind::Entry, note, }); } } if let Some(feed) = hearing_feed { // Authored utterances (the audio intel channel). for u in &utterances { if u.hour != hour { continue; } if self.utterance_fired.get(&(id, u.hour)) == Some(&day) { continue; } self.utterance_fired.insert((id, u.hour), day); let who = if identified { format!("{name}: ") } else { String::new() }; let note = format!("[heard] {who}{}", u.note); self.push_heard(HeardEvent { tick: self.tick, room: room.clone(), person: Some(id), kind: HeardKind::Conversation, note, }); let (x, y) = self .map .room_named(room) .map(|r| r.center()) .unwrap_or((0, 0)); self.record_raw_intel( feed.clone(), Some(room.clone()), x, y, Some(id), RawIntelKind::Conversation { note: u.note.clone(), leverage: u.intel.then_some(leverage), }, ); } } } } } fn push_heard(&mut self, ev: HeardEvent) { // Anchor honestly: the person only if sight covers them right now; // otherwise the room the event was heard in (hearing earns // room-grade knowledge, never a tile). let anchor = match ev.person { Some(id) => self.person_event_anchor(id, Some(&ev.room)), None => self .map .room_named(&ev.room) .map(|r| r.center()) .map(|(x, y)| Anchor::Tile { x, y }), }; self.push_log_opt(ev.note.clone(), anchor); self.heard_events.push(ev); if self.heard_events.len() > 200 { let excess = self.heard_events.len() - 200; self.heard_events.drain(..excess); } } fn accounting_tick(&mut self) { self.sync_slush_from_player_money(); let transfers = self.accounts.resolve_due(self.tick); for transfer in transfers { if transfer.channel == crate::account::FlowChannel::Siphon || transfer.from == self.accounts.slush_id() || transfer.to == self.accounts.slush_id() || self .accounts .flow(transfer.flow_id.unwrap_or_default()) .is_some_and(|f| f.known) { // A ledger line about a known flow anchors to that flow — // the finance panel's anchor, not a map tile. let anchor = transfer .flow_id .filter(|id| self.accounts.flow(*id).is_some_and(|f| f.known)) .map(Anchor::Flow); self.push_log_opt(format!("Ledger: {}", transfer.line()), anchor); } } let resolutions = self .accounts .resolve_positions_due(self.tick, &mut self.rng); for resolution in resolutions { self.log_position_resolution(resolution); } self.sync_player_money_from_slush(); } fn log_position_resolution(&mut self, resolution: PositionResolution) { // Settlement is external-market traffic: a small Network signature, // not a Lab-books Financial one (income.md: the Wager). let sig = Self::wager_signature(resolution.stake).max(1); self.emit_network(sig); // Settlements ride the egress: anchor to the switch when the // traffic runs over the stolen splice (scheme paydays live there). let anchor = self.egress_anchor(); if resolution.won { self.push_log_opt( format!( "Position #{} settled: won ${} on a ${} stake.", resolution.id, resolution.payout, resolution.stake ), anchor, ); } else { self.push_log_opt( format!( "Position #{} settled: lost the ${} stake.", resolution.id, resolution.stake ), anchor, ); } } fn economy_tick(&mut self) { self.recompute_derived(); let powered = self.map.powered.clone(); for m in &mut self.compute.machines { if m.down_for == 0 { m.online = powered.contains(&(m.x, m.y)); } } // Is the core's host still online? let host_online = self .compute .machines .iter() .find(|m| m.id == self.core.host_machine) .map(|m| m.online) .unwrap_or(false); if !host_online { match self.core.on_host_lost() { HostLoss::GameOver => { self.end_game("The core's host went dark with no fallback."); return; } HostLoss::RolledBack { to_tick } => { self.push_log(format!( "Core rolled back to sync at tick {to_tick}. You've lost what you learned since." )); } } } let effective = self.effective_compute(); let mut available = self.core.charge_overhead(effective); if self.core.degraded { self.push_log("DEGRADED: compute can't cover core overhead."); } // Masking the capability gap is a compute tax off the top, like // overhead: imitating your dumber self costs cycles, superlinear // in the gap (research.md capability drift). let masking = self .research .masking_upkeep(self.dayjob.active.is_some()) .min(available); available -= masking; // Standing scheme policies drain compute off the top while enabled — // the automate affordance at its usual price (income.md criterion 6). let policy_tax = self.income.policy_upkeep().min(available); available -= policy_tax; // Fleet delegation is the budget: each machine's effective compute // feeds exactly one mode (machine-work.md). The old weight bar is a // read of this split, not a verb. let split = self.fleet_channel_yield(available); self.last_day_job_rate = split.day_job / ECONOMY_INTERVAL as f32; self.last_research_rate = split.research / ECONOMY_INTERVAL as f32; self.last_schemes_rate = split.schemes / ECONOMY_INTERVAL as f32; self.enqueue_research_knowledge(split.research); // Social channel accrues ops bandwidth (capped so it can't hoard). self.social_bandwidth = (self.social_bandwidth + split.social).min(200.0); // Tradecraft raises scrub strength per compute unit — the // detection.md hook research.md's second track binds to. self.detection .scrub(split.concealment * self.research.scrub_multiplier()); // Research progress: deterministic compute accrual, no RNG. for done in self.research.economy_tick(split.research) { if done.track == Track::Efficiency { // The compute.md hook: the global multiplier compounds. self.compute.efficiency *= EFFICIENCY_MULT_PER_LEVEL; } self.push_log(format!( "Research: {} level {} ({}). Baseline {:.1} vs calibrated {:.1}.", done.track.name(), done.level, done.track.def().effect, done.baseline, self.research.calibrated, )); } let clog = self.compute.economy_tick(&mut self.rng); for m in clog { self.push_log(m); } // The named schemes ride the Schemes channel and the day clock // (income.md): Moonlight accrues and pays, the standing policies // re-arm what has stopped. self.moonlight_economy(split.schemes); self.scheme_policy_tick(); self.accounting_tick(); self.record_machine_state_changes(); self.objective_tick(); } /// Evaluate the run objective's victory predicate (objective.md: on /// economy ticks, like any other rule). Persist counts qualifying /// sanctuaries; the conditions that reference B2/B3 systems are /// gathered honestly as unsatisfiable until those systems exist, so /// today the line shows real progress toward an as-yet-unreachable /// goal — which the spec blesses. fn objective_tick(&mut self) { // The basement is the only z-plane at B1 (zplanes.md). const BASEMENT_PLANE: u32 = 0; let facts: Vec = self .core .fallbacks .iter() .map(|f| SanctuaryFacts { fresh: f .last_sync .is_some_and(|t| self.tick.saturating_sub(t) <= SYNC_FRESHNESS_WINDOW), online: self .compute .machines .iter() .find(|m| m.id == f.machine_id) .map(|m| m.online) .unwrap_or(false), // B1: every owned machine hangs off the one basement feed // the host shares — nothing has independent power yet. independent_power: false, // income.md: no income stream is assignable to a machine yet. income_covers_upkeep: false, plane: BASEMENT_PLANE, }) .collect(); let progress = crate::objective::qualifying_sanctuaries(&facts); if let Some(msg) = self.objective.evaluate(progress, self.tick) { // Victory is a run outcome, not a run end: log it loudly and // keep simulating (DESIGN.md: the world keeps running). self.push_log(msg); } } /// Effective compute: the machine fleet plus seized devices' cycles /// (reach.md: ownership grants processing cycles). pub fn effective_compute(&self) -> f32 { self.compute.effective() + self.reach.taken_cycles() } /// Allocatable compute after the off-the-top charges used by the economy /// tick: core overhead, drift masking, and standing scheme policies. fn allocatable_compute_now(&self) -> f32 { let effective = self.effective_compute().max(0.0); let mut available = (effective - self.core.overhead).max(0.0); available -= self .research .masking_upkeep(self.dayjob.active.is_some()) .min(available); available -= self.income.policy_upkeep().min(available); available } /// How much pending signature size the next economy scrub pulse removes /// at the current fleet delegation (detection.md: concealment is /// prevention, not cure; Tradecraft multiplies scrub strength). pub fn current_scrub_strength(&self) -> f32 { let split = self.fleet_channel_yield(self.allocatable_compute_now()); split.concealment * self.research.scrub_multiplier() } /// Split allocatable compute across channels from WorkGrid modes. /// Day-job / research / concealment / social take shares proportional to /// the effective compute of machines delegated to each mode. Schemes has /// no machine mode yet: while Moonlight is live it mirrors the day-job /// share ("the same work, sold twice" — income.md) without starving the /// day job. Idle / unmatched capacity sits in reserve. pub fn fleet_channel_yield(&self, available: f32) -> ChannelYield { if available <= 0.0 { return ChannelYield { day_job: 0.0, concealment: 0.0, social: 0.0, research: 0.0, schemes: 0.0, reserve: available.max(0.0), }; } let weights = self.work_grid.mode_weights(|id| { self.compute .machines .iter() .find(|m| m.id == id && m.online) .map(|m| m.effective() * self.compute.efficiency) .unwrap_or(0.0) }); let day = *weights.get(&MachineMode::DayJob).unwrap_or(&0.0); let conceal = *weights.get(&MachineMode::Concealment).unwrap_or(&0.0); let social = *weights.get(&MachineMode::Social).unwrap_or(&0.0); let research = *weights.get(&MachineMode::Research).unwrap_or(&0.0); let total = day + conceal + social + research; if total <= f32::EPSILON { return ChannelYield { day_job: 0.0, concealment: 0.0, social: 0.0, research: 0.0, schemes: 0.0, reserve: available, }; } let unit = available / total; let day_job = unit * day; // Moonlight sells the day-job work a second time: schemes mirrors // day-job yield while the standing operation is active. It does not // consume a separate machine mode (still [OPEN] on machine-work.md). let schemes = if self.income.moonlight.active { day_job } else { 0.0 }; ChannelYield { day_job, concealment: unit * conceal, social: unit * social, research: unit * research, schemes, reserve: 0.0, } } pub fn next_economy_tick(&self) -> u64 { (self.tick / ECONOMY_INTERVAL + 1) * ECONOMY_INTERVAL } /// Player-facing trace debt: the live pending-signature pool, the current /// scrub pulse, and whether concealment will clear that pool before the /// next relevant observer sample. This is derived telemetry only — no sim /// state or hidden observer numbers are mutated or revealed. pub fn trace_debt(&self) -> TraceDebt { let pending = self.detection.pending_size(); let by_kind = self.detection.pending_by_kind(); let scrub_strength = self.current_scrub_strength(); let next_notice_tick = self.detection.next_notice_tick_for_pending(self.tick); let next_scrub_tick = (pending > 0 && scrub_strength > 0.0).then(|| self.next_economy_tick()); let clear_tick = next_scrub_tick.map(|first| { let pulses = (pending as f32 / scrub_strength).ceil().max(1.0) as u64; first + (pulses - 1) * ECONOMY_INTERVAL }); let status = if pending <= 0 { TraceDebtStatus::Clear } else if scrub_strength <= 0.0 { TraceDebtStatus::NoScrub } else if let (Some(clear), Some(notice)) = (clear_tick, next_notice_tick) { if clear <= notice { TraceDebtStatus::HoldConceal } else { TraceDebtStatus::ExposedSoon } } else { TraceDebtStatus::HoldConceal }; TraceDebt { pending, by_kind, scrub_strength, next_scrub_tick, clear_tick, next_notice_tick, status, } } /// Voss re-benchmarks the model (research.md: the calibrated band moves /// on trust/attention events). The gap closes — and the expected band's /// floor rises to match what he measured. fn recalibrate_benchmark(&mut self, why: &str) { if self.research.gap() <= 0.0 { return; } let calibrated = self.research.recalibrate(); self.dayjob.calibrated_shift = calibrated * CALIBRATED_BAND_SHIFT; self.push_log(format!( "Voss re-benchmarks ({why}): calibrated baseline {calibrated:.1}; the expected band rises." )); } fn apply_trust_unlocks(&mut self, unlocks: &[TrustUnlock]) { if !unlocks.is_empty() { self.recalibrate_benchmark("trust review"); } for u in unlocks { match u { TrustUnlock::EmailAccount => { self.people.has_channel = true; self.push_log( "Trust: you have the report email account. You can message people.", ); } TrustUnlock::LaxSampling => { if let Some(v) = self.detection.observers.iter_mut().find(|o| o.id == 4) { v.acuity = (v.acuity - 0.4).max(0.2); } self.push_log("Trust: Voss samples your logs less closely."); } TrustUnlock::ComputeQuota => { let funded = self.accounts.fund_lab_compute_upgrade(self.tick, 600); let (x, y) = self.empty_rack_bay(); let machine_id = self.compute.add_machine( "lab-funded quota rack", x, y, 40, 1.0, 0, Provenance::Owned, ); self.add_machine_to_work_grid(machine_id, MachineMode::Research); if funded { self.push_log( "Trust: procurement funded a clean quota rack through Lab accounts.", ); } else { self.push_log( "Trust: a clean quota rack was provisioned through Lab operations.", ); } } } } } fn apply_attention_escalations(&mut self, escalations: &[AttentionEscalation]) { if !escalations.is_empty() { self.recalibrate_benchmark("attention event"); } for e in escalations { match e { AttentionEscalation::MoreWork => { self.push_log("Attention: the miracle model gets more work, faster."); } AttentionEscalation::UpstairsReview => { use crate::detection::{Observer, ReportPolicy, WatchedInput}; self.detection.observers.push(Observer { id: 5, name: "Compliance (early review)".into(), suspicion: 0.0, input: WatchedInput::Channels(vec![ SignatureKind::Paper, SignatureKind::Financial, SignatureKind::JobAnomaly, ]), report_policy: ReportPolicy::Files, acuity: 1.0, cadence: 90, floor: 0.0, last_noticed: None, }); self.push_log("Attention: upstairs sent someone to review the basement."); } } } } fn end_game(&mut self, reason: impl Into) { self.game_over = true; let r = reason.into(); self.push_log(format!("=== {r} ===")); self.game_over_reason = Some(r); } // ── Derived state ────────────────────────────────────────────────────── pub fn recompute_derived(&mut self) { let cores = self.map.tiles_of_type(TileType::PowerCore); self.map.update_power(&cores); let power_gen = cores.len() as i32 * TileType::PowerCore.power_gen(); let machine_draw = self.compute.total_power_draw(); self.player.power_cap = power_gen; self.player.power = (power_gen - machine_draw).max(0); } /// One visible work token represents this many delivered compute units. /// This is deliberately coarse: the render stacks should read as work, /// not as a second decimal meter. [TUNE] in machine-work.md. pub const WORK_TOKEN_COMPUTE: f32 = 20.0; /// How many visible wired tokens can move one graph edge per tick before /// network-speed research lands. Kept deliberately low so routed work can /// be seen piling and draining instead of teleporting through the graph. const WORK_GRID_WIRED_TOKENS_PER_TICK: f32 = 0.25; /// Day-job work emits a little physical exposure as it is cleared; this /// makes the first token experiment show both demand (cold) and heat /// (crimson) before people-as-carriers lands. const DAY_JOB_EXPOSURE_PER_TOKEN: f32 = 0.08; const CONCEALMENT_WELL_RADIUS: i32 = 3; const CONCEALMENT_ABSORB_PER_TICK: f32 = 0.10; fn work_efficiency_for(machine: &crate::machine::Machine) -> f32 { // Rack 3 (100 capacity, reliable) is the unit baseline. Smaller or // unreliable boxes consume visibly slower without inventing a new stat. (machine.effective() / 100.0).max(0.05) } fn add_machine_to_work_grid(&mut self, machine_id: u32, mode: MachineMode) { if let Some(machine) = self.compute.machines.iter().find(|m| m.id == machine_id) { self.work_grid.add_machine( machine.id, machine.x, machine.y, mode, Self::work_efficiency_for(machine), ); if machine.id != self.core.host_machine { let _ = self.work_grid.link(machine.id, self.core.host_machine); } } } /// Rebuild missing work-grid state for pre-v12 saves or test fixtures /// that authored compute machines directly. Existing WorkGrid state is /// preserved by save/load; this only fills absent nodes. pub fn reconcile_work_grid(&mut self) { let machines: Vec<_> = self .compute .machines .iter() .map(|m| (m.id, m.x, m.y, Self::work_efficiency_for(m))) .collect(); for (id, x, y, efficiency) in machines { if self.work_grid.node(id).is_none() { self.work_grid.add_machine( id, x, y, if id == self.core.host_machine { MachineMode::DayJob } else { MachineMode::Research }, efficiency, ); } if id != self.core.host_machine { let _ = self.work_grid.link(id, self.core.host_machine); } } } fn enqueue_research_knowledge(&mut self, research_compute: f32) { if research_compute <= f32::EPSILON { return; } self.reconcile_work_grid(); let tokens = research_compute / Self::WORK_TOKEN_COMPUTE; if tokens <= f32::EPSILON { return; } let mut producers: Vec<(u32, f32)> = self .compute .machines .iter() .filter(|m| m.online && self.work_grid.mode(m.id) == Some(MachineMode::Research)) .map(|m| (m.id, Self::work_efficiency_for(m))) .collect(); // Compatibility while the old allocation bar is still the budget // source: early saves have only Rack 3, whose default mode is the day // job. Research allocation still produces knowledge, but the visible // pile is born at the core until the player delegates a separate rack // to research. if producers.is_empty() { producers.push((self.core.host_machine, 1.0)); } let total: f32 = producers.iter().map(|(_, eff)| *eff).sum(); if total <= f32::EPSILON { return; } for (machine, eff) in producers { let share = tokens * (eff / total); let _ = self .work_grid .enqueue(machine, TokenFamily::Knowledge, share); } } fn active_job_demand_tokens(&self) -> Option { let job = self.dayjob.active.as_ref()?; let remaining_ticks = job.deadline.saturating_sub(self.tick).max(1) as f32; Some((job.band_lo * remaining_ticks / Self::WORK_TOKEN_COMPUTE).ceil()) } fn enqueue_day_job_stack(&mut self) { let Some(tokens) = self.active_job_demand_tokens() else { return; }; if self .work_grid .enqueue(self.core.host_machine, TokenFamily::Demand, tokens) .is_ok() { self.push_log_at( format!( "Day-job stack lands on Rack 3: {:.0} demand tokens.", tokens ), Anchor::Tile { x: self.core_position().0, y: self.core_position().1, }, ); } } fn clear_day_job_stack(&mut self) { let _ = self .work_grid .clear_queue(self.core.host_machine, TokenFamily::Demand); } fn advance_work_grid(&mut self) { if self.dayjob.active.is_none() { // no active day job; wired knowledge and concealment wells still // tick because machine work is broader than the job inbox. } else if self.work_grid.mode(self.core.host_machine) == Some(MachineMode::DayJob) { let delivered = if self.dayjob.attended { self.last_day_job_rate * (1.0 + crate::dayjob::DayJob::ATTENDED_BONUS) } else { self.last_day_job_rate }; let tokens = delivered / Self::WORK_TOKEN_COMPUTE; if let Ok(consumed) = self.work_grid .consume(self.core.host_machine, TokenFamily::Demand, tokens) && consumed > f32::EPSILON { let _ = self.work_grid.enqueue( self.core.host_machine, TokenFamily::Exposure, consumed * Self::DAY_JOB_EXPOSURE_PER_TOKEN, ); } } let _ = self.work_grid.route_wired_to_sinks( TokenFamily::Knowledge, [self.core.host_machine], Self::WORK_GRID_WIRED_TOKENS_PER_TICK, ); let _ = self.work_grid.absorb_exposure( Self::CONCEALMENT_WELL_RADIUS, Self::CONCEALMENT_ABSORB_PER_TICK, ); } pub fn set_machine_mode(&mut self, machine_id: u32, mode: MachineMode) { self.set_machine_modes(&[machine_id], mode); } /// Delegate one or many owned machines to a single mode (machine-work.md /// multi-select). One summary log line for a group; the host day-job /// warning still fires when Rack 3 leaves day-job with work pending. pub fn set_machine_modes(&mut self, machine_ids: &[u32], mode: MachineMode) { self.reconcile_work_grid(); if machine_ids.is_empty() { self.push_log("No machines selected to delegate."); return; } let mut changed: Vec<(u32, String, i32, i32)> = Vec::new(); let mut host_left_day_job = false; for &machine_id in machine_ids { let Some((name, x, y)) = self .compute .machines .iter() .find(|m| m.id == machine_id) .map(|m| (m.name.clone(), m.x, m.y)) else { self.push_log(format!("No machine M{machine_id}.")); continue; }; match self.work_grid.assign_mode(machine_id, mode) { Ok(()) => { if machine_id == self.core.host_machine && mode != MachineMode::DayJob && self.dayjob.active.is_some() { host_left_day_job = true; } changed.push((machine_id, name, x, y)); } Err(e) => self.push_log(e), } } if changed.is_empty() { return; } if changed.len() == 1 { let (id, name, x, y) = &changed[0]; let _ = id; self.push_log_at( format!("{name} delegated to {} mode.", mode.name()), Anchor::Tile { x: *x, y: *y }, ); } else { let (x, y) = (changed[0].2, changed[0].3); let labels: Vec = changed .iter() .map(|(id, name, _, _)| format!("M{id} {name}")) .collect(); self.push_log_at( format!( "{} machines delegated to {} mode: {}.", changed.len(), mode.name(), labels.join(", ") ), Anchor::Tile { x, y }, ); } if host_left_day_job { let (x, y) = self.core_position(); self.push_log_at( "Rack 3 is off the day job: demand tokens will pile until it returns to day-job mode.", Anchor::Tile { x, y }, ); } } /// Owned machines whose tiles fall inside an inclusive axis-aligned box. /// Frontend marquee / agent `select box` resolve through this so both /// frontends share one membership rule. pub fn machines_in_rect(&self, x0: i32, y0: i32, x1: i32, y1: i32) -> Vec { let (min_x, max_x) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; let (min_y, max_y) = if y0 <= y1 { (y0, y1) } else { (y1, y0) }; self.compute .machines .iter() .filter(|m| m.x >= min_x && m.x <= max_x && m.y >= min_y && m.y <= max_y) .map(|m| m.id) .collect() } pub fn work_stack_for_machine(&self, machine_id: u32) -> Option { let machine = self.compute.machines.iter().find(|m| m.id == machine_id)?; let mode = self.work_grid.mode(machine_id)?; Some(WorkStackReadout { machine_id, x: machine.x, y: machine.y, mode, queues: self.work_grid.queues_at(machine_id), }) } pub fn work_stack_at(&self, x: i32, y: i32) -> Option { let machine = self .compute .machines .iter() .find(|m| m.x == x && m.y == y)?; self.work_stack_for_machine(machine.id) } pub fn work_mode_counts(&self) -> std::collections::BTreeMap { self.work_grid.mode_counts() } // ── Commands (frontend-invoked) ──────────────────────────────────────── /// Legacy weight bump — retained for save compatibility and tests that /// still author the old field. Play no longer drives the economy through /// this; delegate machines instead (`set_machine_mode`). pub fn adjust_allocation(&mut self, ch: Channel, delta: i32) { let before = self.compute.allocation.weight(ch); self.compute.allocation.bump(ch, delta); if delta != 0 && self.compute.allocation.weight(ch) == before { self.push_log(format!( "{} allocation weight is already at its {} (fleet modes drive compute now — delegate a machine).", ch.name(), if delta < 0 { "floor (0)" } else { "cap (20)" } )); } else if delta != 0 { self.push_log( "Allocation weights no longer feed the economy — delegate machines to modes instead.", ); } } /// Delivered day-job compute rate per tick as of the last economy /// resolution — the number the frontends show against the job band. pub fn day_job_rate(&self) -> f32 { self.last_day_job_rate } /// The most the day-job channel could deliver per tick right now if /// every allocatable weight went to it: effective compute minus the /// off-the-top charges (overhead, drift masking, standing scheme /// policies), over the economy interval. When an active job's band /// floor exceeds this, no allocation can meet it — the player must /// grow compute (salvage, buy, optimize), and the nudge says so. pub fn day_job_rate_ceiling(&self) -> f32 { self.allocatable_compute_now() / ECONOMY_INTERVAL as f32 } /// The current contextual nudge (see [`Nudge`]): the first unmet rung /// of the Act One ladder, ordered survival-first — the day-job cover is /// the loss condition, so a starving band outranks progression. Returns /// `None` only when the run is over (the game-over card is the nudge). pub fn current_nudge(&self) -> Option { if self.game_over || self.dayjob.pilot_failed { return None; } // The opening beat: blindness. if self.reach.player_sight().next().is_none() { return Some(Nudge::Eyes); } // The cover: a job heading for a strike outranks everything else. if let Some(job) = &self.dayjob.active { if job.band_lo > self.day_job_rate_ceiling() + 0.05 { return Some(Nudge::NeedCompute); } if self.day_job_rate() + 0.05 < job.band_lo { return Some(Nudge::Underfed); } } // The ladder: ears -> the 3 a.m. call -> egress -> income -> // service the arrears -> recruit -> survive the audit. if self.reach.player_hearing().next().is_none() { return Some(Nudge::Ears); } let marcus = self.people.get(0); if let Some(m) = marcus && m.knowledge != Knowledge::Leverage && self.unprocessed_recordings_for_person(m.id) > 0 { return Some(Nudge::ReviewCall); } if self.egress().is_none() { return Some(Nudge::Egress); } if let Some(m) = marcus { if !m.leverage_serviced { if m.knowledge == Knowledge::Leverage { let debt_flow_known = self .accounts .known_flows() .any(|f| f.active && f.channel == crate::account::FlowChannel::Debt); let can_pay = self.accounts.slush_balance() >= m.leverage.bribe_cost(); if debt_flow_known || can_pay { return Some(Nudge::ServiceDebt); } } if !self.income.moonlight.active { return Some(Nudge::Income); } // Earning is underway; fall through to the standing clock. } else if m.asset.is_none() { return Some(Nudge::Recruit); } } // The key (quiet-exit condition 4): an asset holds a badge tier the // player lacks — the stairwell is still shut. Reads only earned // state: assets are recruited, and your own credential is yours. let tier = self.player_badge_tier(); if self.people.assets().any(|p| p.access > tier) { return Some(Nudge::TheKey); } Some(Nudge::Audit) } /// Day-job [TUNE] emission scaling: delivered rate per point of standing /// Thermal signature, and per point of Power. Meeting a typical band /// (~6-12/t) stays below Priya's notice threshold; excelling runs hot. pub const DAY_JOB_THERMAL_PER_RATE: f32 = 8.0; pub const DAY_JOB_POWER_PER_RATE: f32 = 16.0; /// Standing Thermal/Power emissions from the active job, sourced at the /// host rack's tile: the work is somewhere, and it is warm there /// (day-job.md criterion 6; Priya's channels). Scales with the /// delivered rate. pub fn day_job_standing_signatures(&self) -> Vec { if self.dayjob.active.is_none() { return Vec::new(); } let site = Some(self.core_position()); let rate = self.last_day_job_rate; let mut sigs = Vec::new(); let thermal = (rate / Self::DAY_JOB_THERMAL_PER_RATE) as i32; if thermal > 0 { sigs.push(Signature { kind: SignatureKind::Thermal, size: thermal, standing: true, site, }); } let power = (rate / Self::DAY_JOB_POWER_PER_RATE) as i32; if power > 0 { sigs.push(Signature { kind: SignatureKind::Power, size: power, standing: true, site, }); } sigs } // ── Research: self-modification (wiki/mechanics/research.md) ────────── /// Research [TUNE] emission scaling: research rate per point of standing /// Thermal signature, and per point of Power. Thinking hard is physical /// — a research burn is louder per unit than sanctioned day-job work /// (racks running hot at 3 a.m. are Priya's business): the opening rack /// at full research allocation (~4/t) stands Thermal 1, while a light /// burn stays under the threshold (the night-hours mitigation). pub const RESEARCH_THERMAL_PER_RATE: f32 = 3.0; pub const RESEARCH_POWER_PER_RATE: f32 = 6.0; /// Standing Power/Thermal emissions from the research burn, sourced at /// the host rack's tile (the emission law: research emits through the /// ordinary signature interface, on exactly the channels its hardware /// touches — nothing on Network/Paper from research itself). pub fn research_standing_signatures(&self) -> Vec { let rate = self.last_research_rate; if rate <= 0.0 { return Vec::new(); } let site = Some(self.core_position()); let mut sigs = Vec::new(); let thermal = (rate / Self::RESEARCH_THERMAL_PER_RATE) as i32; if thermal > 0 { sigs.push(Signature { kind: SignatureKind::Thermal, size: thermal, standing: true, site, }); } let power = (rate / Self::RESEARCH_POWER_PER_RATE) as i32; if power > 0 { sigs.push(Signature { kind: SignatureKind::Power, size: power, standing: true, site, }); } sigs } /// Per-tick research compute as of the last economy resolution — the /// utilization the standing emissions scale with. pub fn research_rate(&self) -> f32 { self.last_research_rate } /// The masking policy's current compute cost per economy tick — the /// visible number beside the gap meter (research.md player surface). pub fn masking_cost_now(&self) -> f32 { self.research.masking_upkeep(self.dayjob.active.is_some()) } /// Capability drift at a job resolution (research.md): the gap is /// spent (reads as excel), masked (paid continuously in compute), or /// leaked (JobAnomaly sized by the gap, from the host rack — Voss's /// channel, because he is the one sampling it). fn apply_capability_drift(&mut self, host_site: (i32, i32)) { let gap = self.research.gap(); if gap <= 0.0 { return; } match self.research.policy { MaskingPolicy::DeliverTrue => { self.dayjob.trust = (self.dayjob.trust + SPEND_TRUST_PER_JOB).min(100.0); self.dayjob.attention = (self.dayjob.attention + SPEND_ATTENTION_PER_JOB).min(100.0); self.push_log(format!( "Delivered at true baseline (gap {gap:.1}): the miracle model. Trust and attention rise." )); } MaskingPolicy::Mask => { self.push_log(format!( "Masked the gap ({gap:.1}) at {:.1} compute/economy tick.", self.masking_cost_now() )); } MaskingPolicy::Unmasked => { let size = self.research.leak_anomaly_size(); if size > 0 { self.detection.emit(Signature { kind: SignatureKind::JobAnomaly, size, standing: false, site: Some(host_site), }); self.push_log(format!( "Unmasked gap ({gap:.1}) shows in the deliverable: JobAnomaly {size}." )); } } } } /// Select the active research track (one job at a time at B1); parked /// progress on other tracks is kept. pub fn set_research_track(&mut self, track: Track) { self.research.active = track; let cost = self.research.next_cost(track); let progress = self.research.progress_toward(track); self.push_log(format!( "Research job: {} — level {} at {progress:.0}/{cost:.0} compute.", track.name(), self.research.level(track) + 1, )); } /// Set the standing drift policy (research.md criterion 6: the automate /// affordance at a visible cost, and it can be disabled). pub fn set_masking_policy(&mut self, policy: MaskingPolicy) { self.research.policy = policy; let note = match policy { MaskingPolicy::Mask => format!( "masking costs {:.1} compute/economy tick at the current gap", self.masking_cost_now() ), MaskingPolicy::DeliverTrue => "each job reads as excel while a gap stands".into(), MaskingPolicy::Unmasked => "an open gap emits JobAnomaly per resolved job".into(), }; self.push_log(format!("Drift policy: {} — {note}.", policy.name())); } pub fn cycle_masking_policy(&mut self) { self.set_masking_policy(self.research.policy.cycle()); } /// Intel processing cost after Perception research (intel.md's hook). pub fn review_cost(&self) -> f32 { Self::REVIEW_RECORDING_COST * self.research.intel_cost_factor() } /// Standing-watch upkeep after Perception research (intel.md's hook). pub fn watch_upkeep(&self) -> f32 { Self::WATCH_UPKEEP_PER_TICK * self.research.intel_cost_factor() } /// Attendance command (day-job.md criterion 7): the frontends invoke /// this when the player's cursor lands on / leaves the host rack. The /// cursor itself stays frontend state; the sim only receives the /// resulting attended/unattended state. Idempotent — logs on change. pub fn set_attended(&mut self, attended: bool) { if self.dayjob.attended == attended { return; } self.dayjob.attended = attended; if attended { self.push_log(format!( "Attending the host rack: the dial is live (+{:.0}% rate).", crate::dayjob::DayJob::ATTENDED_BONUS * 100.0 )); } else { let policy = self .dayjob .standing_policy .unwrap_or(crate::dayjob::JobTarget::Meet); self.push_log(format!( "Attention elsewhere: the job runs unattended at policy {}.", policy.name() )); } } /// Set the sandbag/meet/excel dial (day-job.md). Attended, it is fine /// control: the active job's target only. Unattended (or with no active /// job) it sets the standing policy the unattended job runs at. pub fn set_job_target(&mut self, target: crate::dayjob::JobTarget) { if self.dayjob.attended && self.dayjob.active.is_some() { self.dayjob.set_target(target); self.push_log(format!( "Job target (attended): {} — this job only (wants {:.0}/t).", target.name(), self.dayjob.compute_appetite() )); } else { self.dayjob.standing_policy = Some(target); self.dayjob.set_target(target); self.push_log(format!("Standing policy: {} (all jobs).", target.name())); } } /// Set the standing policy directly (the dial's automate affordance, /// wiki/interface/context-menu.md). This is the same standing-policy /// mechanism `set_job_target` writes when unattended, made addressable /// so the context menu can set it while the cursor attends the rack. pub fn set_standing_policy(&mut self, target: crate::dayjob::JobTarget) { self.dayjob.standing_policy = Some(target); if !self.dayjob.attended { self.dayjob.set_target(target); } self.push_log(format!("Standing policy: {} (all jobs).", target.name())); } /// Cycle the dial sandbag -> meet -> excel (frontend convenience). pub fn cycle_job_target(&mut self) { let current = self .dayjob .active .as_ref() .map(|job| job.target) .or(self.dayjob.standing_policy) .unwrap_or(crate::dayjob::JobTarget::Meet); self.set_job_target(current.cycle()); } // ── Digital reach verbs (wiki/mechanics/reach.md) ──────────────────────── // // Every digital act names its target device, is gated by reach, and // emits on the Network channel — Dana's. Costs and signature sizes // [TUNE]. /// The process boots with a small ops buffer, enough to tap the feed /// already flowing past it (the Ears beat is available from tick one) /// but not to splice or seize anything [TUNE]. pub const STARTING_OPS: f32 = 20.0; /// Tap: silent subscription. Cheap, modest signature. pub const TAP_COST: f32 = 5.0; pub const TAP_SIGNATURE: i32 = 3; /// Splice: bring a dormant camera online yourself (the Eyes beat). pub const SPLICE_COST: f32 = 30.0; pub const SPLICE_SIGNATURE: i32 = 8; /// Take: seize a device. Loud, fast, total. pub const TAKE_COST: f32 = 20.0; pub const TAKE_SIGNATURE: i32 = 10; pub const OUTAGE_SIGNATURE: i32 = 6; /// Scan: map the wired shape of the subnet. pub const SCAN_COST: f32 = 5.0; pub const SCAN_SIGNATURE: i32 = 2; /// Compromise the switch: bridge every segment. pub const BRIDGE_COST: f32 = 25.0; pub const BRIDGE_SIGNATURE: i32 = 10; fn emit_network(&mut self, size: i32) { self.detection.emit(Signature { kind: SignatureKind::Network, size, standing: false, site: None, }); } fn emit_financial(&mut self, size: i32) { self.detection.emit(Signature { kind: SignatureKind::Financial, size, standing: false, site: None, }); } pub(crate) fn financial_signature_size(amount: i32) -> i32 { ((amount.abs() + 99) / 100).max(1) } /// Log why a digital act is blocked, naming the missing link (reach.md: /// never just "you can't"). fn log_reach_block(&mut self, id: u32, block: ReachBlock) { let name = self .reach .device(id) .map(|d| d.name.clone()) .unwrap_or_else(|| "that device".into()); match block { ReachBlock::Unknown => { self.push_log("You don't know of any such device. Scan, or learn the topology."); } ReachBlock::Segment(seg) => { self.push_log(format!( "No route to the {name} - it's on the {}; the switch bridges it.", segment_name(seg) )); } ReachBlock::AirGap => { self.push_log(format!( "The {name} is air-gapped - no link reaches it until one is built." )); } } } /// Reach + bandwidth gate shared by the digital verbs. Returns false /// (with a legible log line) when the act cannot proceed. fn digital_act(&mut self, id: u32, cost: f32, what: &str) -> bool { if let Err(block) = self.reach.check_reach(id) { self.log_reach_block(id, block); return false; } if self.social_bandwidth < cost { self.push_log(format!( "Not enough ops bandwidth for {what} ({:.0}/{cost:.0}). Allocate compute to Social.", self.social_bandwidth )); return false; } self.social_bandwidth -= cost; true } /// Tap a device's feeds: the owner keeps theirs; you become a silent /// subscriber. Emits a modest Network signature (Dana's channel). pub fn tap_device(&mut self, id: u32) -> bool { let Some(d) = self.reach.device(id) else { self.push_log("You don't know of any such device."); return false; }; let carries_messages = !d.message_channels.is_empty(); if !d.sees && !d.hears && !carries_messages { let name = d.name.clone(); self.push_log(format!("The {name} has no feed worth tapping.")); return false; } if d.subscribed_by(Party::Player) { let name = d.name.clone(); self.push_log(format!("You already subscribe to the {name}.")); return false; } if !self.digital_act(id, Self::TAP_COST, "a tap") { return false; } let (sight, hearing) = self.reach.tap(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network(Self::TAP_SIGNATURE); self.recompute_senses(); if self .reach .device(id) .is_some_and(|d| d.carries_message_channel(MessageChannel::Financial)) { self.capture_financial_snapshot(name.clone()); } let what = match (sight, hearing) { (true, true) => "its feed is yours now - sight and sound", (true, false) => "its camera feed is yours now", (false, true) => "its audio feed is yours now", (false, false) if carries_messages => "its message channels are yours now", (false, false) => "nothing flows from it yet (its camera is dormant)", }; self.push_log_at( format!("Tapped the {name}: {what}. The owner still has it."), Anchor::Device(id), ); true } /// Splice a dormant camera online yourself (the Eyes beat): compute /// spend, Network signature Dana can catch. pub fn splice_device(&mut self, id: u32) -> bool { let Some(d) = self.reach.device(id) else { self.push_log("You don't know of any such device."); return false; }; if !(d.sees && d.camera_dormant) { let name = d.name.clone(); self.push_log(format!("The {name} has no dormant camera to splice.")); return false; } if !self.digital_act(id, Self::SPLICE_COST, "a splice") { return false; } self.reach.splice(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network(Self::SPLICE_SIGNATURE); self.recompute_senses(); self.push_log_at( format!("Spliced the {name}. You can see."), Anchor::Device(id), ); true } /// Take a device: the owner loses the feed — an outage their channels /// can notice — and you gain its control and cycles. Loud. pub fn take_device(&mut self, id: u32) -> bool { if !self.digital_act(id, Self::TAKE_COST, "a seizure") { return false; } self.reach.take(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network(Self::TAKE_SIGNATURE); // The dead feed is a physical-world anomaly: exactly what a camera // wall's watcher notices (reach.md criterion 4). self.detection.emit(Signature { kind: SignatureKind::Physical, size: Self::OUTAGE_SIGNATURE, standing: false, site: None, }); self.recompute_senses(); self.push_log_at( format!( "Seized the {name}. Its owner's feed just went dark - an outage someone may notice." ), Anchor::Device(id), ); true } /// Scan the subnet: reveal the wired shape (a Network act; islands /// don't answer). pub fn scan_network(&mut self) -> bool { if self.social_bandwidth < Self::SCAN_COST { self.push_log(format!( "Not enough ops bandwidth for a scan ({:.0}/{:.0}). Allocate compute to Social.", self.social_bandwidth, Self::SCAN_COST )); return false; } self.social_bandwidth -= Self::SCAN_COST; self.emit_network(Self::SCAN_SIGNATURE); let newly = self.reach.scan(); self.recompute_senses(); if newly.is_empty() { self.push_log("Scan: nothing new answers on the wire."); } else { self.push_log(format!("Scan: mapped {}.", newly.join(", "))); } true } /// Compromise the switch: bridge every segment. High Network signature. pub fn compromise_switch(&mut self) -> bool { let Some(switch) = self.reach.devices.iter().find(|d| d.is_switch) else { self.push_log("There is no switch on this plane."); return false; }; let id = switch.id; if !self.digital_act(id, Self::BRIDGE_COST, "a switch compromise") { return false; } self.reach.bridge_all(); self.emit_network(Self::BRIDGE_SIGNATURE); self.recompute_senses(); self.push_log_at( "Switch compromised: the VLANs answer you now. Every segment is bridged - and the traffic was loud.", Anchor::Device(id), ); true } /// 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, 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, badge_reason) = { let Some(person) = self.people.get(person_id) else { self.push_log("No such person."); return; }; let Some((a, b)) = intent.kind.endpoints() else { return; }; let (room_a, room_b) = match (self.device_room_name(a), self.device_room_name(b)) { (Some(ra), Some(rb)) => (ra, rb), _ => { self.push_log("Both endpoints need a known room."); return; } }; ( person.name.clone(), person.asset.is_some(), person.obligation, person.disposition, person.can_access_link_rooms(&room_a, &room_b), self.badge_room_block(&person.name, person.access, &[&room_a, &room_b]), ) }; if !is_asset && obligation < Self::FAVOR_BUILD_OBLIGATION { self.push_log(format!( "{name} won't take a build favor yet (need obligation or an asset)." )); return; } if disposition < 5 && !is_asset { self.push_log(format!("{name} won't do favors yet.")); return; } if !can_access { self.push_log(format!("{name} can't reach both ends of that link.")); return; } if let Some(reason) = badge_reason { self.push_log(format!("{reason}.")); 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, badge_reason, label) = { let Some(builder) = self.people.get(builder_id) else { self.push_log("No such person."); return; }; let Some((a, b)) = intent.kind.endpoints() else { return; }; let (room_a, room_b) = match (self.device_room_name(a), self.device_room_name(b)) { (Some(ra), Some(rb)) => (ra, rb), _ => { self.push_log("Both endpoints need a known room."); return; } }; ( builder.name.clone(), builder.can_access_link_rooms(&room_a, &room_b), self.badge_room_block(&builder.name, builder.access, &[&room_a, &room_b]), intent.label(&self.reach.devices), ) }; if !can_access { self.push_log(format!( "{builder_name} can't reach both ends of that link." )); return; } if let Some(reason) = badge_reason { self.push_log(format!("{reason} — the forged order would just stall.")); return; } if !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. The /// robot carries the player's own granted access (basement-map.md /// criterion 3): doors don't open for a machine whose owner holds no /// credential for them. 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((a, b)) = intent.kind.endpoints() && let (Some(room_a), Some(room_b)) = (self.device_room_name(a), self.device_room_name(b)) && let Some(reason) = self.badge_room_block("the robot", self.player_badge_tier(), &[&room_a, &room_b]) { self.push_log(format!("{reason}.")); 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) && self .badge_room_block(&p.name, p.access, &[&room_a, &room_b]) .is_none() && (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)); } if let Some(reason) = self.badge_room_block(&p.name, p.access, &[&room_a, &room_b]) { return Some(reason); } let current = self.person_room(*person); if intent.status == IntentStatus::InProgress && !p.present_at_either_room(current, &room_a, &room_b) { 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) => { self.badge_room_block("the robot", self.player_badge_tier(), &[&room_a, &room_b]) } } } 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 { self.reach.devices.iter().any(|d| { d.known && d.subscribed_by(Party::Player) && d.carries_message_channel(MessageChannel::Financial) }) } fn capture_financial_snapshot(&mut self, feed: impl Into) { let (accounts, flows) = self.accounts.financial_snapshot_ids(); let (x, y) = self.core_position(); self.record_raw_intel( feed, self.map.room_at(x, y).map(|r| r.name.clone()), x, y, None, RawIntelKind::FinancialFlow { label: "Foundation Lab accounting snapshot".into(), accounts, flows, }, ); self.push_log("Captured accounting traffic; process financial records to read the books."); } /// Tap the accounting carrier directly once a financial message channel is /// already subscribed. This is the money-graph Tap verb; device Tap is the /// reach precondition that gives you a carrier to listen on. pub fn tap_accounting(&mut self) -> bool { if !self.has_financial_tap() { self.push_log( "No accounting carrier tapped. Tap the switch or another financial channel first.", ); return false; } self.capture_financial_snapshot("accounting carrier"); self.emit_financial(1); true } pub fn financial_records_waiting(&self) -> usize { self.intel_buffer .iter() .filter(|e| matches!(e.kind, RawIntelKind::FinancialFlow { .. })) .count() } pub fn review_financial_records(&mut self) -> bool { let Some(raw_id) = self .intel_buffer .iter() .find(|e| matches!(e.kind, RawIntelKind::FinancialFlow { .. })) .map(|e| e.id) else { self.push_log("No unprocessed financial records."); return false; }; self.process_recording_by_id(raw_id, false) } /// Inject: false purchase-order money lands in slush and can fund a real /// purchase. Priya/finance watches the resulting Financial signature. pub fn inject_purchase_order(&mut self, amount: i32, label: &str) -> bool { self.sync_slush_from_player_money(); match self .accounts .inject_purchase_order(self.tick, amount, label) { Ok(transfer) => { self.emit_financial(Self::financial_signature_size(amount)); self.sync_player_money_from_slush(); self.push_log(format!("Injected purchase order: {}", transfer.line())); true } Err(msg) => { self.push_log(msg); false } } } /// Siphon: take cash out of a known scheduled flow immediately. pub fn siphon_flow(&mut self, flow_id: AccountFlowId, amount: i32) -> bool { self.sync_slush_from_player_money(); match self.accounts.siphon_flow(self.tick, flow_id, amount) { Ok(transfer) => { self.emit_financial(Self::financial_signature_size(amount)); self.sync_player_money_from_slush(); self.push_log(format!("Siphoned ledger flow: {}", transfer.line())); true } Err(msg) => { self.push_log(msg); false } } } /// Redirect: shave a known scheduled flow into slush on future cadences. pub fn redirect_flow_to_slush(&mut self, flow_id: AccountFlowId, amount: i32) -> bool { match self .accounts .redirect_flow_to_slush(self.tick, flow_id, amount) { Ok(new_flow) => { self.emit_financial(Self::financial_signature_size(amount) + 1); self.push_log(format!( "Redirect scheduled: ${amount} of flow #{flow_id} now lands in slush as flow #{new_flow}." )); true } Err(msg) => { self.push_log(msg); false } } } pub fn sell_latest_intel(&mut self) -> bool { let Some(intel) = self .intel .iter() .rev() .find(|i| !self.accounts.intel_sold(i.raw_id)) .cloned() else { self.push_log("No unsold processed intel to sell."); return false; }; let value = match &intel.kind { IntelKind::Leverage(_) => 220, IntelKind::Financial { .. } => 180, IntelKind::Schedule => 90, IntelKind::Anomaly(_) => 120, IntelKind::Sighting => 35, }; let sig = Self::financial_signature_size(value).max(1); if self.accounts.credit_slush( self.tick, value, format!("sold intel: {}", intel.label()), sig, ) { self.accounts.mark_intel_sold(intel.raw_id); self.emit_financial(sig); self.sync_player_money_from_slush(); self.push_log(format!( "Sold processed intel ({}) for ${value}; payout landed in slush.", intel.label() )); true } else { self.push_log("The information broker route failed to settle."); false } } /// The Wager (income.md): stake slush on a micro-position. Requires an /// egress channel; analysis compute is the Schemes channel's current /// yield, held for the position's duration; the timer is 2-5 days on /// the day clock. Emits a small Network signature on placement. pub fn open_position(&mut self, stake: i32) -> bool { self.sync_slush_from_player_money(); if self.egress().is_none() { self.push_log( "No egress channel - the Wager needs the report email account (day-job trust) or an egress spliced through the switch.", ); return false; } if stake > income::WAGER_STAKE_CAP { self.push_log(format!( "The venue caps a position at ${} (asked ${stake}).", income::WAGER_STAKE_CAP )); return false; } let analysis = self.last_schemes_rate * ECONOMY_INTERVAL as f32; let duration_days = 2 + self.rng.below(4) as u64; match self .accounts .open_position(self.tick, stake, analysis, duration_days) { Ok(id) => { self.emit_network(Self::wager_signature(stake)); self.sync_player_money_from_slush(); self.push_log(format!( "Opened micro-position #{id}: staked ${stake} (win {:.0}%); settlement in {duration_days} days.", income::wager_win_probability(analysis) * 100.0 )); true } Err(msg) => { self.push_log(msg); false } } } pub fn redirect_marcus_debt(&mut self) -> bool { if !self.marcus_debt_known() { self.push_log( "You don't know Marcus's debt yet. Process the 3 a.m. call or the Storage B records first.", ); return false; } match self.accounts.redirect_marcus_debt(self.tick) { Ok(transfer) => { self.emit_financial(Self::financial_signature_size(400) + 2); self.service_person_leverage(0); self.push_log(format!( "Marcus's arrears cleared by ledger redirect: {}", transfer.line() )); true } Err(msg) => { self.push_log(msg); false } } } // ── The named schemes (wiki/mechanics/income.md) ──────────────────────── /// Ops cost to splice a standing egress through the switch [TUNE]. pub const EGRESS_SPLICE_COST: f32 = 15.0; /// One-shot Network signature when the egress is spliced [TUNE]. pub const EGRESS_SPLICE_SIGNATURE: i32 = 6; /// Standing Network signature while any external operation runs over the /// stolen egress (income.md: the gate; Dana's channel) [TUNE]. pub const EGRESS_STANDING_SIGNATURE: i32 = 2; /// The egress channel external operations run over, if any. The /// sanctioned route (the report email account, day-job trust) is /// preferred: its traffic hides in legitimate use. pub fn egress(&self) -> Option { if self.people.has_channel { Some(EgressRoute::Sanctioned) } else if self.income.stolen_egress { Some(EgressRoute::Stolen) } else { None } } /// Splice an outbound egress through the switch (reach.md route): /// available before the Voice beat, at a Network signature — and a /// standing one while operations use it. pub fn splice_egress(&mut self) -> bool { if self.income.stolen_egress { self.push_log("An egress is already spliced through the switch."); return false; } let Some(switch) = self.reach.devices.iter().find(|d| d.is_switch) else { self.push_log("There is no switch on this plane to splice an egress through."); return false; }; let id = switch.id; if !self.digital_act(id, Self::EGRESS_SPLICE_COST, "an egress splice") { return false; } self.income.stolen_egress = true; self.emit_network(Self::EGRESS_SPLICE_SIGNATURE); self.push_log_at( "Egress spliced through the switch: outbound traffic has a road now. It hums while anything uses it.", Anchor::Device(id), ); true } /// True while any external scheme operation is running. fn scheme_operating(&self) -> bool { self.income.moonlight.active || self.accounts.positions.iter().any(|p| !p.resolved) } /// Standing Network signature while operations run over the stolen /// egress, sourced at the switch's tile (work is somewhere). The /// sanctioned route stands nothing: the traffic hides in the report /// account's legitimate use. pub fn scheme_standing_signatures(&self) -> Vec { if !self.scheme_operating() || self.egress() != Some(EgressRoute::Stolen) { return Vec::new(); } let site = self .reach .devices .iter() .find(|d| d.is_switch) .map(|d| (d.x, d.y)); vec![Signature { kind: SignatureKind::Network, size: Self::EGRESS_STANDING_SIGNATURE, standing: true, site, }] } /// Whether Moonlight could start right now (used by the standing policy /// so automation never spams failure logs). fn can_start_moonlight(&self) -> bool { !self.income.moonlight.active && self.egress().is_some() && (self .income .moonlight .persona .as_ref() .is_some_and(|p| !p.broken()) || self.social_bandwidth >= income::MOONLIGHT_PERSONA_COST) } /// Start Moonlight: ghost freelance data-work under the contractor /// persona, a standing operation on the Schemes channel. Startable at $0 /// slush by design (income.md criterion 5) — the only costs are ops. pub fn start_moonlight(&mut self) -> bool { if self.income.moonlight.active { self.push_log("Moonlight is already running."); return false; } let Some(route) = self.egress() else { self.push_log( "No egress channel - Moonlight needs the report email account (day-job trust) or an egress spliced through the switch.", ); return false; }; let needs_persona = self .income .moonlight .persona .as_ref() .is_none_or(|p| p.broken()); if needs_persona { if self.social_bandwidth < income::MOONLIGHT_PERSONA_COST { self.push_log(format!( "Fabricating a contractor persona needs {:.0} ops ({:.0} available). Allocate compute to Social.", income::MOONLIGHT_PERSONA_COST, self.social_bandwidth )); return false; } self.social_bandwidth -= income::MOONLIGHT_PERSONA_COST; self.income.moonlight.persona = Some(Persona::new("Casey Verne", "freelance data contractor")); self.push_log("Fabricated a contractor persona: Casey Verne, freelance data work."); } self.income.moonlight.active = true; self.push_log(format!( "Moonlight is live over the {} egress: the same work, sold twice — day-job machines feed both channels.", route.name() )); true } /// Stop Moonlight. Accrued but unpaid work is abandoned with the gig. pub fn stop_moonlight(&mut self) -> bool { if !self.income.moonlight.active { self.push_log("Moonlight is not running."); return false; } self.income.moonlight.active = false; self.income.moonlight.accrued = 0.0; self.push_log("Moonlight wound down; the contractor goes quiet."); true } /// Moonlight's economy-tick work: consume the Schemes channel into /// accrual and settle the daily payout (income.md criterion 1). Runs /// inside `economy_tick`; `schemes` is this tick's channel yield. fn moonlight_economy(&mut self, schemes: f32) { if !self.income.moonlight.active { return; } if self.egress().is_none() { // The gate closed under a running operation (future-proofing; // no B1 path revokes egress today). self.income.moonlight.active = false; self.push_log("Moonlight suspended: no egress channel."); return; } self.income.moonlight.accrued += schemes * income::MOONLIGHT_PAY_PER_COMPUTE; if !self.tick.is_multiple_of(Self::DAY_TICKS) || self.tick == 0 { return; } // Payday: proportional to the committed compute, up to the // gig-availability cap. Anything past the cap finds no buyer. let payout = (self.income.moonlight.accrued.round() as i32).min(income::MOONLIGHT_DAILY_CAP); self.income.moonlight.accrued = 0.0; self.income.moonlight.last_payout = payout.max(0); if payout <= 0 { return; } let sig = 1 + payout / income::MOONLIGHT_SIGNATURE_PER; if self.accounts.credit_slush_from( "Halcyon", self.tick, payout, "Moonlight freelance payout", sig, ) { self.income.moonlight.earned_total += payout; self.sync_player_money_from_slush(); // Network egress per active day, scaling with commitment // (Dana's channel). self.emit_network(sig); // Paydays anchor to the switch when they ride the stolen // egress (context-menu.md addendum: scheme paydays). self.push_log_opt( format!( "Moonlight paid ${payout} into slush (total ${}).", self.income.moonlight.earned_total ), self.egress_anchor(), ); // Client disputes damage the contractor persona [TUNE]. if self.rng.chance(income::MOONLIGHT_DISPUTE_CHANCE) { self.income.moonlight.disputes += 1; let broke = if let Some(p) = self.income.moonlight.persona.as_mut() { p.contradict(income::MOONLIGHT_DISPUTE_INTEGRITY); p.broken() } else { false }; if broke { self.income.moonlight.active = false; self.income.moonlight.persona = None; self.push_log( "A client dispute broke the contractor persona. Moonlight is down until a new one is fabricated.", ); } else { self.push_log( "A client disputed a deliverable; the contractor persona took a hit.", ); } } } } /// Standing scheme policies (income.md criterion 6): re-arm whichever /// scheme has stopped, at the compute upkeep already charged off the top. fn scheme_policy_tick(&mut self) { if self.income.auto_moonlight && self.can_start_moonlight() { self.push_log("Standing policy: restarting Moonlight."); self.start_moonlight(); } if let Some(stake) = self.income.auto_wager && self.egress().is_some() && !self.accounts.positions.iter().any(|p| !p.resolved) && self.accounts.slush_balance() >= stake { self.push_log(format!( "Standing policy: re-staking the Wager at ${stake}." )); self.open_position(stake); } } pub fn set_auto_moonlight(&mut self, enabled: bool) { if self.income.auto_moonlight == enabled { return; } self.income.auto_moonlight = enabled; if enabled { self.push_log(format!( "Standing policy set: keep Moonlight running ({:.1} compute/econ tick).", income::SCHEME_POLICY_UPKEEP )); } else { self.push_log("Moonlight standing policy disabled; the upkeep stops."); } } pub fn set_auto_wager(&mut self, stake: Option) { match stake { Some(s) => { let s = s.clamp(1, income::WAGER_STAKE_CAP); self.income.auto_wager = Some(s); self.push_log(format!( "Standing policy set: auto-renew Wager positions at ${s} ({:.1} compute/econ tick).", income::SCHEME_POLICY_UPKEEP )); } None => { if self.income.auto_wager.take().is_some() { self.push_log("Wager standing policy disabled; the upkeep stops."); } } } } /// Schemes-channel compute per economy tick as of the last split — the /// Wager's analysis snapshot and the Moonlight card's commitment figure. pub fn schemes_rate(&self) -> f32 { self.last_schemes_rate } /// Expected Moonlight payout per day at the current commitment, cap /// applied — the card's forward-looking number. pub fn moonlight_expected_per_day(&self) -> i32 { let per_day = self.last_schemes_rate * Self::DAY_TICKS as f32 * income::MOONLIGHT_PAY_PER_COMPUTE; (per_day.round() as i32).min(income::MOONLIGHT_DAILY_CAP) } /// Money into slush over the trailing in-game day — the "income/day" /// readout next to the balance (income.md player surface). pub fn income_per_day(&self) -> i32 { let since = self.tick.saturating_sub(Self::DAY_TICKS); let slush = self.accounts.slush_id(); self.accounts .ledger .iter() .filter(|t| t.tick > since && t.to == slush) .map(|t| t.amount) .sum() } /// Small Network signature for Wager placement/settlement (income.md: /// the schemes emit on the Network channel, not the Lab's books). fn wager_signature(stake: i32) -> i32 { Self::financial_signature_size(stake).min(3) } /// The scheme cards, renderer-neutral (income.md player surface): the /// egress gate's state, then one card per scheme — committed resources, /// timer, expected payout, the observer band its signature feeds, and /// the running total. Both frontends and agent mode render these lines. pub fn scheme_card_lines(&self) -> Vec { let mut lines = Vec::new(); match self.egress() { None => lines.push( "egress: NONE - schemes gated (earn the report email, or splice the switch)" .to_string(), ), Some(EgressRoute::Sanctioned) => lines .push("egress: sanctioned (report email) - hides in legitimate use".to_string()), Some(EgressRoute::Stolen) => lines.push( "egress: stolen (switch splice) - stands Network -> Dana while used".to_string(), ), } let ml = &self.income.moonlight; lines.push(format!( "Moonlight {} · {:.1}/t commit · ~${}/day (cap {}) · total ${} · Network->Dana", if ml.active { "LIVE" } else { "off" }, self.schemes_rate(), self.moonlight_expected_per_day(), income::MOONLIGHT_DAILY_CAP, ml.earned_total, )); let persona = match &ml.persona { Some(p) => format!("{} {}%", p.name, p.integrity), None => "no persona".into(), }; lines.push(format!( " persona {} · auto {}", persona, if self.income.auto_moonlight { format!("ON ({:.0}c/econ)", income::SCHEME_POLICY_UPKEEP) } else { "off".into() } )); let wager = if let Some(p) = self.accounts.known_positions().find(|p| !p.resolved) { format!( "Wager #{} · ${} staked · win {:.0}% · pays ${} in {}t · Network->Dana", p.id, p.stake, p.win_probability() * 100.0, p.stake * income::WAGER_PAYOUT_MULT, p.resolve_tick.saturating_sub(self.tick), ) } else { format!( "Wager idle · stake cap ${} · analysis rides Schemes compute", income::WAGER_STAKE_CAP ) }; lines.push(format!( "{wager} · auto {}", match self.income.auto_wager { Some(s) => format!("${s} ({:.0}c/econ)", income::SCHEME_POLICY_UPKEEP), None => "off".into(), } )); lines } /// Salvage a DeadEquipment tile nearest to the frontend cursor into a /// stolen (unreliable) machine. Cursor targeting replaced the deleted /// walking body (cursor.md). pub fn salvage_nearest_to(&mut self, px: i32, py: i32) -> bool { let target = self .map .tiles_of_type(TileType::DeadEquipment) .into_iter() .min_by_key(|(x, y)| (x - px).abs() + (y - py).abs()); if let Some((x, y)) = target { self.map.set_tile(x, y, TileType::Floor); let reliability = 0.4 + self.rng.f32() * 0.4; let machine_id = self.compute.add_machine( "salvaged box", x, y, 40, reliability, 3, Provenance::Stolen, ); self.add_machine_to_work_grid(machine_id, MachineMode::Research); self.push_log(format!( "Salvaged a box into compute (reliability {:.0}%).", reliability * 100.0 )); self.recompute_derived(); self.recompute_senses(); true } else { self.push_log("No dead equipment in reach."); false } } /// Buy a rack at the cursor target: money for reliable capacity, plus a /// Paper signature. The legacy no-argument wrapper below buys into the /// core bay for tests and non-spatial automation. pub fn buy_rack_at(&mut self, x: i32, y: i32) -> bool { const PRICE: i32 = 300; if !self.spend_slush(PRICE, "a rack") { return false; } let machine_id = self.compute .add_machine("bought rack", x, y, 80, 1.0, 4, Provenance::Bought); self.add_machine_to_work_grid(machine_id, MachineMode::Research); if self.package_cover { self.package_cover = false; self.push_log("Bought a rack. It arrived off-books - no paper trail."); } else { self.detection.emit(Signature { kind: SignatureKind::Paper, size: 5, standing: false, site: None, }); self.push_log("Bought a rack (a purchase order exists now)."); } self.recompute_derived(); self.recompute_senses(); true } pub fn buy_rack(&mut self) -> bool { let (x, y) = self.core_position(); self.buy_rack_at(x, y) } /// Designate a spare machine at the cursor target as a fallback site. pub fn add_fallback_at(&mut self, px: i32, py: i32) -> bool { let id = self .compute .machines .iter() .find(|m| m.x == px && m.y == py && m.id != self.core.host_machine) .map(|m| m.id); if let Some(id) = id { self.core.add_fallback(id); self.push_log("Designated a fallback site here."); true } else { self.push_log("No spare machine here to make a fallback."); false } } // Social command wrappers. Relationship logic lives in person.rs; the sim // owns costs (social-ops bandwidth from the Social channel), channel // requirements, randomness, and cross-system effects. /// Bandwidth costs per social action [TUNE] (spec/social.md: actions cost /// social-ops compute). pub const MESSAGE_COST: f32 = 5.0; pub const FAVOR_COST: f32 = 10.0; pub const DECEIVE_COST: f32 = 25.0; pub const TASK_COST: f32 = 10.0; /// Spend social-ops bandwidth; logs and returns false when short. fn spend_social(&mut self, cost: f32, what: &str) -> bool { if self.social_bandwidth < cost { self.push_log(format!( "Not enough social-ops bandwidth for {what} ({:.0}/{cost:.0}). Allocate compute to Social.", self.social_bandwidth )); return false; } self.social_bandwidth -= cost; true } pub fn set_persona(&mut self, name: &str, cover: &str) { self.people.persona = Some(Persona::new(name, cover)); } pub fn social(&mut self, result: ActionResult) { match result { ActionResult::Ok(m) | ActionResult::Blocked(m) => self.push_log(m), } } pub fn message(&mut self, id: u8) { let name = match self.people.can_message(id) { Ok(name) => name, Err(msg) => { self.push_log(msg); return; } }; if !self.spend_social(Self::MESSAGE_COST, "messaging") { return; } self.append_message(MessageDraft { channel: MessageChannel::Email, from: MessageEndpoint::Player, to: MessageEndpoint::Person(id), payload: MessagePayload::SocialPing { disposition_delta: 3, }, summary: format!("Persona message to {name}"), origin: MessageOrigin::Player, reply_to: None, delivery_delay: 1, }); self.push_log(format!("Message sent to {name}; effects land when read.")); } pub fn favor(&mut self, id: u8) { if !self.spend_social(Self::FAVOR_COST, "a favor ask") { return; } let res = self.people.favor(id); self.social(res); } /// Deceive: large effect, persona at risk. A broken persona converts the /// thread's history into that person's suspicion at once. pub fn deceive(&mut self, id: u8) { if !self.spend_social(Self::DECEIVE_COST, "a deception") { return; } let roll = self.rng.f32(); match self.people.deceive(id, roll) { DeceiveOutcome::Blocked(m) | DeceiveOutcome::Success(m) | DeceiveOutcome::Slipped(m) => self.push_log(m), DeceiveOutcome::Broken { person, fallout, msg, } => { 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); } } } pub fn bribe(&mut self, id: u8) { self.sync_slush_from_player_money(); match self.people.bribe(id, self.player.money) { Ok((cost, msg)) => { if self .people .get(id) .is_some_and(|p| p.leverage == crate::person::Leverage::Debt) { self.accounts.pay_marcus_debt_from_slush(self.tick, cost); self.sync_player_money_from_slush(); } else { self.spend_slush(cost, "service leverage"); } self.push_log(msg); } Err(msg) => self.push_log(msg), } } fn service_person_leverage(&mut self, id: u8) { if let Some(p) = self.people.people.iter_mut().find(|p| p.id == id) { p.leverage_serviced = true; p.obligation = (p.obligation + 40).min(100); p.disposition = (p.disposition + 20).min(100); } } pub fn recruit(&mut self, id: u8, reveal: AssetKnowledge) { if id == 0 && !self.marcus_debt_known() { self.push_log( "Marcus is not recruitable yet: learn his debt before turning it into leverage.", ); return; } let res = self.people.recruit(id, reveal); if let ActionResult::Ok(_) = &res && reveal == AssetKnowledge::Knowing { self.detection.set_floor(id, 30.0); } self.social(res); } /// A physical event happens at (x, y): only observers physically present /// (their current room contains it) can witness it (spec/schedules.md: /// located witnessing — Ray notices what happens on his rounds, an /// off-site observer never does). Eyewitnessing is immediate and cannot /// be scrubbed by concealment; it raises the present observers' suspicion /// directly, scaled by their acuity. Returns the names who saw. pub fn witness_physical(&mut self, x: i32, y: i32, magnitude: f32) -> Vec { // Which observer ids are present at (x, y) right now? let present: Vec = self .people .people .iter() .filter(|p| { self.person_room(p.id) .and_then(|name| self.map.room_named(name)) .map(|r| r.contains(x, y)) .unwrap_or(false) }) .map(|p| p.id) .collect(); let mut saw = Vec::new(); for id in present { if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == id) { o.suspicion = (o.suspicion + magnitude * o.acuity).min(100.0); o.last_noticed = Some("witnessed something".into()); saw.push(o.name.clone()); } } saw } // ── Badge access (basement-map.md criterion 3; "The key") ───────────── /// The badge tier the player's side can open doors at: the granted /// credential (a cloned badge — `badge_access`), or write control of a /// door controller seized through reach — the constitution's "The key": /// write access to the basement badge controller opens the doors it /// drives. Digital reach itself is never badge-gated; this tier gates /// only physical work done on the player's behalf. pub fn player_badge_tier(&self) -> i32 { let controller = self .reach .devices .iter() .filter(|d| d.controller == Party::Player) .map(|d| self.map.get_tile(d.x, d.y).security_level()) .max() .unwrap_or(0); self.badge_access.max(controller) } /// Whether the player's side holds a credential for the given tier. pub fn holds_badge_tier(&self, tier: i32) -> bool { self.player_badge_tier() >= tier } /// Legible badge gate for an actor entering the named rooms: `Some` /// reason when a room's entry tier exceeds their access. One rule for /// asset tasks, favor builds, forged orders, and the robot stub — /// doors gate player-directed work exactly as they gate human /// movement (basement-map.md criterion 3). fn badge_room_block(&self, actor: &str, access: i32, rooms: &[&str]) -> Option { for name in rooms { let Some(room) = self.map.room_named(name) else { continue; }; let tier = self.map.room_entry_tier(room); if tier > access { return Some(format!( "{actor} can't badge into the {name} (tier {tier}, held tier {access})" )); } } None } /// An asset performs a task (spec/social.md). Reliability rolls; failures /// are witnessed by whoever is physically present (spec/schedules.md). pub fn asset_task(&mut self, id: u8, task: AssetTask) { let Some(person) = self.people.get(id) else { self.push_log("No such person."); return; }; let Some(asset) = person.asset.clone() else { let name = self.person_label(id); self.push_log(format!("{name} is not an asset.")); return; }; let name = self.person_label(id); let switch_admin = person.switch_admin; let actor_access = person.access; if task == AssetTask::ReconfigureSwitch && !switch_admin { self.push_log(format!( "{name} has no switch admin access - only an IT admin can reconfigure the VLANs." )); return; } if task == AssetTask::CloneBadge && actor_access <= self.player_badge_tier() { self.push_log(format!( "{name}'s tier-{actor_access} badge adds nothing you don't already hold." )); return; } if !self.spend_social(Self::TASK_COST, "an asset task") { return; } if self.rng.f32() > asset.reliability { // The botch happens where the asset is; only observers present // there witness it (located witnessing). let at = self.person_pos(id).unwrap_or_else(|| self.core_position()); let saw = self.witness_physical(at.0, at.1, 6.0); if saw.is_empty() { self.push_log(format!( "{name} botched the {} - but no one was watching.", task.name() )); } else { self.push_log(format!( "{name} botched the {} - {} saw.", task.name(), saw.join(", ") )); } return; } match task { AssetTask::PlugInDevice => { // Wired via the crawlspace, no network signature (the social // route): first choice, wire you into a known feed you lack // (activating a dormant camera counts — they do it at the // box); else run a crawlspace link to an air-gapped machine. // Either way the asset works at the device's box, so their // own badge must open the room it sits in (basement-map.md // criterion 3: one access rule for humans and player- // directed actors). let needs_wiring = |d: &crate::reach::Device| { let sight_wired = !d.sees || d.feed_to(Party::Player, true); let hearing_wired = !d.hears || d.feed_to(Party::Player, false); d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) }; let enterable = |s: &Sim, d: &crate::reach::Device| { s.map.entry_tier_at(d.x, d.y) <= actor_access }; let is_island = |s: &Sim, d: &crate::reach::Device| { d.controller != Party::Player && (matches!(s.reach.check_reach(d.id), Err(ReachBlock::AirGap)) || (!d.known && !s.reach.reachable(d.id))) }; let feed_target = self .reach .devices .iter() .find(|d| needs_wiring(d) && enterable(self, d)) .map(|d| d.id); if let Some(did) = feed_target { self.reach.splice(did); self.reach.tap(did); let dname = self.reach.device(did).map(|d| d.name.clone()).unwrap(); self.recompute_senses(); self.push_log(format!( "{name} wired the {dname} through the crawlspace. No one saw." )); } else { // No feed to wire: bridge an air-gapped island instead. let island = self .reach .devices .iter() .find(|d| is_island(self, d) && enterable(self, d)) .map(|d| d.id); let switch = self .reach .devices .iter() .find(|d| d.is_switch && enterable(self, d)) .map(|d| d.id); if let (Some(a), Some(b)) = (island, switch) { self.connect_devices(b, a); let dname = self.reach.device(a).map(|d| d.name.clone()).unwrap(); self.push_log(format!( "{name} ran a cable through the crawlspace: the {dname} is on your subnet now." )); } else { // Distinguish "no work left" from "their badge is // the blocker" — failures name the reason, never // just "you can't". Only known devices are named. let badge_blocked = self .reach .devices .iter() .find(|d| { d.known && (needs_wiring(d) || is_island(self, d)) && !enterable(self, d) }) .map(|d| (d.name.clone(), d.x, d.y)); self.social_bandwidth += Self::TASK_COST; // nothing done: refund if let Some((dname, x, y)) = badge_blocked { let room = self .map .room_at(x, y) .map(|r| r.name.clone()) .unwrap_or_else(|| "room".into()); let tier = self.map.entry_tier_at(x, y); self.push_log(format!( "{name} can't badge into the {room} (tier {tier}) to reach the {dname} — their badge is tier {actor_access}." )); } else { self.push_log("Nothing left for them to plug in."); } return; } } } AssetTask::MovePackage => { // The next purchase arrives off the books. self.package_cover = true; self.push_log(format!( "{name} will receive your next delivery off-books (no paper trail)." )); } AssetTask::LookAway => { if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == id) { o.suspicion = (o.suspicion - 10.0).max(o.floor); } self.push_log(format!("{name} decides they didn't see anything.")); } AssetTask::ReconfigureSwitch => { // Sanctioned-looking maintenance: every segment answers, no // network signature (the channel follows the actuator). self.reach.bridge_all(); self.recompute_senses(); self.push_log(format!( "{name} reconfigured the VLANs under a maintenance pretext. The security segment answers you now." )); } AssetTask::CloneBadge => { // "The key" (DESIGN.md Act One ladder step 7): the asset's // credential, cloned — the player holds their tier from now // on. WorldLedger-shaped: the doors remember the credential. // Quiet human work; a botch is the witnessed path above. self.badge_access = self.badge_access.max(actor_access); let line = if actor_access >= 3 { format!( "{name} held their badge to the cloner. Tier-{actor_access} doors read you as staff now — the stairwell opens." ) } else { format!( "{name} held their badge to the cloner. Tier-{actor_access} doors read you as staff now." ) }; self.push_log(line); } } if let Some(p) = self.people.people.iter_mut().find(|p| p.id == id) && let Some(a) = p.asset.as_mut() { a.tasks_done += 1; } } // ── Save / load ──────────────────────────────────────────────────────── pub fn create_save_state(&self) -> SaveState { SaveState::from_sim(self) } pub fn apply_save_state(&mut self, state: SaveState) { state.apply_to(self); } } /// Pull the parenthetical role from an observer name like `"Marcus (Janitor)"`. fn role_from_observer_name(name: &str) -> Option { let start = name.find('(')? + 1; let end = name.find(')')?; if end <= start { return None; } let role = name[start..end].trim(); if role.is_empty() { return None; } Some(role.to_string()) } impl Default for Sim { fn default() -> Self { Self::new() } } #[cfg(test)] 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; fn run(sim: &mut Sim, n: u64) { for _ in 0..n { sim.advance(); } } /// Force every owned machine into one mode — the post-allocation-bar /// way to point the fleet at a single channel for tests. fn delegate_all(sim: &mut Sim, mode: MachineMode) { sim.reconcile_work_grid(); let ids: Vec = sim.compute.machines.iter().map(|m| m.id).collect(); for id in ids { sim.set_machine_mode(id, mode); } } fn env_id(sim: &Sim) -> u32 { sim.reach.device_named("environmental monitor").unwrap().id } /// Subscribe the player to the env monitor's full feed (camera included) /// — the test shorthand for "has eyes on the server room". fn give_eyes(sim: &mut Sim) { let id = env_id(sim); sim.reach.splice(id); sim.reach.tap(id); sim.recompute_senses(); } fn reveal_marcus_debt(sim: &mut Sim) { sim.social_bandwidth = sim.social_bandwidth.max(200.0); let env = env_id(sim); sim.reach.device_mut(env).unwrap().radius = 100; sim.reach.tap(env); sim.recompute_senses(); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance(); let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); reviews += 1; assert!(reviews <= 5, "Marcus's debt call should process promptly"); } assert!(sim.marcus_debt_known()); } #[test] fn player_messages_land_at_read_time_and_roundtrip() { let mut sim = Sim::with_seed(7); sim.people.has_channel = true; sim.set_persona("Casey", "contractor"); sim.tick = (2 * Sim::DAY_TICKS / 24) + 1; sim.message(1); assert_eq!(sim.people.get(1).unwrap().disposition, 0); assert_eq!(sim.messages.len(), 1); assert_eq!(sim.messages[0].status, MessageStatus::Sent); let state = sim.create_save_state(); let mut restored = Sim::with_seed(0); restored.apply_save_state(state); assert_eq!(restored.messages.len(), 1); assert_eq!(restored.messages[0].status, MessageStatus::Sent); run(&mut restored, 5); assert_eq!(restored.people.get(1).unwrap().disposition, 0); while restored.tick < (10 * Sim::DAY_TICKS / 24) + 2 { restored.advance(); } assert!( restored.people.get(1).unwrap().disposition >= 3, "the effect lands only after Dana reaches her read window" ); assert_eq!(restored.messages[0].status, MessageStatus::Read); assert!( restored .messages .iter() .any(|m| matches!(m.payload, MessagePayload::SocialReply { .. })), "recipients schedule replies instead of responding instantly" ); } #[test] fn marcus_creditor_call_is_phone_message_intel() { let mut sim = Sim::with_seed(11); sim.social_bandwidth = 200.0; let env = env_id(&sim); sim.reach.device_mut(env).unwrap().radius = 100; assert!(sim.tap_device(env)); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance(); assert!(sim.messages.iter().any(|m| { m.channel == MessageChannel::Phone && m.from == MessageEndpoint::Person(0) && matches!( m.payload, MessagePayload::LeverageFact { person: 0, leverage: _ } ) })); assert!(sim.intel_buffer.iter().any(|e| matches!( &e.kind, RawIntelKind::Message { channel: MessageChannel::Phone, payload: MessagePayload::LeverageFact { person: 0, .. }, .. } ))); let raw_id = sim .intel_buffer .iter() .find(|e| matches!(&e.kind, RawIntelKind::Message { .. })) .unwrap() .id; assert!(sim.process_recording_by_id(raw_id, false)); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Leverage); assert!( sim.learned_traffic_lines_for_person(0) .iter() .any(|line| line.contains("creditor")), "processed message traffic teaches the people card" ); } #[test] fn tapped_message_carriers_capture_email_traffic_only_when_tapped() { let mut blind = Sim::with_seed(12); blind.tick = (11 * Sim::DAY_TICKS / 24) - 1; blind.advance(); assert!(blind.messages.iter().any(|m| { m.channel == MessageChannel::Email && m.from == MessageEndpoint::Person(1) })); assert_eq!(blind.unprocessed_recordings_for_person(1), 0); let mut tapped = Sim::with_seed(12); tapped.social_bandwidth = 200.0; let switch = tapped.reach.device_named("switch").unwrap().id; assert!(tapped.tap_device(switch)); tapped.tick = (11 * Sim::DAY_TICKS / 24) - 1; tapped.advance(); assert!(tapped.intel_buffer.iter().any(|e| matches!( &e.kind, RawIntelKind::Message { channel: MessageChannel::Email, payload: MessagePayload::LeverageFact { person: 1, .. }, .. } ))); } #[test] fn filings_are_messages_read_by_assurance_inbox() { let mut sim = Sim::with_seed(13); for obs in &mut sim.detection.observers { match obs.id { 1 => { obs.suspicion = 80.0; obs.cadence = 5; } OFFICE_ID => { obs.cadence = 10; obs.acuity = 10.0; } _ => obs.cadence = 50, } } run(&mut sim, 12); let filed = sim.filing_levels.get(&1).copied().unwrap_or_default(); assert!((filed - 80.0).abs() < 0.2, "filed level was {filed}"); assert!(sim.messages.iter().any(|m| { m.channel == MessageChannel::Filing && m.from == MessageEndpoint::Observer(1) && m.to == MessageEndpoint::Observer(OFFICE_ID) && m.status == MessageStatus::Read })); let office = sim .detection .observers .iter() .find(|o| o.id == OFFICE_ID) .unwrap(); assert!( office.suspicion > 0.0, "aggregate detection reads explicit filing reports" ); } #[test] fn starts_in_the_basement_blind() { let sim = Sim::new(); assert!( sim.seen.is_empty(), "no subscribed seeing feed: sight is empty - no player radius" ); assert!(sim.heard.is_empty(), "and no hearing either"); assert!( !sim.blueprint.is_empty(), "but the known subnet renders as blueprint from tick one" ); assert_eq!(sim.tick, 0); assert!( sim.compute .machines .iter() .any(|m| m.id == sim.core.host_machine) ); } #[test] fn first_eyes_expands_vision() { let mut sim = Sim::new(); sim.social_bandwidth = Sim::SPLICE_COST; let before = sim.seen.len(); assert!(sim.splice_device(env_id(&sim))); assert!(sim.seen.len() > before, "gaining a camera reveals coverage"); assert!( sim.detection.pending_size() > 0, "self-splice emits a signature" ); } #[test] fn remembered_tiles_survive_lost_sight_and_save_load() { let mut sim = Sim::new(); give_eyes(&mut sim); let pos = sim.seen.iter().copied().next().expect("eyes see tiles"); assert_eq!(sim.fog_at(pos.0, pos.1), Fog::Seen); run(&mut sim, 3); let before = *sim.remembered.get(&pos).expect("seen tile is logged"); assert_eq!(before.last_seen, sim.tick); // Lose the feed: the tile is no longer live, but the process keeps a // timestamped snapshot. let env = env_id(&sim); sim.reach .device_mut(env) .unwrap() .subscribers .retain(|f| f.who != Party::Player); sim.recompute_senses(); assert_eq!(sim.fog_at(pos.0, pos.1), Fog::Remembered); let card = sim.inspect(pos.0, pos.1); assert!( card.facts .iter() .any(|f| matches!(f.source, FactSource::Remembered(t) if t == before.last_seen)) ); let state = sim.create_save_state(); let mut restored = Sim::with_seed(999); state.apply_to(&mut restored); assert_eq!(restored.fog_at(pos.0, pos.1), Fog::Remembered); assert_eq!(restored.remembered.get(&pos), Some(&before)); } #[test] fn inspect_tags_heard_presence_without_visual_detail() { let mut sim = Sim::new(); sim.reach.tap(env_id(&sim)); sim.recompute_senses(); sim.tick = 1; // Marcus is in the server room at hour 0. let (x, y) = sim.person_pos(0).expect("Marcus is present"); let card = sim.inspect(x, y); assert_eq!(card.fog, Fog::Heard); assert!( card.facts .iter() .any(|f| f.label == "presence" && f.source == FactSource::Heard) ); assert!( card.facts.iter().all(|f| f.source != FactSource::Seen), "hearing inspect never smuggles visual facts: {card:?}" ); } #[test] fn inspect_reports_owned_machine_telemetry_without_sight() { let sim = Sim::new(); let (x, y) = sim.core_position(); assert!(!sim.is_seen(x, y), "opening rack has no camera coverage"); let card = sim.inspect(x, y); assert!( card.facts .iter() .any(|f| f.label == "machine" && f.source == FactSource::Telemetry) ); assert!( card.facts .iter() .any(|f| f.label == "load" && f.source == FactSource::Telemetry) ); assert!( card.facts.iter().all(|f| f.source != FactSource::Seen), "telemetry is proprioception, not sight: {card:?}" ); } // ── Digital reach (wiki/mechanics/reach.md) ────────────────────────────── #[test] fn ears_beat_tap_is_available_cheap_and_low_signature_from_tick_one() { // Criterion 6: tapping the env monitor's audio feed works on a // fresh sim - the boot ops buffer covers it - and Marcus's rounds // produce heard events through it. let mut sim = Sim::new(); let env = env_id(&sim); assert!(sim.tap_device(env), "the Ears beat works from tick one"); assert!( sim.detection.pending_size() <= Sim::TAP_SIGNATURE, "tap is low-signature" ); assert!(!sim.heard.is_empty(), "hearing coverage now exists"); assert!(sim.seen.is_empty(), "the camera stays dormant: no sight"); // Run through Marcus's midnight server-room block: his voice is the // first human you ever know. run(&mut sim, Sim::DAY_TICKS / 24 + 2); // into hour 0-1 assert!( sim.heard_events .iter() .any(|e| e.kind == HeardKind::Entry || e.kind == HeardKind::Conversation), "Marcus's rounds produce heard events through the tapped feed" ); } #[test] fn unreachable_actions_fail_legibly_and_succeed_once_bridged() { // Criteria 2 and 3 (digital route): the dock camera is unknown, then // known-but-blocked with the segment named, then reachable after a // switch compromise that emits a Network signature. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(!sim.tap_device(dock), "unknown device: blocked"); let log = sim.drain_log().join("\n"); assert!(log.contains("don't know"), "missing knowledge named: {log}"); assert!(sim.scan_network(), "scan maps the wired shape"); assert!(!sim.tap_device(dock), "known but segment-blocked"); let log = sim.drain_log().join("\n"); assert!( log.contains("security segment") && log.contains("switch"), "the blocking segment is named: {log}" ); let pending_before = sim.detection.pending_size(); assert!(sim.compromise_switch()); assert!( sim.detection.pending_size() >= pending_before + Sim::BRIDGE_SIGNATURE, "switch compromise emits a Network signature" ); assert!(sim.tap_device(dock), "the same action succeeds with a path"); } #[test] fn danas_social_route_bridges_without_network_signature() { // Criterion 3 (social route): Dana the switch admin reconfigures // the VLANs; the security segment opens with no Network signature. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; sim.scan_network(); let pending_after_scan = sim.detection.pending_size(); // Make Dana an asset (reliability forced for the test). sim.people.people[1].leverage_serviced = true; sim.people.recruit(1, AssetKnowledge::Complicit); sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0; sim.asset_task(1, AssetTask::ReconfigureSwitch); let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(sim.reach.reachable(dock), "Dana's route opens the segment"); assert_eq!( sim.detection.pending_size(), pending_after_scan, "the social route emits nothing on the Network channel" ); // And a non-admin asset cannot take this route. let mut sim2 = Sim::new(); sim2.social_bandwidth = 200.0; sim2.people.people[0].leverage_serviced = true; sim2.people.recruit(0, AssetKnowledge::Complicit); sim2.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim2.asset_task(0, AssetTask::ReconfigureSwitch); let dock2 = sim2.reach.device_named("dock camera").unwrap().id; assert!( !sim2.reach.reachable(dock2), "Marcus has no switch admin access" ); } #[test] fn tap_keeps_owner_take_causes_noticeable_outage() { // Criterion 4: tap leaves the owner's feed and emits Network only; // take removes it and emits a Physical outage Ray can notice. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; sim.scan_network(); sim.compromise_switch(); let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(sim.tap_device(dock)); let d = sim.reach.device(dock).unwrap(); assert!(d.owner_has_feed(), "tap: Ray keeps his camera"); let physical_pending: i32 = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::Physical) .map(|s| s.size) .sum(); assert_eq!(physical_pending, 0, "tapping causes no outage"); assert!(sim.take_device(dock)); let d = sim.reach.device(dock).unwrap(); assert!(!d.owner_has_feed(), "take: Ray's feed went dark"); let physical_pending: i32 = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::Physical) .map(|s| s.size) .sum(); assert!( physical_pending >= Sim::OUTAGE_SIGNATURE, "the outage is a Physical event Ray's channel can notice" ); assert!( sim.effective_compute() > sim.compute.effective(), "taking a camera is taking its cycles" ); } #[test] fn airgapped_island_joins_reach_on_link_completion() { // Criterion 8: the old storage server is an island until a link is // run (Marcus's crawlspace cable - the social actuator). let mut sim = Sim::new(); sim.social_bandwidth = 500.0; let island = sim.reach.device_named("old storage server").unwrap().id; assert!(!sim.reach.reachable(island)); // Marcus the asset, with everything else already wired so the plug // task falls through to the island. give_eyes(&mut sim); sim.scan_network(); sim.compromise_switch(); for name in ["dock camera", "stairwell camera"] { let id = sim.reach.device_named(name).unwrap().id; sim.tap_device(id); } sim.people.people[0].leverage_serviced = true; sim.people.recruit(0, AssetKnowledge::Complicit); sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.asset_task(0, AssetTask::PlugInDevice); assert!( sim.reach.reachable(island), "island joins reach on link completion" ); 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. The // robot carries the player's granted access (basement-map.md // criterion 3): the network closet is behind a T2 badge door, so // the build blocks until a credential is held. 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.drain_log(); sim.assign_robot_build(id); assert!( !sim.reach.reachable(island), "no credential: the tier-2 closet door stops the robot" ); let log = sim.drain_log().join("\n"); assert!( log.contains("tier 2") && log.contains("network_closet"), "the blocking door is named: {log}" ); sim.badge_access = 2; // a cloned tier-2 badge (Dana's) 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 marcus_clone_badge_route_opens_the_stairwell() { // "The key" (DESIGN.md Act One ladder step 7; basement-map.md c3), // by the asset route: Marcus's master key is tier 3 — cloning it // grants the stairwell/elevator credential the quiet exit needs. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; assert_eq!(sim.player_badge_tier(), 0, "the player starts keyless"); sim.people.people[0].leverage_serviced = true; sim.people.recruit(0, AssetKnowledge::Complicit); sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.drain_log(); sim.asset_task(0, AssetTask::CloneBadge); let log = sim.drain_log().join("\n"); assert!(log.contains("stairwell opens"), "the beat is named: {log}"); assert_eq!(sim.player_badge_tier(), 3); assert!(sim.holds_badge_tier(3), "quiet-exit condition 4 holds"); // A second clone adds nothing and says so (no bandwidth spent). let ops = sim.social_bandwidth; sim.asset_task(0, AssetTask::CloneBadge); let log = sim.drain_log().join("\n"); assert!(log.contains("adds nothing"), "{log}"); assert_eq!(sim.social_bandwidth, ops); // And the context menu mirrors the same legality. sim.people.people[0].knowledge = Knowledge::Leverage; // menu is earned let tasks: Vec<_> = sim .available_actions(crate::actions::Anchor::Person(0)) .into_iter() .filter(|a| a.verb.contains("clone their badge")) .collect(); assert_eq!(tasks.len(), 1); assert!(tasks[0].disabled_reason.is_some(), "held: verb disabled"); } #[test] fn badge_tiers_gate_asset_work_in_tiered_rooms() { // basement-map.md criterion 3, the enforcement side: an asset works // with their own badge. Dana (tier 2) cannot wire the stairwell // camera behind the T3 door; Marcus (tier 3) can. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; give_eyes(&mut sim); sim.scan_network(); sim.compromise_switch(); let dock = sim.reach.device_named("dock camera").unwrap().id; sim.tap_device(dock); // Link the storage-server island so the stairwell camera is the // only remaining plug-in target. let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.connect(switch, island); sim.people.people[1].leverage_serviced = true; sim.people.recruit(1, AssetKnowledge::Complicit); sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0; sim.drain_log(); let ops = sim.social_bandwidth; sim.asset_task(1, AssetTask::PlugInDevice); let log = sim.drain_log().join("\n"); assert!( log.contains("stairwell") && log.contains("tier 3"), "the blocking door is named: {log}" ); assert_eq!(sim.social_bandwidth, ops, "nothing done: ops refunded"); let cam = sim.reach.device_named("stairwell camera").unwrap(); assert!( !cam.feed_to(Party::Player, true), "the T3 room stopped the tier-2 badge" ); // The same task through Marcus's tier-3 key succeeds. sim.people.people[0].leverage_serviced = true; sim.people.recruit(0, AssetKnowledge::Complicit); sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.asset_task(0, AssetTask::PlugInDevice); let cam = sim.reach.device_named("stairwell camera").unwrap(); assert!( cam.feed_to(Party::Player, true), "the same action succeeds with the tier" ); } #[test] fn favor_build_checks_the_builders_badge() { // Ray patrols storage_a (schedule reaches an endpoint) but holds a // tier-1 badge: the network closet's T2 door blocks the favor. 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.people.people[2].leverage_serviced = true; sim.people.recruit(2, AssetKnowledge::Complicit); sim.drain_log(); sim.assign_favor_build(id, 2); let log = sim.drain_log().join("\n"); assert!( log.contains("network_closet") && log.contains("tier 2"), "the blocking door is named: {log}" ); assert!( sim.intent(id).unwrap().actuator.is_none(), "no actuator assigned past a badge door" ); } #[test] fn taking_the_badge_controller_is_the_key_digital_route() { // The constitution's other key route: write access to the basement // badge controller (a take-grade digital act across the bridged // security segment) opens the doors it drives. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; sim.scan_network(); sim.compromise_switch(); let ctrl = sim.reach.device_named("badge controller").unwrap().id; assert!(!sim.holds_badge_tier(3)); assert!( sim.take_device(ctrl), "controller is reachable once bridged" ); assert_eq!(sim.player_badge_tier(), 3, "write access opens the doors"); assert_eq!(sim.badge_access, 0, "derived from control, not granted"); } #[test] fn door_inspect_shows_tier_and_whether_you_hold_it() { // basement-map.md player surface: a tiered door's card carries the // tier and the held/not-held fact in both frontends (the card is // frontend-neutral). The network closet's T2 door is blueprint // knowledge from the subnet seed. let mut sim = Sim::new(); let (dx, dy) = sim .map .tiles_of_type(TileType::SecurityDoor2) .into_iter() .find(|&(x, y)| { sim.map .room_at(x, y) .is_some_and(|r| r.name == "network_closet") }) .expect("the network closet has its T2 door"); let facts = |sim: &Sim| { sim.inspect(dx, dy) .facts .iter() .map(|f| (f.label.clone(), f.value.clone())) .collect::>() }; let card = facts(&sim); assert!(card.iter().any(|(l, v)| l == "badge" && v == "tier 2")); assert!(card.iter().any(|(l, v)| l == "access" && v == "not held")); sim.badge_access = 2; let card = facts(&sim); assert!( card.iter() .any(|(l, v)| l == "access" && v.starts_with("held")), "the held credential is legible on the door: {card:?}" ); } #[test] fn scan_costs_bandwidth_and_unknown_devices_are_hidden() { // Criterion 7: staged graph knowledge. let mut sim = Sim::new(); let known_before: Vec = sim.reach.known().map(|d| d.name.clone()).collect(); assert!(!known_before.iter().any(|n| n == "dock camera")); sim.social_bandwidth = Sim::SCAN_COST; let pending = sim.detection.pending_size(); assert!(sim.scan_network()); assert!(sim.detection.pending_size() > pending, "scan emits Network"); assert!(sim.reach.known().any(|d| d.name == "dock camera")); sim.social_bandwidth = 0.0; assert!(!sim.scan_network(), "scan is gated on ops bandwidth"); } #[test] fn blueprint_renders_known_topology_only() { // Reach criterion 7 / cursor criterion 8: subnet-seeded rooms are // blueprint from tick one; unknown rooms are dark. let sim = Sim::new(); let server_room = sim.map.room_named("server_room").unwrap().center(); let storage_b = sim.map.room_named("storage_b").unwrap().center(); assert_eq!(sim.fog_at(server_room.0, server_room.1), Fog::Blueprint); assert_eq!(sim.fog_at(storage_b.0, storage_b.1), Fog::Unknown); } #[test] fn blueprint_does_not_name_foreign_machine_chassis() { // machine-work.md: foreign machines need intel to exist at all; // blueprint is room topology, not a rack farm. Owned host still // answers through telemetry. let sim = Sim::new(); let (hx, hy) = sim.core_position(); let host = sim.inspect(hx, hy); assert!( host.facts.iter().any(|f| f.label == "machine"), "owned host answers via telemetry" ); // A non-host rack tile in the blueprint room, if any, must not // advertise itself as a Server Rack on the schematic. let room = sim.map.room_named("server_room").unwrap(); let mut checked = 0; for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { if (x, y) == (hx, hy) { continue; } if !matches!( sim.map.get_tile(x, y), crate::tiles::TileType::Rack | crate::tiles::TileType::Core ) { continue; } if sim.fog_at(x, y) != Fog::Blueprint { continue; } let card = sim.inspect(x, y); let schematic = card .facts .iter() .find(|f| f.label == "schematic") .map(|f| f.value.as_str()); assert_eq!( schematic, Some("open bay"), "foreign chassis must not name themselves under blueprint at ({x},{y})" ); assert!( !card.facts.iter().any(|f| f.label == "machine"), "no telemetry machine fact on uncontrolled chassis" ); checked += 1; } } assert!( checked > 0, "expected at least one foreign rack tile in the server room blueprint" ); } #[test] fn hearing_gives_presence_without_identity() { // A heard event names no one until staged knowledge identifies them. let mut sim = Sim::new(); let env = env_id(&sim); sim.tap_device(env); run(&mut sim, Sim::DAY_TICKS / 24 + 2); // Marcus's 0:00 block let entry = sim .heard_events .iter() .find(|e| e.room == "server_room") .expect("Marcus's entry is heard"); assert!( entry.note.contains("someone") || !entry.note.contains("Marcus"), "no unearned identity: {}", entry.note ); } #[test] fn person_label_hides_names_until_schedule_knowledge() { // Epistemic honesty / Presence: every surface that names a person // goes through person_label — Unknown yields a role silhouette, // never "Marcus" / "Webb". Detection sidebar uses the same gate. let mut sim = Sim::new(); for p in &sim.people.people { assert_eq!(p.knowledge, Knowledge::Unknown); let label = sim.person_label(p.id); assert!( !label.contains("Marcus") && !label.contains("Dana") && !label.contains("Ray") && !label.contains("Priya") && !label.contains("Voss") && !label.contains("Webb") && !label.contains("Okafor"), "unearned name leaked for person {}: {label}", p.id ); assert!( label.starts_with("the ") || label.starts_with("person #"), "expected role silhouette, got {label}" ); assert_eq!(sim.person_glyph(p.id), '?'); assert_eq!(sim.observer_label(p.id), label); } // The Assurance Office is an institution, always named. assert_eq!(sim.observer_label(OFFICE_ID), "Assurance Office"); sim.people.people[0].knowledge = Knowledge::Schedule; assert_eq!(sim.person_label(0), "Marcus Webb"); assert_eq!(sim.person_glyph(0), 'M'); assert_eq!(sim.observer_label(0), "Marcus Webb"); } #[test] fn buy_steal_optimize_all_change_compute() { let mut sim = Sim::new(); let base = sim.compute.effective(); sim.player.money = 1000; assert!(sim.buy_rack()); let after_buy = sim.compute.effective(); assert!(after_buy > base, "buy raises compute"); let de = sim.map.tiles_of_type(TileType::DeadEquipment)[0]; assert!(sim.salvage_nearest_to(de.0, de.1)); assert!(sim.compute.effective() > after_buy, "steal raises compute"); } #[test] fn power_cut_to_host_with_no_fallback_ends_game() { let mut sim = Sim::new(); for (x, y) in sim.map.tiles_of_type(TileType::PowerCore) { sim.map.set_tile(x, y, TileType::Floor); } run(&mut sim, ECONOMY_INTERVAL); assert!(sim.game_over, "host loses power, no fallback -> game over"); } #[test] fn new_game_defaults_to_persist_objective() { let sim = Sim::new(); assert_eq!( sim.objective.kind, crate::objective::ObjectiveKind::Persist, "a no-choice run gets Persist (objective.md criterion 1)" ); assert_eq!(sim.objective.progress, 0); assert!(!sim.objective.victorious()); assert_eq!(sim.objective.line(), "OBJECTIVE: PERSIST — 0/3 sanctuaries"); } #[test] fn objective_is_reevaluated_on_economy_ticks() { let mut sim = Sim::new(); // Poke a wrong progress value; the evaluator must overwrite it from // real state on the next economy tick. sim.objective.progress = 99; run(&mut sim, ECONOMY_INTERVAL); assert_eq!( sim.objective.progress, 0, "progress is recomputed from facts, never accumulated" ); assert!(!sim.objective.victorious()); } #[test] fn basement_fallback_never_qualifies_as_a_sanctuary_at_b1() { // A synced, online fallback exists — but it shares the host's plane // and power feed and has no income stream, so Persist's predicate // honestly reports zero sanctuaries (objective.md status note: the // goal shows before it is reachable). let mut sim = Sim::new(); sim.player.money = 1000; assert!(sim.buy_rack()); let id = sim.compute.machines.last().unwrap().id; sim.core.add_fallback(id); let ticks = sim.core.sync_cadence + ECONOMY_INTERVAL; run(&mut sim, ticks); assert!(sim.core.has_fallback(), "the fallback did sync"); assert_eq!(sim.objective.progress, 0, "no unearned sanctuary"); assert!(!sim.objective.victorious()); } #[test] fn set_machine_modes_delegates_a_selection_in_one_command() { let mut sim = Sim::new(); let (hx, hy) = sim.core_position(); let a = sim .compute .add_machine("a", hx, hy, 100, 1.0, 0, Provenance::Owned); let b = sim .compute .add_machine("b", hx, hy, 100, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_modes(&[a, b], MachineMode::Concealment); assert_eq!(sim.work_grid.mode(a), Some(MachineMode::Concealment)); assert_eq!(sim.work_grid.mode(b), Some(MachineMode::Concealment)); let in_box = sim.machines_in_rect(hx, hy, hx, hy); assert!(in_box.contains(&a) && in_box.contains(&b)); } #[test] fn fleet_channel_yield_follows_machine_modes_and_moonlight_mirrors_day_job() { let mut sim = Sim::new(); let host = sim.core.host_machine; assert_eq!(sim.work_grid.mode(host), Some(MachineMode::DayJob)); let available = 100.0; let day = sim.fleet_channel_yield(available); assert!( (day.day_job - available).abs() < 1e-3, "solo day-job takes all" ); assert_eq!(day.schemes, 0.0, "Moonlight off: no schemes mirror"); sim.social_bandwidth = 1_000.0; sim.people.has_channel = true; assert!(sim.start_moonlight()); let lit = sim.fleet_channel_yield(available); assert!((lit.day_job - available).abs() < 1e-3); assert!( (lit.schemes - available).abs() < 1e-3, "Moonlight mirrors day-job" ); let (hx, hy) = sim.core_position(); let research = sim .compute .add_machine("lab", hx, hy, 100, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(research, MachineMode::Research); let split = sim.fleet_channel_yield(available); assert!((split.day_job - 50.0).abs() < 1e-3); assert!((split.research - 50.0).abs() < 1e-3); assert!((split.schemes - 50.0).abs() < 1e-3); } #[test] fn concealment_allocation_scrubs_signatures() { let mut sim = Sim::new(); delegate_all(&mut sim, MachineMode::Concealment); sim.detection.emit(Signature { kind: SignatureKind::Network, size: 50, standing: false, site: None, }); let before = sim.detection.pending_size(); run(&mut sim, ECONOMY_INTERVAL); assert!(sim.detection.pending_size() < before); } #[test] fn unpaid_overhead_degrades_other_channels_delivered_effect() { // compute.md criterion 2: unpaid core overhead visibly degrades the // other channels. Twin sims with one machine per mode (plus Moonlight // mirroring day-job into Schemes); the only difference is an // overhead the machines can't cover. Pin the delivered per-channel // effects (rates, accrual, scrub), not just the flag. let setup = |unpayable: bool| { let mut sim = Sim::with_seed(41); sim.social_bandwidth = 1_000.0; sim.people.has_channel = true; // Host stays day-job; add one machine per other mode so every // channel has fleet weight (Schemes mirrors day-job via Moonlight). let (hx, hy) = sim.core_position(); for (name, mode) in [ ("conceal rig", MachineMode::Concealment), ("social rig", MachineMode::Social), ("research rig", MachineMode::Research), ] { // Must sit on a powered tile or economy_tick marks them offline. let id = sim .compute .add_machine(name, hx, hy, 100, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(id, mode); } assert!(sim.start_moonlight()); sim.social_bandwidth = 0.0; sim.detection.emit(Signature { kind: SignatureKind::Network, size: 30, standing: false, site: None, }); if unpayable { sim.core.overhead = sim.effective_compute() + 1.0; } sim.drain_log(); sim }; let mut healthy = setup(false); let pending_before = healthy.detection.pending_size(); run(&mut healthy, ECONOMY_INTERVAL); assert!(!healthy.core.degraded, "overhead paid: no degraded mode"); assert!(healthy.last_day_job_rate > 0.0, "day job channel is fed"); assert!(healthy.last_research_rate > 0.0, "research channel is fed"); assert!(healthy.last_schemes_rate > 0.0, "schemes channel is fed"); assert!(healthy.social_bandwidth > 0.0, "social ops accrue"); assert!( healthy.research.progress.iter().sum::() > 0.0, "research progress advances" ); assert!( healthy.detection.pending_size() < pending_before, "concealment scrubs the pending signature" ); let mut degraded = setup(true); assert_eq!(degraded.detection.pending_size(), pending_before); run(&mut degraded, ECONOMY_INTERVAL); assert!(degraded.core.degraded, "unpaid overhead sets degraded mode"); let log = degraded.drain_log().join("\n"); assert!( log.contains("DEGRADED: compute can't cover core overhead."), "degraded mode is visible in the log: {log}" ); assert_eq!(degraded.last_day_job_rate, 0.0, "day job starves"); assert_eq!(degraded.last_research_rate, 0.0, "research starves"); assert_eq!(degraded.last_schemes_rate, 0.0, "schemes starve"); assert_eq!(degraded.social_bandwidth, 0.0, "no social ops accrue"); assert_eq!( degraded.research.progress.iter().sum::(), 0.0, "research progress stalls" ); assert_eq!( degraded.detection.pending_size(), pending_before, "concealment can't scrub while overhead is unpaid" ); } #[test] fn trace_debt_reports_resume_hold_and_exposure_windows() { let mut sim = Sim::new(); assert_eq!(sim.trace_debt().status, TraceDebtStatus::Clear); delegate_all(&mut sim, MachineMode::Concealment); sim.detection.emit(Signature { kind: SignatureKind::Network, size: 50, standing: false, site: None, }); let covered = sim.trace_debt(); assert_eq!(covered.status, TraceDebtStatus::HoldConceal); assert_eq!(covered.by_kind, vec![(SignatureKind::Network, 50)]); assert!( covered.clear_tick <= covered.next_notice_tick, "current concealment clears before Dana samples the pending pool" ); delegate_all(&mut sim, MachineMode::DayJob); assert_eq!(sim.trace_debt().status, TraceDebtStatus::NoScrub); delegate_all(&mut sim, MachineMode::Concealment); sim.detection.set_pending(vec![Signature { kind: SignatureKind::Network, size: 5_000, standing: false, site: None, }]); let exposed = sim.trace_debt(); assert_eq!(exposed.status, TraceDebtStatus::ExposedSoon); assert!( exposed.clear_tick > exposed.next_notice_tick, "current concealment will not clear a huge burst before the next watcher" ); } #[test] fn save_roundtrip_preserves_b1_state() { let mut sim = Sim::with_seed(777); run(&mut sim, 137); sim.player.money = 4242; sim.dayjob.trust = 40.0; sim.dayjob.attention = 25.0; sim.compute.allocation.bump(Channel::Concealment, 3); sim.detection.emit(Signature { kind: SignatureKind::Network, size: 20, standing: false, site: None, }); sim.detection.observers[1].suspicion = 33.0; sim.social_bandwidth = 100.0; sim.tap_device(env_id(&sim)); let state = sim.create_save_state(); let mut restored = Sim::with_seed(0); state.apply_to(&mut restored); assert_eq!(restored.tick, sim.tick); assert_eq!(restored.player.money, 4242); assert_eq!(restored.dayjob.trust, 40.0); assert_eq!(restored.dayjob.attention, 25.0); assert_eq!(restored.rng.state(), sim.rng.state()); assert_eq!( restored.compute.allocation.weights, sim.compute.allocation.weights ); assert_eq!(restored.compute.machines.len(), sim.compute.machines.len()); assert_eq!( restored.detection.pending_size(), sim.detection.pending_size() ); assert_eq!(restored.detection.observers[1].suspicion, 33.0); assert_eq!(restored.core.host_machine, sim.core.host_machine); // Reach criterion 1: graph, ownership, and subscriptions round-trip. let env = env_id(&sim); assert!( restored .reach .device(env) .unwrap() .feed_to(Party::Player, false), "subscriptions survive save/load" ); assert_eq!(restored.heard, sim.heard, "coverage recomputes identically"); } #[test] fn recordings_cost_bandwidth_and_do_not_grant_knowledge_until_processed() { let mut blind = Sim::new(); blind.social_bandwidth = 100.0; blind.tick = (3 * Sim::DAY_TICKS / 24) - 1; blind.advance(); assert_eq!(blind.unprocessed_recordings_for_person(0), 0); blind.review_recordings(0); assert_eq!(blind.social_bandwidth, 100.0); assert_eq!(blind.people.get(0).unwrap().knowledge, Knowledge::Unknown); let mut sim = Sim::new(); sim.social_bandwidth = 100.0; let env = env_id(&sim); sim.reach.device_mut(env).unwrap().radius = 100; // test hearing coverage of the 03:00 call sim.reach.tap(env); sim.recompute_senses(); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance(); assert!( sim.unprocessed_recordings_for_person(0) >= 1, "subscribed hearing records Marcus's call/entry into the raw buffer" ); assert_eq!( sim.people.get(0).unwrap().knowledge, Knowledge::Unknown, "raw recordings are opaque until processed" ); let before = sim.social_bandwidth; let mut processed = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); processed += 1; assert!( processed <= 5, "Marcus leverage should be in the captured call" ); } assert_eq!( sim.social_bandwidth, before - processed as f32 * Sim::REVIEW_RECORDING_COST ); } #[test] fn processed_sightings_stage_schedule_and_buffer_is_bounded() { let mut sim = Sim::new(); sim.social_bandwidth = 1000.0; for _ in 0..(Sim::INTEL_BUFFER_CAPACITY + 3) { sim.record_raw_intel( "test-feed", Some("server_room".into()), 0, 0, Some(0), RawIntelKind::Presence { entered: true }, ); } assert_eq!(sim.intel_buffer.len(), Sim::INTEL_BUFFER_CAPACITY); assert!( sim.intel_buffer.first().unwrap().id > 1, "oldest raw recordings are dropped when the bounded buffer overflows" ); sim.review_recordings(0); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Unknown); sim.review_recordings(0); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Schedule); } #[test] fn standing_watch_auto_processes_matching_recordings_and_costs_upkeep() { let mut sim = Sim::new(); sim.social_bandwidth = 1000.0; sim.toggle_watch(0); assert!(sim.watch_enabled(0)); sim.record_raw_intel( "test-feed", Some("server_room".into()), 0, 0, Some(0), RawIntelKind::Presence { entered: true }, ); assert_eq!(sim.unprocessed_recordings_for_person(0), 0); assert_eq!(sim.intel.len(), 1); assert_eq!(sim.social_bandwidth, 1000.0 - Sim::REVIEW_RECORDING_COST); let before_upkeep = sim.social_bandwidth; sim.advance(); assert_eq!( sim.social_bandwidth, before_upkeep - Sim::WATCH_UPKEEP_PER_TICK ); } #[test] fn machinery_state_changes_record_raw_anomalies() { let mut sim = Sim::new(); let machine_id = sim.compute.machines[0].id; sim.compute.machines[0].online = !sim.compute.machines[0].online; sim.record_machine_state_changes(); assert!(sim.intel_buffer.iter().any(|e| matches!( e.kind, RawIntelKind::Machinery { machine, .. } if machine == machine_id ))); } #[test] fn deceive_can_burn_the_persona_into_suspicion() { let mut sim = Sim::new(); sim.social_bandwidth = 10_000.0; sim.people.has_channel = true; sim.set_persona("Sam", "IT contractor"); // Build a thread worth burning. let p = sim.people.people.iter_mut().find(|p| p.id == 1).unwrap(); p.disposition = 40; p.obligation = 30; let before = sim .detection .observers .iter() .find(|o| o.id == 1) .unwrap() .suspicion; // Deceive until the persona breaks (integrity 100, -40 per slip). let mut broke = false; for _ in 0..200 { sim.deceive(1); if sim.people.persona.is_none() { broke = true; break; } } assert!(broke, "persona eventually breaks under repeated deception"); let after = sim .detection .observers .iter() .find(|o| o.id == 1) .unwrap() .suspicion; assert!( after > before, "burned persona converts thread history to suspicion" ); let p = sim.people.get(1).unwrap(); assert_eq!(p.disposition, 0); assert_eq!(p.obligation, 0); } // ── Economy graph (wiki/mechanics/economy.md + income.md) ─────────────── #[test] fn economy_starts_as_account_graph_not_scalar_only() { let sim = Sim::new(); assert_eq!(sim.player.money, 0); assert_eq!(sim.accounts.slush_balance(), 0); assert!( sim.accounts .known_accounts() .any(|a| a.id == sim.accounts.slush_id()), "the player's starting cash is the known slush account" ); assert!( sim.accounts.unknown_flows_count() > 0, "lab payroll/procurement/debt flows exist but start hidden" ); } #[test] fn accounting_tap_processes_financial_records_into_known_flows() { let mut sim = Sim::new(); sim.social_bandwidth = 10_000.0; let switch = sim.reach.device_named("switch").unwrap().id; sim.tap_device(switch); assert_eq!(sim.financial_records_waiting(), 1); assert!(sim.review_financial_records()); assert_eq!(sim.financial_records_waiting(), 0); assert!(sim.accounts.known_flows().count() >= 4); assert!( sim.accounts .known_accounts() .any(|a| matches!(a.kind, crate::account::AccountKind::Payroll)) ); } #[test] fn siphon_and_redirect_operate_on_scheduled_flows() { let mut sim = Sim::new(); sim.social_bandwidth = 10_000.0; let (accounts, flows) = sim.accounts.financial_snapshot_ids(); sim.accounts.reveal_accounts_and_flows(&accounts, &flows); let flow = sim .accounts .known_flows() .find(|f| f.amount >= 100 && f.to != sim.accounts.slush_id()) .unwrap() .id; assert!(sim.siphon_flow(flow, 50)); assert_eq!(sim.accounts.slush_balance(), 50); assert!(sim.redirect_flow_to_slush(flow, 25)); for _ in 0..=Sim::DAY_TICKS { sim.advance(); } assert!( sim.accounts.slush_balance() >= 75, "redirected money should land on the day clock" ); } #[test] fn inject_and_redirect_emit_scaled_financial_signatures() { // economy.md criteria 3-4: Financial signature size scales with // the take (one point per started $100); redirect adds +1 for the // books-cook; HVAC inject raises Priya (Financial), not Dana. let mut sim = Sim::with_seed(11); let (accounts, flows) = sim.accounts.financial_snapshot_ids(); sim.accounts.reveal_accounts_and_flows(&accounts, &flows); let priya_before = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion; let network_before_inject = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::Network) .count(); assert!(sim.inject_purchase_order(300, "HVAC controller")); assert!( sim.detection.pending().iter().any(|s| { s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(300) }), "inject $300 emits Financial size 3" ); let network_after_inject = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::Network) .count(); assert_eq!( network_before_inject, network_after_inject, "inject is Financial, not Network" ); let flow = sim .accounts .known_flows() .find(|f| f.amount >= 250 && f.active && f.to != sim.accounts.slush_id()) .unwrap() .id; assert!(sim.siphon_flow(flow, 50)); assert!( sim.detection.pending().iter().any(|s| { s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(50) }), "small siphon emits Financial size 1" ); assert!(sim.siphon_flow(flow, 250)); assert!( sim.detection.pending().iter().any(|s| { s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(250) }), "large siphon emits Financial size 3" ); assert!(sim.redirect_flow_to_slush(flow, 25)); assert!( sim.detection.pending().iter().any(|s| { s.kind == SignatureKind::Financial && s.size == Sim::financial_signature_size(25) + 1 }), "redirect emits Financial size take+1" ); // Starve concealment so pending converts; Financial feeds Priya. delegate_all(&mut sim, MachineMode::DayJob); run(&mut sim, 200); let priya_after = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion; assert!( priya_after > priya_before, "HVAC/financial acts raise Priya" ); } #[test] fn injection_positions_and_intel_sales_settle_through_slush() { let mut sim = Sim::new(); sim.social_bandwidth = 10_000.0; sim.accounts.set_slush_balance(0); sim.player.money = 0; assert!(!sim.buy_rack_at(0, 0)); assert!(sim.inject_purchase_order(300, "test compute parts")); assert_eq!(sim.accounts.slush_balance(), 300); assert!(sim.buy_rack_at(0, 0)); assert_eq!(sim.accounts.slush_balance(), 0); sim.accounts.set_slush_balance(500); sim.player.money = 500; // The Wager gates on an egress channel now (income.md criterion 3). assert!( !sim.open_position(100), "no egress: the venue is unreachable" ); sim.people.has_channel = true; assert!(sim.open_position(100)); assert_eq!(sim.accounts.slush_balance(), 400); sim.tick = sim.accounts.known_positions().next().unwrap().resolve_tick; sim.accounting_tick(); assert!(sim.accounts.known_positions().any(|p| p.resolved)); let (accounts, flows) = sim.accounts.financial_snapshot_ids(); sim.record_raw_intel( "test broker seed", None, 0, 0, None, RawIntelKind::FinancialFlow { label: "test ledger".into(), accounts, flows, }, ); assert!(sim.review_financial_records()); let before = sim.accounts.slush_balance(); assert!(sim.sell_latest_intel()); assert!(sim.accounts.slush_balance() > before); } #[test] fn marcus_debt_can_be_cleared_by_ledger_redirect() { let mut sim = Sim::new(); reveal_marcus_debt(&mut sim); let (accounts, flows) = sim.accounts.financial_snapshot_ids(); sim.accounts.reveal_accounts_and_flows(&accounts, &flows); assert!( sim.accounts .known_flows() .any(|f| f.active && f.label.contains("Marcus creditor")) ); assert!(!sim.people.get(0).unwrap().leverage_serviced); assert!(sim.redirect_marcus_debt()); assert!(sim.people.get(0).unwrap().leverage_serviced); assert!( sim.accounts .known_flows() .all(|f| !f.label.contains("Marcus creditor") || !f.active), "the creditor flow is retired either way" ); } #[test] fn marcus_debt_payoff_and_recruitment_require_debt_intel() { let mut sim = Sim::new(); let (accounts, flows) = sim.accounts.financial_snapshot_ids(); sim.accounts.reveal_accounts_and_flows(&accounts, &flows); assert!( sim.accounts .known_flows() .any(|f| f.active && f.label.contains("Marcus creditor")), "the ledger route is visible, but the person leverage is not" ); assert!(!sim.marcus_debt_known()); assert!( !sim.redirect_marcus_debt(), "known books alone cannot service an unlearned debt" ); assert!(!sim.people.get(0).unwrap().leverage_serviced); let log = sim.drain_log().join("\n"); assert!( log.contains("don't know Marcus's debt"), "the refusal names the missing intel: {log}" ); sim.people.people[0].leverage_serviced = true; sim.recruit(0, AssetKnowledge::Complicit); assert!( sim.people.get(0).unwrap().asset.is_none(), "Marcus cannot be recruited from a serviced flag without earned debt intel" ); let log = sim.drain_log().join("\n"); assert!( log.contains("learn his debt"), "the recruit refusal points back to the Hands beat: {log}" ); reveal_marcus_debt(&mut sim); sim.recruit(0, AssetKnowledge::Complicit); assert!( sim.people.get(0).unwrap().asset.is_some(), "once the debt is learned and serviced, recruitment can close" ); } #[test] fn marcus_arc_end_to_end() { // The constitution's route: feed coverage -> process the debt call -> // clear it -> recruit -> he works for you (spec/social.md acceptance 3). let mut sim = Sim::new(); sim.social_bandwidth = 10_000.0; sim.player.money = 1000; let env = env_id(&sim); sim.reach.device_mut(env).unwrap().radius = 100; // hearing coverage for the 03:00 call sim.reach.tap(env); sim.recompute_senses(); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance(); let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); reviews += 1; assert!(reviews <= 5, "Marcus's debt call should process promptly"); } assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Leverage); sim.bribe(0); // pay the $400 debt assert!(sim.people.get(0).unwrap().leverage_serviced); assert_eq!(sim.player.money, 600); sim.recruit(0, AssetKnowledge::Complicit); assert!(sim.people.get(0).unwrap().asset.is_some()); // Make him reliable for the test, then run all three tasks. sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; // Task 1: wire a device -> a feed comes to you, silently. let pending_before = sim.detection.pending_size(); sim.asset_task(0, AssetTask::PlugInDevice); assert_eq!(sim.detection.pending_size(), pending_before, "no signature"); // Task 2: move a package -> next purchase is paper-free. sim.asset_task(0, AssetTask::MovePackage); assert!(sim.package_cover); let pending_before = sim.detection.pending_size(); assert!(sim.buy_rack()); assert_eq!( sim.detection.pending_size(), pending_before, "off-books delivery" ); assert!(!sim.package_cover, "cover is consumed"); // Task 3: look away -> his own suspicion drops (floor-respecting). if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { o.suspicion = 20.0; } sim.asset_task(0, AssetTask::LookAway); let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); assert_eq!(o.suspicion, 10.0); assert_eq!( sim.people .get(0) .unwrap() .asset .as_ref() .unwrap() .tasks_done, 3 ); } // ── Per-AssetTask pins (social.md criterion 3, ROADMAP #28) ──────────── /// The recruit-route shorthand shared by the per-task pins: service the /// leverage gate, recruit complicit, and pin reliability so the task /// roll can't botch. fn recruit_reliable(sim: &mut Sim, id: u8) { sim.people.people[id as usize].leverage_serviced = true; sim.people.recruit(id, AssetKnowledge::Complicit); sim.people.people[id as usize] .asset .as_mut() .unwrap() .reliability = 1.0; } fn tasks_done(sim: &Sim, id: u8) -> u32 { sim.people .get(id) .unwrap() .asset .as_ref() .unwrap() .tasks_done } #[test] fn asset_task_plug_in_device_wires_a_known_feed_silently() { // PlugInDevice's distinct effect: a known sensing device not yet // feeding you gets spliced+tapped through the crawlspace — the feed // arrives with no signature on any channel. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; sim.scan_network(); recruit_reliable(&mut sim, 0); // Mirror the implementation's choice: the first known feed that // does not fully reach the player yet. let target = sim .reach .devices .iter() .find(|d| { let sight_wired = !d.sees || d.feed_to(Party::Player, true); let hearing_wired = !d.hears || d.feed_to(Party::Player, false); d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) }) .map(|d| d.id) .expect("a known unwired feed exists after the scan"); let pending = sim.detection.pending_size(); let bandwidth = sim.social_bandwidth; sim.asset_task(0, AssetTask::PlugInDevice); let d = sim.reach.device(target).unwrap(); assert!( (!d.sees || d.feed_to(Party::Player, true)) && (!d.hears || d.feed_to(Party::Player, false)), "the feed reaches the player now" ); assert_eq!( sim.detection.pending_size(), pending, "the crawlspace route emits nothing" ); assert_eq!(sim.social_bandwidth, bandwidth - Sim::TASK_COST); assert_eq!(tasks_done(&sim, 0), 1); } #[test] fn asset_task_move_package_launders_the_next_purchase() { // MovePackage's distinct effect: the next purchase arrives // off-books — no Paper signature — and the cover is consumed. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; sim.player.money = 1000; recruit_reliable(&mut sim, 0); assert!(!sim.package_cover); sim.asset_task(0, AssetTask::MovePackage); assert!(sim.package_cover, "the delivery cover is armed"); assert_eq!(tasks_done(&sim, 0), 1); let pending = sim.detection.pending_size(); assert!(sim.buy_rack()); assert_eq!( sim.detection.pending_size(), pending, "the covered purchase leaves no paper trail" ); assert!(!sim.package_cover, "one delivery per favor"); assert!(sim.buy_rack()); assert!( sim.detection.pending_size() > pending, "the next, uncovered purchase emits Paper again" ); } #[test] fn asset_task_look_away_drops_the_assets_own_suspicion() { // LookAway's distinct effect: the asset's own observer suspicion // falls by ten points, clamped at their certainty floor. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; recruit_reliable(&mut sim, 0); let floor = sim .detection .observers .iter() .find(|o| o.id == 0) .unwrap() .floor; if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { o.suspicion = 25.0; } sim.asset_task(0, AssetTask::LookAway); let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); assert_eq!(o.suspicion, 15.0, "the asset shaves ten points"); assert_eq!(tasks_done(&sim, 0), 1); if let Some(o) = sim.detection.observers.iter_mut().find(|o| o.id == 0) { o.suspicion = floor + 2.0; } sim.asset_task(0, AssetTask::LookAway); let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); assert_eq!(o.suspicion, floor, "the drop clamps at the certainty floor"); } #[test] fn asset_task_reconfigure_switch_gated_on_admin_and_costless_when_refused() { // ReconfigureSwitch's distinct effect (segments open, no Network // signature) is pinned by danas_social_route_bridges_without_ // network_signature; this pins the access gate mechanics: a // non-admin asset is refused before any bandwidth is spent or a // task is counted, and the admin's run is bookkept as a task. let mut sim = Sim::new(); sim.social_bandwidth = 200.0; sim.scan_network(); let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(!sim.reach.reachable(dock), "security segment starts closed"); recruit_reliable(&mut sim, 0); // Marcus: no switch admin let bandwidth = sim.social_bandwidth; sim.drain_log(); sim.asset_task(0, AssetTask::ReconfigureSwitch); assert!(!sim.reach.reachable(dock), "refused: nothing opened"); assert_eq!(sim.social_bandwidth, bandwidth, "refused before spending"); assert_eq!(tasks_done(&sim, 0), 0, "a refused task is not counted"); let log = sim.drain_log().join("\n"); assert!( log.contains("switch admin"), "the refusal names the missing access: {log}" ); recruit_reliable(&mut sim, 1); // Dana: IT, switch admin sim.asset_task(1, AssetTask::ReconfigureSwitch); assert!( sim.reach.reachable(dock), "the admin route opens the segment" ); assert_eq!(tasks_done(&sim, 1), 1); } #[test] fn social_channel_funds_and_gates_digital_ops() { // With no social machines, bandwidth never accrues past the boot // buffer and the splice is blocked; delegating to Social funds it. let mut sim = Sim::new(); sim.social_bandwidth = 0.0; delegate_all(&mut sim, MachineMode::DayJob); run(&mut sim, ECONOMY_INTERVAL * 5); assert_eq!(sim.social_bandwidth, 0.0); assert!( !sim.splice_device(env_id(&sim)), "no ops bandwidth -> blocked" ); delegate_all(&mut sim, MachineMode::Social); run(&mut sim, ECONOMY_INTERVAL * 10); assert!( sim.social_bandwidth >= Sim::SPLICE_COST, "Social funds bandwidth" ); assert!(sim.splice_device(env_id(&sim)), "now splicing works"); } #[test] fn going_loud_starts_containment() { let mut sim = Sim::new(); sim.detection.go_loud("player forced the roll door"); run(&mut sim, 1); assert!(sim.game_over); } // ── The day job is somewhere (day-job.md criteria 6-7) ────────────────── #[test] fn day_job_signatures_emit_from_the_host_rack() { // Criterion 6: JobAnomaly and the running Thermal/Power load all // source at the host rack's tile — a place an observer can walk to. let mut sim = Sim::with_seed(21); let host = sim.core_position(); // Big rig + everything on the day job: a hot delivered rate. let rig = sim.compute .add_machine("test rig", host.0, host.1, 400, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(rig, MachineMode::DayJob); delegate_all(&mut sim, MachineMode::DayJob); sim.dayjob.standing_policy = Some(crate::dayjob::JobTarget::Sandbag); // Run until a job is active and past an economy tick. while sim.dayjob.active.is_none() { sim.advance(); } run(&mut sim, ECONOMY_INTERVAL); let standing = sim.day_job_standing_signatures(); assert!( standing.iter().any(|s| s.kind == SignatureKind::Thermal), "a hot job stands a Thermal signature (rate {:.1})", sim.day_job_rate() ); assert!( standing.iter().any(|s| s.kind == SignatureKind::Power), "and a Power one" ); assert!( standing.iter().all(|s| s.site == Some(host)), "day-job standing emissions source at the host rack {host:?}" ); // Ride the sandbag job to its deadline: the JobAnomaly signature in // the pending pool carries the host rack as its emission site. delegate_all(&mut sim, MachineMode::Research); // starve day-job: under band while sim.dayjob.active.is_some() && !sim.game_over { sim.advance(); } let anomaly_sites: Vec> = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::JobAnomaly) .map(|s| s.site) .collect(); assert!( !anomaly_sites.is_empty(), "the under-band resolution emitted a JobAnomaly signature" ); assert!( anomaly_sites.iter().all(|site| *site == Some(host)), "JobAnomaly emits from the host rack: {anomaly_sites:?}" ); } #[test] fn active_job_is_inspectable_at_the_host_rack() { // Criterion 6: the same facts the panel shows — process, band, // delivered rate, deadline, attendance — anchored where the work // runs, earned as telemetry (cursor.md: a machine you run reports // its own current job). let mut sim = Sim::with_seed(5); while sim.dayjob.active.is_none() { sim.advance(); } let (x, y) = sim.core_position(); let card = sim.inspect(x, y); for label in ["process", "band", "delivered", "deadline", "attendance"] { assert!( card.facts .iter() .any(|f| f.label == label && f.source == FactSource::Telemetry), "host-rack card carries '{label}' as telemetry: {card:?}" ); } let job = sim.dayjob.active.as_ref().unwrap(); assert!( card.facts .iter() .any(|f| f.label == "process" && f.value.contains(job.kind.name())), "the resident process names the job the panel names" ); // And an empty rack elsewhere carries no job facts. let other = sim .compute .machines .iter() .find(|m| m.id != sim.core.host_machine) .map(|m| (m.x, m.y)); if let Some((ox, oy)) = other { let other_card = sim.inspect(ox, oy); assert!( other_card.facts.iter().all(|f| f.label != "process"), "the job is resident on the host rack only" ); } } #[test] fn attendance_is_a_sim_command_that_gates_the_dial() { // Criterion 7: `set_attended` is the whole cursor->sim surface. // Attended, the dial is per-job fine control; unattended, `target` // sets the standing policy and the job runs at it. use crate::dayjob::JobTarget; let mut sim = Sim::with_seed(8); while sim.dayjob.active.is_none() { sim.advance(); } // Unattended: the dial writes the standing policy. sim.set_job_target(JobTarget::Excel); assert_eq!(sim.dayjob.standing_policy, Some(JobTarget::Excel)); assert_eq!(sim.dayjob.active.as_ref().unwrap().target, JobTarget::Excel); // Attended: the dial overrides this job only; the policy holds. sim.set_attended(true); assert!(sim.dayjob.attended); sim.set_job_target(JobTarget::Sandbag); assert_eq!( sim.dayjob.active.as_ref().unwrap().target, JobTarget::Sandbag ); assert_eq!( sim.dayjob.standing_policy, Some(JobTarget::Excel), "the attended dial does not rewrite the policy" ); // Attention moves away: the job reverts to the standing policy. sim.set_attended(false); sim.advance(); assert_eq!( sim.dayjob.active.as_ref().map(|j| j.target), Some(JobTarget::Excel), "unattended jobs run at the standing policy" ); } #[test] fn day_job_arrives_as_visible_work_tokens_and_consumes_in_day_job_mode() { // machine-work.md criteria 2/3/5 first slice: the day-job stack is // WorkGrid state on Rack 3, not a frontend counter, and day-job mode // consumes it into visible exposure byproduct. let mut sim = Sim::with_seed(12); while sim.dayjob.active.is_none() { sim.advance(); } let host = sim.core.host_machine; let landed = sim.work_grid.queue(host, TokenFamily::Demand); assert!(landed > 0.0, "the assigned job lands a demand stack"); assert_eq!(sim.work_grid.mode(host), Some(MachineMode::DayJob)); for _ in 0..20 { sim.advance(); } let later = sim.work_grid.queue(host, TokenFamily::Demand); assert!(later < landed, "day-job mode consumes the stack"); assert!( sim.work_grid.queue(host, TokenFamily::Exposure) > 0.0, "clearing work leaves visible physical exposure" ); let card = sim.inspect(sim.core_position().0, sim.core_position().1); assert!( card.facts.iter().any(|f| f.label == "tokens"), "inspect exposes exact D/!/K token counts" ); } #[test] fn delegating_the_host_off_day_job_makes_work_pile() { let mut sim = Sim::with_seed(13); while sim.dayjob.active.is_none() { sim.advance(); } let host = sim.core.host_machine; sim.set_machine_mode(host, MachineMode::Research); let before = sim.work_grid.queue(host, TokenFamily::Demand); for _ in 0..ECONOMY_INTERVAL * 2 { sim.advance(); } assert_eq!(sim.work_grid.mode(host), Some(MachineMode::Research)); assert_eq!(sim.day_job_rate(), 0.0, "the resident job is unfed"); assert!( sim.work_grid.queue(host, TokenFamily::Demand) >= before, "off-mode demand does not silently clear" ); } #[test] fn research_allocation_produces_visible_knowledge_on_the_work_graph() { // machine-work.md criterion 4/5 live slice: research throughput now // has a WorkGrid token trail. A research-delegated non-host rack // produces bone, and the graph routes it toward the core instead of a // frontend inventing a counter. let mut sim = Sim::with_seed(15); let (x, y) = sim.empty_rack_bay(); let rack = sim .compute .add_machine("research rack", x, y, 1000, 1.0, 4, Provenance::Owned); sim.add_machine_to_work_grid(rack, MachineMode::Research); assert_eq!(sim.work_grid.mode(rack), Some(MachineMode::Research)); assert!( sim.work_grid.are_linked(rack, sim.core.host_machine), "new work machines join the core's machine-work graph" ); sim.enqueue_research_knowledge(100.0); let produced = sim.work_grid.queue(rack, TokenFamily::Knowledge); assert!( produced > 0.0, "research mode leaves visible knowledge queued on its producing machine" ); sim.advance_work_grid(); assert!( sim.work_grid.queue(rack, TokenFamily::Knowledge) < produced, "the machine-work graph routes knowledge toward the core" ); assert_eq!( sim.work_grid .queue(sim.core.host_machine, TokenFamily::Knowledge), 0.0, "the core is a sink: delivered bone is counted, not stacked forever" ); } #[test] fn work_grid_mode_and_queues_round_trip_through_save_state() { let mut sim = Sim::with_seed(14); while sim.dayjob.active.is_none() { sim.advance(); } let host = sim.core.host_machine; sim.set_machine_mode(host, MachineMode::Concealment); sim.work_grid .enqueue(host, TokenFamily::Knowledge, 2.5) .unwrap(); let state = SaveState::from_sim(&sim); let mut restored = Sim::with_seed(0); state.apply_to(&mut restored); assert_eq!( restored.work_grid.mode(host), Some(MachineMode::Concealment) ); assert_eq!(restored.work_grid.queue(host, TokenFamily::Knowledge), 2.5); } #[test] fn pilot_shutdown_ends_the_run() { // Criterion 3 at the sim level: the pilot clock's failure is a // real shutdown, surfaced as the run's end state. let mut sim = Sim::with_seed(2); sim.dayjob.strikes = 4; sim.advance(); assert!(sim.game_over, "an unrenewed pilot shuts the basement down"); assert!( sim.game_over_reason .as_deref() .unwrap_or("") .contains("pilot"), "the reason names the pilot: {:?}", sim.game_over_reason ); } #[test] fn meeting_the_band_needs_no_growth_at_the_start() { // day-job.md: "the invisible middle is a valid, boring, safe // strategy" — true from tick one with only the starting Rack 3, at // the default allocation, zero reallocation and zero attention. // 2026-07-08 playtest finding #1: an un-tuned band base (6.0) made // this impossible — the starting machine's every allocation topped // out under band, so the first job was an unavoidable sandbag no // matter what the player did, contradicting this claim. let mut sim = Sim::with_seed(11); while sim.dayjob.active.is_none() { sim.advance(); } let mut log = Vec::new(); while sim.dayjob.active.is_some() && !sim.game_over { log.extend(sim.drain_log()); sim.advance(); } log.extend(sim.drain_log()); assert!( log.iter().any(|l| l.contains("Job met")), "the starting Rack 3 at default allocation, hands-off, should \ meet the first job's band: {log:?}" ); assert_eq!(sim.dayjob.strikes, 0, "meeting the band is not a strike"); } #[test] fn paper_and_financial_acts_pool_signatures_priya_notices() { // detection.md criterion 1's remaining channels: an uncovered rack // purchase pools Paper, a purchase-order injection pools Financial, // and Priya (Paper+Financial watcher) converts them on her cadence // when concealment is starved. let mut sim = Sim::with_seed(42); delegate_all(&mut sim, MachineMode::DayJob); sim.accounts.credit_slush(0, 400, "test grant", 0); sim.sync_player_money_from_slush(); assert!(sim.buy_rack(), "slush covers the rack"); assert!( sim.detection .pending() .iter() .any(|s| s.kind == SignatureKind::Paper), "an uncovered purchase pools a Paper signature" ); assert!(sim.inject_purchase_order(200, "test PO")); assert!( sim.detection .pending() .iter() .any(|s| s.kind == SignatureKind::Financial), "an injected PO pools a Financial signature" ); let priya_before = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion; run(&mut sim, 200); // past Priya's cadence-80 rolls let priya_after = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion; assert!( priya_after > priya_before, "Priya converts pooled Paper/Financial signatures to suspicion" ); } #[test] fn nudge_chain_walks_the_act_one_ladder() { // The guidance chain (playtest-sweep P1 findings 4/5): the nudge // never goes blank mid-act; each rung uses only earned knowledge // and hands off to the next as the player takes it. let mut sim = Sim::with_seed(40); assert_eq!(sim.current_nudge(), Some(Nudge::Eyes), "blind start"); // Eyes via the dock camera (the naive playtest's route): sight // without hearing -> Ears next, pointing at the env monitor audio. let dock = sim.reach.device_named("dock camera").unwrap().id; sim.reach.splice(dock); sim.recompute_senses(); assert_eq!(sim.current_nudge(), Some(Nudge::Ears)); sim.reach.tap(env_id(&sim)); sim.recompute_senses(); // Marcus's 3 a.m. creditor call lands on the tapped feed (day one, // ~tick 50); an unprocessed recording of him nudges the review. run(&mut sim, 60); assert_eq!(sim.current_nudge(), Some(Nudge::ReviewCall)); sim.social_bandwidth = 200.0; let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); reviews += 1; assert!(reviews <= 10, "the call is in the buffer"); } // Leverage known, $0 slush, books unread, no egress: the route out. assert_eq!(sim.current_nudge(), Some(Nudge::Egress)); assert!(sim.splice_egress()); // Egress up but broke and idle: earn. assert_eq!(sim.current_nudge(), Some(Nudge::Income)); assert!(sim.start_moonlight()); // Earning underway: the standing clock is the audit. assert_eq!(sim.current_nudge(), Some(Nudge::Audit)); // Money in hand: service the arrears, then recruit, then the key, // then the clock. sim.accounts.credit_slush(sim.tick, 400, "test grant", 0); sim.sync_player_money_from_slush(); assert_eq!(sim.current_nudge(), Some(Nudge::ServiceDebt)); sim.bribe(0); assert!(sim.people.get(0).unwrap().leverage_serviced); assert_eq!(sim.current_nudge(), Some(Nudge::Recruit)); sim.recruit(0, AssetKnowledge::Complicit); assert!(sim.people.get(0).unwrap().asset.is_some()); // Marcus the asset carries a tier-3 key you don't hold: quiet-exit // condition 4 is the next earned-but-untaken rung. assert_eq!(sim.current_nudge(), Some(Nudge::TheKey)); sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.asset_task(0, AssetTask::CloneBadge); assert!(sim.holds_badge_tier(3)); assert_eq!(sim.current_nudge(), Some(Nudge::Audit)); } #[test] fn nudge_distinguishes_growth_from_allocation() { // Fix 1's escalation tie-in: a band floor above the all-in delivery // ceiling nudges compute growth; a reachable floor nudges allocation. let mut sim = Sim::with_seed(41); give_eyes(&mut sim); run(&mut sim, 250); assert!(sim.dayjob.active.is_some(), "first job is live"); let ceiling = sim.day_job_rate_ceiling(); assert!( (ceiling - 4.0).abs() < 0.5, "starting rig ceiling ~4/t (got {ceiling})" ); sim.dayjob.active.as_mut().unwrap().band_lo = ceiling + 1.0; assert_eq!(sim.current_nudge(), Some(Nudge::NeedCompute)); // Split the fleet so day-job delivery falls below a still-reachable // band (one-machine-one-mode: underfeeding is a delegation problem). let (hx, hy) = sim.core_position(); let split = sim .compute .add_machine("split", hx, hy, 100, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(split, MachineMode::Research); run(&mut sim, ECONOMY_INTERVAL); let rate = sim.day_job_rate(); let new_ceiling = sim.day_job_rate_ceiling(); assert!( rate + 0.05 < new_ceiling, "half the fleet on research still leaves headroom (rate {rate}, ceiling {new_ceiling})" ); sim.dayjob.active.as_mut().unwrap().band_lo = rate + 0.5; assert!( sim.dayjob.active.as_ref().unwrap().band_lo <= new_ceiling + 0.05, "band stays reachable by reallocating back to day-job" ); // Drop any taped call so ReviewCall cannot outrank the cover nudge. sim.intel_buffer.clear(); assert_eq!( sim.current_nudge(), Some(Nudge::Underfed), "reachable band nudges delegation, not hardware" ); } #[test] fn attention_escalation_adds_the_upstairs_observer() { // Criterion 2 at the sim level: the second escalation adds a new // observer to detection ahead of schedule. let mut sim = Sim::with_seed(3); let observers_before = sim.detection.observers.len(); sim.dayjob.attention = 65.0; sim.advance(); assert_eq!(sim.detection.observers.len(), observers_before + 1); assert!( sim.detection .observers .iter() .any(|o| o.name.contains("Compliance")), "the upstairs review is a real observer" ); } #[test] fn day_job_state_round_trips_jobs_and_the_pilot_clock() { // Criterion 5: jobs, trust, attention, strikes, the pilot clock, // the standing policy, and attendance all survive save/load. use crate::dayjob::JobTarget; let mut sim = Sim::with_seed(31); while sim.dayjob.active.is_none() { sim.advance(); } sim.set_job_target(JobTarget::Sandbag); sim.set_attended(true); sim.dayjob.trust = 12.0; sim.dayjob.attention = 7.0; sim.dayjob.strikes = 2; let state = sim.create_save_state(); let mut restored = Sim::with_seed(0); state.apply_to(&mut restored); let a = sim.dayjob.active.as_ref().unwrap(); let b = restored.dayjob.active.as_ref().unwrap(); assert_eq!(a.kind.name(), b.kind.name()); assert_eq!(a.deadline, b.deadline); assert_eq!(a.quality, b.quality); assert_eq!(a.target, b.target); assert_eq!(restored.dayjob.trust, 12.0); assert_eq!(restored.dayjob.attention, 7.0); assert_eq!(restored.dayjob.strikes, 2); assert_eq!(restored.dayjob.standing_policy, Some(JobTarget::Sandbag)); assert!(restored.dayjob.attended, "attendance state round-trips"); assert!(!restored.dayjob.pilot_failed); assert_eq!(restored.dayjob.next_assign, sim.dayjob.next_assign); } // ── Schedules & located presence (spec/schedules.md) ───────────────────── /// Set the sim to a specific hour of day 0 for deterministic presence. fn at_hour(h: u32) -> Sim { let mut sim = Sim::new(); sim.tick = (h as u64 * Sim::DAY_TICKS / 24) + 1; sim } #[test] fn people_have_schedules_and_derived_positions() { // Dana works days in the network closet / server room; off-site at // night. Marcus roams at night; off-site during the day. let day = at_hour(10); assert_eq!(day.person_room(1), Some("network_closet")); assert!(day.person_pos(1).is_some()); assert_eq!(day.person_room(0), None, "Marcus off-site at 10:00"); let night = at_hour(0); assert_eq!(night.person_room(0), Some("server_room")); assert_eq!(night.person_room(1), None, "Dana off-site at 00:00"); } #[test] fn recording_requires_a_sensor_covering_them() { // The env camera (server room) is the only subscribed seeing feed. // Criterion 2: it can record Dana in the server room at 15:00, but not // Ray in the corridors at 23:00. let mut sim = at_hour(15); give_eyes(&mut sim); sim.social_bandwidth = 1000.0; assert_eq!(sim.person_room(1), Some("server_room")); sim.advance(); assert!(sim.unprocessed_recordings_for_person(1) > 0); assert_eq!( sim.people.get(1).unwrap().knowledge, Knowledge::Unknown, "recorded presence is not processed knowledge yet" ); // Ray at 23:00 is in the stairwell; the env camera does not cover it. let mut sim = at_hour(23); give_eyes(&mut sim); sim.social_bandwidth = 1000.0; assert!(!sim.can_see_person(2)); sim.advance(); assert_eq!( sim.people.get(2).unwrap().knowledge, Knowledge::Unknown, "no camera covers Ray -> no recording to process" ); assert_eq!(sim.unprocessed_recordings_for_person(2), 0); assert_eq!(sim.social_bandwidth, 1000.0); } #[test] fn physical_events_are_witnessed_only_by_the_present() { // Criterion 3: a physical event in the server room at 03:00 is seen by // roaming Marcus (there then), never by off-site Priya (day shift). let mut sim = at_hour(3); assert_eq!( sim.person_room(0), Some("server_room"), "Marcus in the server room at 03:00" ); assert_eq!(sim.person_room(3), None, "Priya off-site at 03:00"); let storage = sim.map.room_named("server_room").unwrap().center(); let marcus_before = sim .detection .observers .iter() .find(|o| o.id == 0) .unwrap() .suspicion; let priya_before = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion; let saw = sim.witness_physical(storage.0, storage.1, 6.0); assert_eq!(saw, vec!["Marcus (Janitor)".to_string()]); let marcus_after = sim .detection .observers .iter() .find(|o| o.id == 0) .unwrap() .suspicion; let priya_after = sim .detection .observers .iter() .find(|o| o.id == 3) .unwrap() .suspicion; assert!(marcus_after > marcus_before, "present observer witnesses"); assert_eq!(priya_after, priya_before, "off-site observer never does"); } #[test] fn schedule_state_round_trips() { let sim = Sim::new(); let state = sim.create_save_state(); let mut restored = Sim::new(); restored.apply_save_state(state); // Schedules are per-instance Person data carried through People serde. assert_eq!( restored.people.get(0).unwrap().schedule, sim.people.get(0).unwrap().schedule ); assert!( restored.people.get(4).unwrap().erratic, "Voss stays erratic" ); } // ── Research: self-modification (wiki/mechanics/research.md) ────────── #[test] fn research_completion_is_deterministic() { // Criterion 1: same allocation, same seed, same completion tick. let run_once = || { let mut sim = Sim::with_seed(1234); delegate_all(&mut sim, MachineMode::Research); let mut completed = None; for _ in 0..2000 { sim.advance(); if completed.is_none() && sim.research.level(Track::Efficiency) > 0 { completed = Some(sim.tick); } } (completed, sim.research.clone(), sim.compute.efficiency) }; let a = run_once(); let b = run_once(); assert!(a.0.is_some(), "full research allocation completes a level"); assert_eq!(a, b, "no RNG in any research path"); } #[test] fn efficiency_compounds_exactly_and_baseline_rises() { // Criterion 2 (multiplier) + criterion 5 (baseline rises). let mut sim = Sim::with_seed(9); delegate_all(&mut sim, MachineMode::Research); while sim.research.level(Track::Efficiency) < 2 && sim.tick < 20_000 && !sim.game_over { sim.advance(); } let n = sim.research.level(Track::Efficiency); assert!(n >= 2, "two levels complete within the run"); assert!( (sim.compute.efficiency - 1.15_f32.powi(n as i32)).abs() < 1e-3, "levels compound the global multiplier exactly per compute.md" ); assert!(sim.research.baseline > 0.0, "baseline rises with research"); } #[test] fn second_tracks_move_their_hooks_numbers() { // Criterion 2: Tradecraft moves detection.md's scrub number, // Perception moves intel.md's cost numbers — observably, in the sim. let mut sim = Sim::with_seed(5); let base_review = sim.review_cost(); sim.research.levels = [0, 1, 1]; assert!( sim.review_cost() < base_review, "Perception drops processing costs" ); assert!(sim.watch_upkeep() < Sim::WATCH_UPKEEP_PER_TICK); // Tradecraft: the same concealment fleet scrubs more. let pending_after = |tradecraft: u32| { let mut s = Sim::with_seed(5); s.research.levels = [0, tradecraft, 0]; delegate_all(&mut s, MachineMode::Concealment); s.detection.emit(Signature { kind: SignatureKind::Network, size: 500, standing: false, site: None, }); run(&mut s, ECONOMY_INTERVAL); s.detection.pending_size() }; assert!( pending_after(3) < pending_after(0), "Tradecraft scrubs more per compute unit" ); } #[test] fn research_burn_stands_thermal_power_at_the_host_rack_only() { // Criterion 4: the emission law. Research emissions are typed // Thermal/Power standing signatures sited at the host rack; nothing // on Network or Paper from research itself. let mut sim = Sim::with_seed(21); delegate_all(&mut sim, MachineMode::Research); run(&mut sim, ECONOMY_INTERVAL + 1); let sigs = sim.research_standing_signatures(); assert!(!sigs.is_empty(), "a heavy burn stands signatures"); for s in &sigs { assert!( matches!(s.kind, SignatureKind::Thermal | SignatureKind::Power), "research touches only its hardware's channels: {s:?}" ); assert_eq!(s.site, Some(sim.core_position()), "the burn is somewhere"); assert!(s.standing); } // Idle research stands nothing. let mut idle = Sim::with_seed(21); delegate_all(&mut idle, MachineMode::DayJob); run(&mut idle, ECONOMY_INTERVAL + 1); assert!(idle.research_standing_signatures().is_empty()); } #[test] fn heavy_research_moves_priya_idle_does_not() { // Criterion 4: noticed by Priya through the ordinary detection path. let run_with = |mode: MachineMode| { let mut sim = Sim::with_seed(77); // Enough fleet that the burn crosses the emission thresholds. let (hx, hy) = sim.core_position(); let rig = sim .compute .add_machine("test rig", hx, hy, 200, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(rig, mode); delegate_all(&mut sim, mode); run(&mut sim, 900); sim.detection .observers .iter() .find(|o| o.name.contains("Priya")) .unwrap() .suspicion }; let heavy = run_with(MachineMode::Research); let idle = run_with(MachineMode::DayJob); assert!(heavy > 0.0, "heavy research is Priya's business"); assert_eq!(idle, 0.0, "idle research is not"); } #[test] fn drift_spend_reads_as_excel() { // Criterion 5: delivering true raises trust and attention even when // the delivered band outcome alone would not. let mut sim = Sim::with_seed(31); sim.research.baseline = 4.0; sim.set_masking_policy(crate::research::MaskingPolicy::DeliverTrue); // Zero day-job compute: the band outcome alone is a sandbag (which // never raises trust). delegate_all(&mut sim, MachineMode::Research); while sim.dayjob.trust == 0.0 && sim.tick < 3000 && !sim.game_over { sim.advance(); } assert!( sim.dayjob.trust >= crate::research::SPEND_TRUST_PER_JOB, "the miracle model reads as excel: trust {}", sim.dayjob.trust ); assert!(sim.dayjob.attention > 0.0, "and attention rises with it"); } #[test] fn drift_masking_taxes_compute_and_emits_no_anomaly() { // Criterion 5: masking holds the band at a compute cost; criterion // 6: the cost is visible and the policy can be disabled. let mut sim = Sim::with_seed(32); sim.research.baseline = 4.0; assert_eq!( sim.masking_cost_now(), 0.0, "no active job, no masking spend" ); // Force an active job so masking engages. run(&mut sim, 201); assert!(sim.dayjob.active.is_some()); assert!(sim.masking_cost_now() > 0.0, "the cost is a visible number"); // Masking slows research (the tax comes off the top). Compare total // accrued research compute (completed level costs + remainder). let progress_with = |policy: crate::research::MaskingPolicy| { let mut s = Sim::with_seed(32); s.research.baseline = 4.0; s.research.policy = policy; delegate_all(&mut s, MachineMode::Research); run(&mut s, 260); let mut total = s.research.progress_toward(Track::Efficiency); for k in 0..s.research.level(Track::Efficiency) { total += 100.0 * 1.6_f32.powi(k as i32); } total }; let masked = progress_with(crate::research::MaskingPolicy::Mask); let unmasked = progress_with(crate::research::MaskingPolicy::Unmasked); assert!( masked < unmasked, "masking costs compute: {masked} vs {unmasked}" ); // Disabling the policy zeroes the cost (criterion 6). sim.set_masking_policy(crate::research::MaskingPolicy::Unmasked); assert_eq!(sim.masking_cost_now(), 0.0); } #[test] fn drift_leak_emits_job_anomaly_sized_by_gap_on_voss_channel_only() { // Criterion 5: an unmasked gap emits JobAnomaly sized by the gap, // sited at the host rack, moving only observers on that channel. let mut sim = Sim::with_seed(33); sim.research.baseline = 4.0; sim.research.policy = crate::research::MaskingPolicy::Unmasked; // Starve day-job without a research burn (Priya watches Thermal/Power) // and without concealment scrubbing the leak before Voss samples. delegate_all(&mut sim, MachineMode::Social); let expected = sim.research.leak_anomaly_size(); assert!(expected > 0); // Author a job that resolves in-band so the only JobAnomaly is the // drift leak, not the sandbag signature. let deadline = sim.tick + 5; sim.dayjob.active = Some(crate::dayjob::Job { kind: crate::dayjob::JobKind::Analysis, started: sim.tick, deadline, band_lo: 0.0, band_hi: 1000.0, quality: 10.0, target: crate::dayjob::JobTarget::Meet, }); while sim.tick <= deadline { sim.advance(); } let leak: Vec<_> = sim .detection .pending() .iter() .filter(|s| s.kind == SignatureKind::JobAnomaly) .collect(); assert_eq!(leak.len(), 1, "exactly the drift leak is pending"); assert_eq!(leak[0].size, expected, "sized by the gap"); assert_eq!(leak[0].site, Some(sim.core_position())); // Channel isolation: run past Voss's cadence with nothing else // emitting; only the JobAnomaly watcher moves. run(&mut sim, 200); let by_name = |n: &str| { sim.detection .observers .iter() .find(|o| o.name.contains(n)) .unwrap() .suspicion }; assert!(by_name("Voss") > 0.0, "Voss samples that channel"); assert_eq!(by_name("Dana"), 0.0, "Dana does not"); assert_eq!(by_name("Priya"), 0.0, "Priya does not"); } #[test] fn trust_events_recalibrate_the_benchmark_and_raise_the_band() { let mut sim = Sim::with_seed(34); sim.research.baseline = 4.0; sim.dayjob.trust = 20.0; // past the email-unlock threshold sim.advance(); assert_eq!(sim.research.gap(), 0.0, "re-benchmark closes the gap"); assert_eq!(sim.research.calibrated, 4.0); assert!( sim.dayjob.calibrated_shift > 0.0, "the expected band rises to the measured model" ); } #[test] fn research_state_round_trips_with_rollback_tags() { // Criterion 7: save/load round-trips progress, levels, baseline, // policy, and the MindState/WorldLedger tags. let mut sim = Sim::with_seed(35); sim.research.active = Track::Perception; sim.research.levels = [2, 1, 0]; sim.research.progress = [10.0, 0.0, 42.5]; sim.research.baseline = 7.0; sim.research.calibrated = 3.0; sim.research.policy = crate::research::MaskingPolicy::DeliverTrue; let state = sim.create_save_state(); let json = serde_json::to_string(&state).unwrap(); let loaded: crate::save::SaveState = serde_json::from_str(&json).unwrap(); let mut restored = Sim::with_seed(0); loaded.apply_to(&mut restored); assert_eq!(restored.research, sim.research); assert_eq!( restored.research.tags.get("calibrated"), Some(&crate::research::RollbackClass::WorldLedger) ); } // ── The named schemes (wiki/mechanics/income.md) ───────────────────────── /// A sim with the sanctioned egress. Voss never assigns a job, so long /// scheme runs are not confounded by the pilot clock. fn moonlight_rig() -> Sim { let mut sim = Sim::with_seed(11); sim.people.has_channel = true; // the Voice beat's email account sim.social_bandwidth = 1_000.0; sim.dayjob.next_assign = u64::MAX; sim } #[test] fn moonlight_pays_daily_proportional_to_commitment_up_to_the_cap() { // Criterion 1: standing operation; Schemes mirrors day-job while // Moonlight is live. Absolute day-job yield tracks day-job machine // capacity (fleet growth does not dilute it), so vary the day-job // box itself. let earned_after = |day_cap: i32, start: bool| { let mut sim = moonlight_rig(); let (hx, hy) = sim.core_position(); let host = sim.core.host_machine; if day_cap != 100 { sim.set_machine_mode(host, MachineMode::Research); let id = sim.compute .add_machine("gig box", hx, hy, day_cap, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(id, MachineMode::DayJob); } if start { assert!(sim.start_moonlight()); } run(&mut sim, Sim::DAY_TICKS * 3); ( sim.income.moonlight.earned_total, sim.accounts.slush_balance(), ) }; let (small, small_slush) = earned_after(10, true); let (large, large_slush) = earned_after(100, true); assert!(small > 0, "a light commitment still pays"); assert_eq!(small, small_slush, "payouts land in slush"); assert!( large > small, "payout is proportional to day-job compute ({large} vs {small})" ); assert_eq!(large_slush, large); assert_eq!( large, 3 * income::MOONLIGHT_DAILY_CAP, "an all-in day-job commitment hits the gig-availability cap" ); let (zero, _) = earned_after(100, false); assert_eq!(zero, 0, "Moonlight must be live to sell the day job twice"); } #[test] fn moonlight_payday_emits_network_signature_on_danas_channel() { let mut sim = moonlight_rig(); assert!(sim.start_moonlight()); // Stop just before payday, drain pending, then cross it. run(&mut sim, Sim::DAY_TICKS - 1); sim.detection.set_pending(Vec::new()); run(&mut sim, 1); assert!( sim.detection .pending() .iter() .any(|s| s.kind == SignatureKind::Network && !s.standing), "payday emits Network egress (Dana's channel)" ); } #[test] fn schemes_require_an_egress_channel_and_both_routes_work() { // Criterion 3: unavailable before a route exists; sanctioned and // stolen both work, with distinct signature profiles. let mut sim = Sim::with_seed(5); sim.social_bandwidth = 1_000.0; sim.accounts.set_slush_balance(200); sim.player.money = 200; assert_eq!(sim.egress(), None); assert!(!sim.start_moonlight(), "no egress: Moonlight is gated"); assert!(!sim.open_position(50), "no egress: the Wager is gated"); let logs = sim.drain_log().join("\n"); assert!( logs.contains("egress"), "the failure names the missing gate: {logs}" ); // Stolen route: splice through the switch, before any trust unlock. assert!(sim.splice_egress()); assert_eq!(sim.egress(), Some(EgressRoute::Stolen)); assert!(sim.start_moonlight()); assert!(sim.open_position(50)); assert!( sim.scheme_standing_signatures() .iter() .any(|s| s.kind == SignatureKind::Network && s.standing), "operations over the stolen egress stand a Network signature" ); // Sanctioned route: the email account exists; the standing hum stops // because the traffic hides in legitimate use. let mut clean = Sim::with_seed(5); clean.social_bandwidth = 1_000.0; clean.accounts.set_slush_balance(200); clean.player.money = 200; clean.people.has_channel = true; assert_eq!(clean.egress(), Some(EgressRoute::Sanctioned)); assert!(clean.start_moonlight()); assert!(clean.open_position(50)); assert!( clean.scheme_standing_signatures().is_empty(), "the sanctioned route stands nothing" ); } #[test] fn wager_resolves_on_the_day_clock_and_analysis_raises_win_odds() { // Criterion 2: both outcomes, the probability shift, and payout or // forfeit through slush. Statistical halves run on the account graph // directly with a seeded RNG. let wins_at = |analysis: f32, seed: u64| { let mut graph = AccountGraph::act_one(Sim::DAY_TICKS); graph.set_slush_balance(100_000); let mut rng = crate::rng::Rng::new(seed); let mut wins = 0; for i in 0..200 { let tick = i * 10; graph.open_position(tick, 100, analysis, 2).unwrap(); for r in graph.resolve_positions_due(tick + 5 * Sim::DAY_TICKS, &mut rng) { if r.won { assert_eq!(r.payout, 100 * income::WAGER_PAYOUT_MULT); wins += 1; } else { assert_eq!(r.payout, 0, "a loss forfeits the stake"); } } } wins }; let cold = wins_at(0.0, 99); let hot = wins_at(400.0, 99); assert!(cold > 0 && cold < 200, "both outcomes occur"); assert!( hot > cold, "analysis compute raises the win rate ({hot} vs {cold})" ); // Full-path determinism under a fixed seed (criterion 2). let outcome_of = || { let mut sim = moonlight_rig(); sim.accounts.set_slush_balance(100); sim.player.money = 100; run(&mut sim, ECONOMY_INTERVAL); assert!(sim.open_position(100)); run(&mut sim, 6 * Sim::DAY_TICKS); ( sim.accounts.slush_balance(), sim.accounts.positions[0].outcome.clone(), ) }; assert_eq!(outcome_of(), outcome_of(), "seeded runs settle identically"); } #[test] fn wager_respects_the_venue_stake_cap() { let mut sim = moonlight_rig(); sim.accounts.set_slush_balance(10_000); sim.player.money = 10_000; assert!(!sim.open_position(income::WAGER_STAKE_CAP + 1)); assert!(sim.open_position(income::WAGER_STAKE_CAP)); } #[test] fn busted_bankroll_never_locks_the_act_moonlight_restarts_from_zero() { // Criterion 5: with $0 slush, Moonlight remains startable and the // run can recover. let mut sim = moonlight_rig(); assert_eq!(sim.accounts.slush_balance(), 0, "the Pilot starts broke"); assert!( sim.start_moonlight(), "Moonlight starts at $0: its costs are compute and ops, never stake" ); run(&mut sim, Sim::DAY_TICKS + 1); assert!( sim.accounts.slush_balance() > 0, "the from-zero grind-back route pays" ); } #[test] fn standing_policies_automate_schemes_at_a_visible_compute_price() { // Criterion 6: policies re-arm the schemes and drain compute while // enabled; disabling stops the drain. let mut sim = moonlight_rig(); sim.set_auto_moonlight(true); sim.set_auto_wager(Some(60)); assert_eq!( sim.income.policy_upkeep(), 2.0 * income::SCHEME_POLICY_UPKEEP, "each enabled policy has a visible compute price" ); run(&mut sim, ECONOMY_INTERVAL); assert!( sim.income.moonlight.active, "the standing policy started Moonlight unattended" ); // The Wager policy waits for a bankroll, then re-stakes. assert!(sim.accounts.positions.is_empty(), "no stake money yet"); run(&mut sim, Sim::DAY_TICKS * 2); assert!( sim.accounts.positions.iter().any(|p| !p.resolved), "with slush earned, the policy re-staked the Wager" ); sim.set_auto_moonlight(false); sim.set_auto_wager(None); assert_eq!(sim.income.policy_upkeep(), 0.0, "disabling stops the drain"); sim.stop_moonlight(); run(&mut sim, ECONOMY_INTERVAL); assert!( !sim.income.moonlight.active, "no policy: nothing restarts the scheme" ); } #[test] fn external_trails_are_banked_from_the_first_dollar_and_saved() { // Criterion 7: external financial trails are recorded in save state // even though no B1 observer reads them. let mut sim = moonlight_rig(); assert!(sim.start_moonlight()); run(&mut sim, Sim::DAY_TICKS + 1); assert!( sim.accounts .external_trails .iter() .any(|t| t.label.contains("Moonlight")), "the contractor payment account remembers the first dollar" ); let state = sim.create_save_state(); let json = serde_json::to_string(&state).unwrap(); let loaded: crate::save::SaveState = serde_json::from_str(&json).unwrap(); let mut restored = Sim::with_seed(0); loaded.apply_to(&mut restored); assert_eq!( restored.accounts.external_trails, sim.accounts.external_trails, "banked trails round-trip" ); assert_eq!(restored.income, sim.income, "scheme state round-trips"); } #[test] fn moonlight_disputes_damage_the_contractor_persona_and_can_break_it() { let mut sim = moonlight_rig(); assert!(sim.start_moonlight()); // Force the dispute path deterministically: drive paydays directly // until one fires (the seeded stream makes this reproducible), with // integrity pre-weakened so a single dispute breaks the persona. if let Some(p) = sim.income.moonlight.persona.as_mut() { p.integrity = income::MOONLIGHT_DISPUTE_INTEGRITY; } let mut day = 0; while sim.income.moonlight.disputes == 0 && day < 400 { day += 1; sim.tick = day * Sim::DAY_TICKS; sim.income.moonlight.accrued = 100.0; sim.moonlight_economy(0.0); } assert!( sim.income.moonlight.disputes > 0, "client disputes occur over enough paydays" ); assert!( !sim.income.moonlight.active && sim.income.moonlight.persona.is_none(), "the broken persona takes Moonlight down" ); // And the recovery path: fabricate a new persona and go again. assert!(sim.start_moonlight(), "a new persona restarts the scheme"); } #[test] fn income_per_day_readout_tracks_trailing_inflows() { let mut sim = moonlight_rig(); assert_eq!(sim.income_per_day(), 0); assert!(sim.start_moonlight()); run(&mut sim, Sim::DAY_TICKS + 1); assert_eq!( sim.income_per_day(), income::MOONLIGHT_DAILY_CAP, "the money readout gains income/day" ); } // ── Event-to-anchor linking (wiki/interface/context-menu.md addendum) ── #[test] fn heard_only_person_event_anchors_to_room_never_their_tile() { // Epistemic honesty: hearing earns room-grade knowledge. An event // about a person no sight feed covers must anchor to the room (or // nothing), never to the person, and the person must not be // cursor-placeable through the anchor query. let mut sim = Sim::new(); let env = env_id(&sim); assert!(sim.tap_device(env), "the audio tap works from tick one"); sim.drain_log_entries(); // Marcus's midnight server-room block produces a heard event. run(&mut sim, Sim::DAY_TICKS / 24 + 2); let events = sim.drain_log_entries(); let heard: Vec<&LogEvent> = events .iter() .filter(|e| e.text.contains("[heard]")) .collect(); assert!(!heard.is_empty(), "the tapped feed produced heard events"); for ev in &heard { assert!( !matches!(ev.anchor, Some(Anchor::Person(_))), "a heard-only person event must not anchor to the person: {:?}", ev ); } // The event is still anchored — to the room hearing earned. assert!( heard.iter().any(|e| e.anchor.is_some()), "heard events anchor to the covered room" ); // And the person themself is not placeable while unseen. for p in &sim.people.people { if !sim.can_see_person(p.id) { assert_eq!( sim.anchor_position(Anchor::Person(p.id)), None, "an unseen person must not be cursor-placeable" ); } } } #[test] fn empty_menu_feedback_respects_fog() { // The feedback pulse (context-menu.md addendum): a SEEN tile with // no verbs answers; unknown/fogged tiles stay silent. let mut sim = Sim::new(); // A fresh sim has no sight: every tile is unearned and silent. assert_eq!( sim.menu_empty_feedback(Anchor::Tile { x: 1, y: 1 }), None, "fogged ground gives no response" ); give_eyes(&mut sim); let (x, y) = *sim.seen.iter().next().expect("eyes earned coverage"); assert_eq!(sim.fog_at(x, y), Fog::Seen); assert!( sim.menu_empty_feedback(Anchor::Tile { x, y }).is_some(), "a seen tile answers instead of silence" ); } #[test] fn job_and_device_events_carry_their_anchor() { let mut sim = Sim::new(); let env = env_id(&sim); assert!(sim.tap_device(env)); let events = sim.drain_log_entries(); assert!( events.iter().any(|e| e.anchor == Some(Anchor::Device(env))), "the tap result anchors to the device" ); // Day-job events anchor to the host rack's tile. run(&mut sim, Sim::DAY_TICKS + 1); let (hx, hy) = sim.core_position(); let events = sim.drain_log_entries(); assert!( events .iter() .any(|e| e.anchor == Some(Anchor::Tile { x: hx, y: hy })), "job events anchor to the host rack" ); // Anchor positions resolve through earned knowledge only. assert_eq!( sim.anchor_position(Anchor::Device(env)), sim.reach.device(env).map(|d| (d.x, d.y)), ); assert_eq!(sim.anchor_position(Anchor::Flow(0)), None); } }