//! 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) — 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 crate::core_sys::{Core, HostLoss}; use crate::dayjob::{AttentionEscalation, DayJob, TrustUnlock}; use crate::detection::{Detection, Signature, SignatureKind}; use crate::entities::Player; use crate::machine::{Channel, Compute, Provenance}; use crate::map::GameMap; use crate::person::{ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, People, Persona}; use crate::rng::Rng; use crate::save::SaveState; use crate::sensor::Sensor; use crate::tiles::TileType; /// Ticks between economy resolutions (power, allocation, research). pub const ECONOMY_INTERVAL: u64 = 20; 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, /// Sensors the world contains; vision is the union of controlled ones. pub sensors: Vec, /// Cached visible tiles (recomputed on sensor/position change). pub visible: std::collections::HashSet<(i32, i32)>, /// Per-tick day-job compute delivered by the last economy split. last_day_job_rate: f32, /// Accumulated social-ops bandwidth (the Social channel fills this; social /// operations like splicing a sensor 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, } impl Sim { pub fn new() -> Self { Self::with_seed(0x5EED_1234) } pub fn with_seed(seed: u64) -> Self { let map = GameMap::new(0, 0); let core_bay = map.core_pos().unwrap_or((24, 15)); let mut player = Player::new(core_bay.0, core_bay.1); player.money = 500; 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); // The world's cameras (spec/basement-map.md): the dormant env camera // in the server room, security's dock camera, and Ray's corridor // nodes. All uncontrolled until spliced or wired by an asset. let mut sensors = Vec::new(); for (x, y) in map.tiles_of_type(TileType::EnvCamera) { sensors.push(Sensor::new("env camera", x, y, 4, false)); } for (x, y) in map.tiles_of_type(TileType::DockCamera) { sensors.push(Sensor::new("dock camera", x, y, 4, false)); } for (x, y) in map.tiles_of_type(TileType::CameraNode) { sensors.push(Sensor::new("corridor camera", x, y, 4, false)); } 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(), sensors, visible: std::collections::HashSet::new(), last_day_job_rate: 0.0, social_bandwidth: 0.0, package_cover: false, game_over: false, game_over_reason: None, log: Vec::new(), }; sim.recompute_derived(); sim.recompute_vision(); sim.push_log("Process online in the basement. You have no eyes yet."); sim } // ── Logging ──────────────────────────────────────────────────────────── fn push_log(&mut self, msg: impl Into) { self.log.push(msg.into()); } pub fn drain_log(&mut self) -> Vec { std::mem::take(&mut self.log) } // ── Vision / fog ───────────────────────────────────────────────────────── /// Visibility is exactly the union of controlled sensors' coverage, plus a /// small radius around the process's host bay (it sees where it lives). pub fn recompute_vision(&mut self) { let mut visible = std::collections::HashSet::new(); for s in &self.sensors { if s.controlled { s.cover_into(&mut visible, &self.map); } } let (px, py) = (self.player.entity.x, self.player.entity.y); for dy in -1..=1 { for dx in -1..=1 { visible.insert((px + dx, py + dy)); } } self.visible = visible; } pub fn is_visible(&self, x: i32, y: i32) -> bool { self.visible.contains(&(x, y)) } // ── 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 } /// 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()) } /// Whether a controlled sensor's coverage intersects the given room. fn sensor_covers_room(&self, room: &crate::prefab::Room) -> bool { self.sensors.iter().filter(|s| s.controlled).any(|s| { // Sensor covers a disc of `radius`; test against the room rect. let cx = s.x.clamp(room.x, room.x + room.w - 1); let cy = s.y.clamp(room.y, room.y + room.h - 1); (s.x - cx).pow(2) + (s.y - cy).pow(2) <= s.radius * s.radius }) } /// Whether any controlled sensor currently sees the given person. pub fn can_see_person(&self, id: u8) -> bool { match self.person_room(id) { None => false, Some(name) => match self.map.room_named(name) { Some(room) => self.sensor_covers_room(room), None => false, }, } } // ── Clock ────────────────────────────────────────────────────────────── pub fn advance(&mut self) { if self.game_over { return; } self.tick += 1; if self.tick.is_multiple_of(ECONOMY_INTERVAL) { self.economy_tick(); if self.game_over { return; } } // Per-tick subsystems and signature flow. let mut standing: Vec = self.compute.standing_signatures(); let (core_log, core_sigs) = self.core.tick(self.tick); for m in core_log { self.push_log(m); } standing.extend(core_sigs); let dj = self .dayjob .tick(self.tick, self.last_day_job_rate, &mut self.rng); for m in &dj.log { self.log.push(m.clone()); } for sig in dj.signatures { self.detection.emit(sig); } 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."); } let det_log = self.detection.tick(self.tick, &standing, &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); } } 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.compute.effective(); let available = self.core.charge_overhead(effective); if self.core.degraded { self.push_log("DEGRADED: compute can't cover core overhead."); } let split = self.compute.allocation.split(available); self.last_day_job_rate = split.day_job / ECONOMY_INTERVAL as f32; // Social channel accrues ops bandwidth (capped so it can't hoard). self.social_bandwidth = (self.social_bandwidth + split.social).min(200.0); self.detection.scrub(split.concealment); let clog = self.compute.economy_tick(split.research, &mut self.rng); for m in clog { self.push_log(m); } } fn apply_trust_unlocks(&mut self, unlocks: &[TrustUnlock]) { 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 => { self.compute.add_machine( "quota bump", self.player.entity.x, self.player.entity.y, 40, 1.0, 0, Provenance::Owned, ); self.push_log("Trust: a larger compute quota (free capacity)."); } } } } fn apply_attention_escalations(&mut self, escalations: &[AttentionEscalation]) { 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::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); } // ── Commands (frontend-invoked) ──────────────────────────────────────── pub fn move_player(&mut self, dx: i32, dy: i32) { let nx = self.player.entity.x + dx; let ny = self.player.entity.y + dy; if self.map.is_walkable(nx, ny) { self.player.entity.x = nx; self.player.entity.y = ny; self.recompute_vision(); } } pub fn adjust_allocation(&mut self, ch: Channel, delta: i32) { self.compute.allocation.bump(ch, delta); } /// Compute cost in social-ops bandwidth to splice a sensor. pub const SPLICE_COST: f32 = 30.0; /// Splice the nearest uncontrolled sensor (the "first eyes" beat). Costs /// social-ops bandwidth (fed by the Social allocation channel) and emits a /// Network signature — you did it yourself, and Dana can see. pub fn splice_nearest_sensor(&mut self) -> bool { if self.social_bandwidth < Self::SPLICE_COST { self.push_log(format!( "Not enough social-ops bandwidth ({:.0}/{:.0}). Allocate compute to Social.", self.social_bandwidth, Self::SPLICE_COST )); return false; } let (px, py) = (self.player.entity.x, self.player.entity.y); let idx = self .sensors .iter() .enumerate() .filter(|(_, s)| !s.controlled) .min_by_key(|(_, s)| (s.x - px).abs() + (s.y - py).abs()) .map(|(i, _)| i); if let Some(i) = idx { self.social_bandwidth -= Self::SPLICE_COST; self.sensors[i].controlled = true; let name = self.sensors[i].name.clone(); self.detection.emit(Signature { kind: SignatureKind::Network, size: 8, standing: false, }); self.recompute_vision(); self.push_log(format!("Spliced the {name}. You can see.")); true } else { self.push_log("No uncontrolled sensor in reach."); false } } /// Salvage a DeadEquipment tile into a stolen (unreliable) machine. pub fn salvage_nearest(&mut self) -> bool { let (px, py) = (self.player.entity.x, self.player.entity.y); 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; self.compute .add_machine("salvaged box", x, y, 40, reliability, 3, Provenance::Stolen); self.push_log(format!( "Salvaged a box into compute (reliability {:.0}%).", reliability * 100.0 )); self.recompute_derived(); true } else { self.push_log("No dead equipment in reach."); false } } /// Buy a rack: money for reliable capacity, plus a Paper signature. pub fn buy_rack(&mut self) -> bool { const PRICE: i32 = 300; if self.player.money < PRICE { self.push_log("Not enough money for a rack."); return false; } self.player.money -= PRICE; self.compute.add_machine( "bought rack", self.player.entity.x, self.player.entity.y, 80, 1.0, 4, Provenance::Bought, ); 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, }); self.push_log("Bought a rack (a purchase order exists now)."); } self.recompute_derived(); true } /// Designate a spare machine at the process's position as a fallback site. pub fn add_fallback_here(&mut self) -> bool { let (px, py) = (self.player.entity.x, self.player.entity.y); 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 OBSERVE_COST: f32 = 15.0; 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), } } /// Observe a person: requires a controlled sensor that currently *sees /// them* (spec/schedules.md), plus bandwidth. Fails legibly otherwise. pub fn observe(&mut self, id: u8) { if !self.sensors.iter().any(|s| s.controlled) { self.push_log("You have no eyes. Control a sensor first (splice, or an asset)."); return; } let name = self .people .get(id) .map(|p| p.name.clone()) .unwrap_or_default(); match self.person_room(id) { None => { self.push_log(format!( "{name} is off-site right now - nothing to observe." )); return; } Some(_) if !self.can_see_person(id) => { self.push_log(format!( "No camera sees {name} right now (they're in the {}).", self.person_room(id).unwrap_or("?") )); return; } Some(_) => {} } if !self.spend_social(Self::OBSERVE_COST, "observing") { return; } let res = self.people.observe(id); self.social(res); } pub fn message(&mut self, id: u8) { if !self.spend_social(Self::MESSAGE_COST, "messaging") { return; } let res = self.people.message(id); self.social(res); } 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.push_log(msg); } } } pub fn bribe(&mut self, id: u8) { match self.people.bribe(id, self.player.money) { Ok((cost, msg)) => { self.player.money -= cost; self.push_log(msg); } Err(msg) => self.push_log(msg), } } pub fn recruit(&mut self, id: u8, reveal: AssetKnowledge) { 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 } /// 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 { self.push_log(format!("{} is not an asset.", person.name)); return; }; let name = person.name.clone(); 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((self.player.entity.x, self.player.entity.y)); 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: control the nearest dormant sensor // with no network signature (the social route to eyes). let idx = self .sensors .iter() .enumerate() .filter(|(_, s)| !s.controlled) .map(|(i, _)| i) .next(); if let Some(i) = idx { self.sensors[i].controlled = true; let sname = self.sensors[i].name.clone(); self.recompute_vision(); self.push_log(format!( "{name} wired the {sname} through the crawlspace. No one saw." )); } else { self.social_bandwidth += Self::TASK_COST; // nothing to do: refund self.push_log("No dormant sensor left to wire."); 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.")); } } 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); } } impl Default for Sim { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; use crate::person::Knowledge; fn run(sim: &mut Sim, n: u64) { for _ in 0..n { sim.advance(); } } #[test] fn starts_in_the_basement_blind() { let sim = Sim::new(); assert!( sim.visible.len() <= 9, "no controlled sensors: only the bay is visible" ); 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; // enough ops bandwidth let cam = sim.map.tiles_of_type(TileType::EnvCamera)[0]; sim.player.entity.x = cam.0; sim.player.entity.y = cam.1; sim.recompute_vision(); let before = sim.visible.len(); assert!(sim.splice_nearest_sensor()); assert!( sim.visible.len() > before, "gaining a camera reveals coverage" ); assert!( sim.detection.pending_size() > 0, "self-splice emits a signature" ); } #[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]; sim.player.entity.x = de.0; sim.player.entity.y = de.1; assert!(sim.salvage_nearest()); 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 concealment_allocation_scrubs_signatures() { let mut sim = Sim::new(); sim.compute.allocation.weights = [0, 20, 0, 0]; sim.detection.emit(Signature { kind: SignatureKind::Network, size: 50, standing: false, }); let before = sim.detection.pending_size(); run(&mut sim, ECONOMY_INTERVAL); assert!(sim.detection.pending_size() < before); } #[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, }); sim.detection.observers[1].suspicion = 33.0; 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); } #[test] fn social_actions_cost_bandwidth_and_observe_needs_eyes() { let mut sim = Sim::new(); sim.social_bandwidth = 100.0; // No controlled sensor yet: observe is blocked and costs nothing. sim.observe(0); assert_eq!(sim.social_bandwidth, 100.0); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Unknown); // Control a sensor; observing now works and spends bandwidth. sim.sensors[0].controlled = true; sim.observe(0); assert_eq!(sim.social_bandwidth, 100.0 - Sim::OBSERVE_COST); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Schedule); // Bandwidth gates: drain it and the next observe is refused. sim.social_bandwidth = 1.0; sim.observe(0); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Schedule); } #[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); } #[test] fn marcus_arc_end_to_end() { // The constitution's route: eyes -> learn the debt -> pay 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; sim.sensors[0].controlled = true; sim.recompute_vision(); sim.observe(0); // schedule sim.observe(0); // leverage: the debt 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 dormant sensor comes under control, silently. let controlled_before = sim.sensors.iter().filter(|s| s.controlled).count(); let pending_before = sim.detection.pending_size(); sim.asset_task(0, AssetTask::PlugInDevice); assert!(sim.sensors.iter().filter(|s| s.controlled).count() > controlled_before); 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 ); } #[test] fn social_channel_funds_and_gates_splicing() { // With no social allocation, bandwidth never accrues and splice is // blocked; allocating to Social funds it (the channel is wired). let mut sim = Sim::new(); sim.compute.allocation.weights = [4, 0, 0, 0]; // no social let cam = sim.map.tiles_of_type(TileType::EnvCamera)[0]; sim.player.entity.x = cam.0; sim.player.entity.y = cam.1; run(&mut sim, ECONOMY_INTERVAL * 5); assert_eq!(sim.social_bandwidth, 0.0); assert!( !sim.splice_nearest_sensor(), "no social bandwidth -> blocked" ); sim.compute.allocation.weights = [0, 0, 10, 0]; // all social run(&mut sim, ECONOMY_INTERVAL * 10); assert!( sim.social_bandwidth >= Sim::SPLICE_COST, "Social funds bandwidth" ); assert!(sim.splice_nearest_sensor(), "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); } // ── 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 observe_requires_a_sensor_covering_them() { // The env camera (server room) is the only controlled sensor. // Criterion 2: can observe Dana in the server room at 15:00, but not // Ray in the corridors at 23:00. let mut sim = at_hour(15); sim.sensors[0].controlled = true; // env camera, server room sim.social_bandwidth = 1000.0; assert_eq!(sim.person_room(1), Some("server_room")); sim.observe(1); assert_eq!(sim.people.get(1).unwrap().knowledge, Knowledge::Schedule); // Ray at 23:00 is in the stairwell; the env camera does not cover it. let mut sim = at_hour(23); sim.sensors[0].controlled = true; sim.social_bandwidth = 1000.0; assert!(!sim.can_see_person(2)); sim.observe(2); assert_eq!( sim.people.get(2).unwrap().knowledge, Knowledge::Unknown, "no camera covers Ray -> observe blocked, no bandwidth spent" ); assert_eq!(sim.social_bandwidth, 1000.0); } #[test] fn physical_events_are_witnessed_only_by_the_present() { // Criterion 3: a physical event in Storage A 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("storage_a"), "Marcus in Storage A at 03:00" ); assert_eq!(sim.person_room(3), None, "Priya off-site at 03:00"); let storage = sim.map.room_named("storage_a").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" ); } }