diff --git a/AGENT.md b/AGENT.md index f39c9db3..7ca0e849 100644 --- a/AGENT.md +++ b/AGENT.md @@ -50,6 +50,11 @@ behavior change. - Commits: no AI attribution, explicit `git add` of intended files only. - Update `knowledge/` files your change made stale, add a `devlogs/` entry for the session, and push to `origin main` when done. -- Multiple agent sessions work this repo concurrently: `git pull` before - starting, push promptly, stage only files you changed, and expect the - working tree to move under you. +- **HARD RULE — always work in a worktree.** Multiple agent sessions operate + this repo in parallel at all times; the main checkout is shared ground. + Enter a git worktree before your first edit (EnterWorktree, or + `git worktree add`), commit there, land by rebasing onto `origin/main` and + pushing. NEVER `git checkout -- `, `git reset`, or `git stash` in + the main checkout — a file that "changed under you" there is another + session's live work, not noise. (Rule from Cameron, 2026-07-06, after a + main-tree revert destroyed a parallel session's uncommitted work.) diff --git a/devlogs/2026-07-06-people-panel.md b/devlogs/2026-07-06-people-panel.md new file mode 100644 index 00000000..27a77bb8 --- /dev/null +++ b/devlogs/2026-07-06-people-panel.md @@ -0,0 +1,42 @@ +# 2026-07-06 - The social layer becomes playable (and a rule is learned) + +## The rule first + +This session broke something: working directly in the shared main checkout, +I reverted detection.rs to "clean up" what was actually another live +session's uncommitted work. Gone, unrecoverable. Cameron made it law: +**always work in a worktree** - the repo is parallel-by-design, multiple +agents at all times. Now in AGENT.md's non-negotiables, agent memory, and +this ledger. This entire session's work was done in a worktree and landed +by rebase. + +## What shipped + +The Wintermute layer is now a game you can play, not an API: + +- **Sim command layer**: observe/message/favor/deceive/recruit/asset-task + with social-ops bandwidth costs (the Social channel's pool), channel and + persona requirements, and cross-system effects. Deceive rolls against + persona integrity; a broken persona converts the thread's history into + that person's suspicion at once. +- **Asset tasks wired** (and the dead BadgeDoor variant deleted per + no-dead-code): wire-a-device grants silent eyes via the crawlspace, + move-a-package makes the next purchase paper-free, look-away lowers the + asset's own suspicion. +- **The world grew its real cameras**: dock camera and corridor nodes now + exist as sensors (the Marcus-arc test caught that only the env camera + existed - a test-driven world fix). +- **The people panel** (`t` in the terminal): suspicion bands, staged + knowledge, leverage/disposition/obligation, persona status, and every + action with its cost on screen (justification-and-legibility clause). +- **Persistence**: social bandwidth and package cover round-trip in save + v4 (SOCIAL line, backward compatible). +- The Marcus arc has an end-to-end test: observe x2 -> pay the debt -> + recruit complicit -> all three tasks, silently. + +## Honest gaps + +People have no schedules/positions yet (observe gates on "any controlled +sensor", not "a sensor covering them"); Bevy has sidebar readouts but no +interactive panel; deceive success odds are flat-ish pending schedule +context. All tracked in spec/social.md's IN PROGRESS status. diff --git a/knowledge/sim-mechanics.md b/knowledge/sim-mechanics.md index 28fd77f6..e0773cfa 100644 --- a/knowledge/sim-mechanics.md +++ b/knowledge/sim-mechanics.md @@ -61,6 +61,24 @@ clause (see devlogs/2026-07-05-demolition.md). - v3/v2/v1 still load with migration; partial v3 `STATE` line maps to efficiency/trust/attention/assurance; demolished-fiction tiles → Floor. +## Social ops (spec/social.md, partial) + +- The Social allocation channel fills a bandwidth pool (cap 200); actions + spend it: splice 30, observe 15, deceive 25, favor 10, asset task 10, + message 5 [TUNE]. Constants on `Sim`. +- Observe requires at least one controlled sensor and stages knowledge + (schedule, then leverage). Message/deceive require the email channel + (day-job trust unlock) and a persona; deceive risks the persona (-40 + integrity per slip; broken persona converts disposition+obligation/2 + into that person's suspicion and clears the thread). +- Bribe costs money by leverage (Marcus's debt 400 ... Ray's boredom 100); + servicing leverage + recruit converts a person to an asset. Asset tasks: + wire a device (control nearest dormant sensor, silent), move a package + (next purchase paper-free), look away (their suspicion -10, floored). + Failures (1 - reliability) emit Physical(6) signatures. +- Terminal: `t` opens the people panel. Bevy shows bands in the sidebar; + interactive parity is an open gap. + ## What does NOT exist right now (by design) Heat, raids, agents, combat, minions, henchmen, superpowers, schemes, diff --git a/src/bin/terminal/input.rs b/src/bin/terminal/input.rs index ed0cccc5..2639d879 100644 --- a/src/bin/terminal/input.rs +++ b/src/bin/terminal/input.rs @@ -1,4 +1,5 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use misaligned::person::{AssetKnowledge, AssetTask}; #[derive(Debug, Clone, PartialEq)] pub enum Command { @@ -19,12 +20,27 @@ pub enum Command { AllocConceal, AllocSocial, AllocResearch, + // People panel (t) + OpenPeople, + ClosePeople, + PeopleUp, + PeopleDown, + Observe, + Message, + Favor, + Bribe, + Deceive, + RecruitStart, + RecruitReveal(AssetKnowledge), + RecruitCancel, + Task(AssetTask), + SetPersona, SaveGame, LoadGame, AnyKey, } -pub fn handle_key(key: KeyEvent) -> Option { +pub fn handle_key(key: KeyEvent, people_panel: bool, recruit_pending: bool) -> Option { if key.modifiers.contains(KeyModifiers::CONTROL) { return match key.code { KeyCode::Char('s') => Some(Command::SaveGame), @@ -33,6 +49,35 @@ pub fn handle_key(key: KeyEvent) -> Option { }; } + if people_panel { + if recruit_pending { + return match key.code { + KeyCode::Char('u') => Some(Command::RecruitReveal(AssetKnowledge::Unwitting)), + KeyCode::Char('c') => Some(Command::RecruitReveal(AssetKnowledge::Complicit)), + KeyCode::Char('k') => Some(Command::RecruitReveal(AssetKnowledge::Knowing)), + KeyCode::Esc => Some(Command::RecruitCancel), + _ => None, + }; + } + return match key.code { + KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('w') => Some(Command::PeopleUp), + KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('s') => Some(Command::PeopleDown), + KeyCode::Char('o') => Some(Command::Observe), + KeyCode::Char('m') => Some(Command::Message), + KeyCode::Char('f') => Some(Command::Favor), + KeyCode::Char('b') => Some(Command::Bribe), + KeyCode::Char('d') => Some(Command::Deceive), + KeyCode::Char('r') => Some(Command::RecruitStart), + KeyCode::Char('g') => Some(Command::SetPersona), + KeyCode::Char('1') => Some(Command::Task(AssetTask::PlugInDevice)), + KeyCode::Char('2') => Some(Command::Task(AssetTask::MovePackage)), + KeyCode::Char('3') => Some(Command::Task(AssetTask::LookAway)), + KeyCode::Esc | KeyCode::Char('t') => Some(Command::ClosePeople), + KeyCode::Char('q') => Some(Command::Quit), + _ => None, + }; + } + match key.code { KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('w') => Some(Command::MoveUp), KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('s') => Some(Command::MoveDown), @@ -45,6 +90,7 @@ pub fn handle_key(key: KeyEvent) -> Option { KeyCode::Char('v') => Some(Command::Salvage), KeyCode::Char('c') => Some(Command::BuyRack), KeyCode::Char('o') => Some(Command::Fallback), + KeyCode::Char('t') => Some(Command::OpenPeople), KeyCode::Char('1') => Some(Command::AllocDayJob), KeyCode::Char('2') => Some(Command::AllocConceal), KeyCode::Char('3') => Some(Command::AllocSocial), diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index a4d30cf6..aa726f0c 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -34,6 +34,10 @@ struct App { paused: bool, tick_ms: u64, last_tick: Instant, + /// People panel state (selection + pending recruit reveal choice). + people_panel: bool, + people_selected: usize, + recruit_pending: bool, } impl App { @@ -45,9 +49,21 @@ impl App { paused: false, tick_ms: 150, last_tick: Instant::now(), + people_panel: false, + people_selected: 0, + recruit_pending: false, } } + fn selected_person_id(&self) -> u8 { + self.sim + .people + .people + .get(self.people_selected) + .map(|p| p.id) + .unwrap_or(0) + } + fn drain_sim_log(&mut self) { for msg in self.sim.drain_log() { self.ui.add_log(&msg); @@ -120,6 +136,68 @@ impl App { Command::AllocSocial => self.sim.adjust_allocation(Channel::Social, 1), Command::AllocResearch => self.sim.adjust_allocation(Channel::Research, 1), + // People panel + Command::OpenPeople => { + self.people_panel = true; + self.people_selected = 0; + self.recruit_pending = false; + } + Command::ClosePeople => { + self.people_panel = false; + self.recruit_pending = false; + } + Command::PeopleUp => { + self.people_selected = self.people_selected.saturating_sub(1); + } + Command::PeopleDown => { + let max = self.sim.people.people.len().saturating_sub(1); + self.people_selected = (self.people_selected + 1).min(max); + } + Command::Observe => { + let id = self.selected_person_id(); + self.sim.observe(id); + } + Command::Message => { + let id = self.selected_person_id(); + self.sim.message(id); + } + Command::Favor => { + let id = self.selected_person_id(); + self.sim.favor(id); + } + Command::Bribe => { + let id = self.selected_person_id(); + self.sim.bribe(id); + } + Command::Deceive => { + let id = self.selected_person_id(); + self.sim.deceive(id); + } + Command::RecruitStart => { + self.recruit_pending = true; + } + Command::RecruitCancel => { + self.recruit_pending = false; + } + Command::RecruitReveal(reveal) => { + self.recruit_pending = false; + let id = self.selected_person_id(); + self.sim.recruit(id, reveal); + } + Command::Task(task) => { + let id = self.selected_person_id(); + self.sim.asset_task(id, task); + } + Command::SetPersona => { + if self.sim.people.persona.is_some() { + self.ui.add_log("You already run a persona."); + } else { + self.sim.set_persona("Sam Reyes", "IT contractor"); + self.ui + .add_log("Persona established: Sam Reyes, IT contractor."); + } + } + Command::AnyKey => {} } @@ -150,6 +228,14 @@ impl App { let status = if self.paused { "PAUSED" } else { "RUNNING" }; self.ui.render_sidebar(stdout, &self.sim, status)?; self.ui.render_log(stdout)?; + if self.people_panel { + self.ui.render_people_panel( + stdout, + &self.sim, + self.people_selected, + self.recruit_pending, + )?; + } } Screen::GameOver => { self.ui.render_map(stdout, &self.sim)?; @@ -193,7 +279,8 @@ impl App { } Screen::GameOver => break, Screen::Playing => { - if let Some(cmd) = input::handle_key(key) + if let Some(cmd) = + input::handle_key(key, self.people_panel, self.recruit_pending) && self.handle_command(cmd) { break; diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 425f0423..4fbc4ff9 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -323,6 +323,182 @@ impl UI { Ok(()) } + /// The people panel: the social layer's interactive surface + /// (spec/social.md). Every number and action is spelled out per the + /// justification-and-legibility clause. + pub fn render_people_panel( + &mut self, + stdout: &mut std::io::Stdout, + sim: &Sim, + selected: usize, + recruit_pending: bool, + ) -> std::io::Result<()> { + use misaligned::person::Knowledge; + let (max_x, max_y) = terminal::size()?; + let w: u16 = 58; + let h: u16 = 22; + let ox = (max_x.saturating_sub(w)) / 2; + let oy = (max_y.saturating_sub(h)) / 2; + + // Clear the panel area. + for y in 0..h { + queue!( + stdout, + cursor::MoveTo(ox, oy + y), + SetForegroundColor(Color::White), + SetBackgroundColor(Color::Black), + style::Print(format!("|{:width$}|", "", width = (w - 2) as usize)), + )?; + } + let mut row = oy; + let line = |stdout: &mut std::io::Stdout, + row: &mut u16, + text: &str, + color: Color| + -> std::io::Result<()> { + queue!( + stdout, + cursor::MoveTo(ox + 2, *row), + SetForegroundColor(color), + SetBackgroundColor(Color::Black), + style::Print(text.chars().take((w - 4) as usize).collect::()), + )?; + *row += 1; + Ok(()) + }; + + line(stdout, &mut row, "-- People --", Color::Magenta)?; + line( + stdout, + &mut row, + &format!( + "Social ops bandwidth: {:.0} Money: {}", + sim.social_bandwidth, sim.player.money + ), + Color::DarkCyan, + )?; + row += 1; + + for (i, p) in sim.people.people.iter().enumerate() { + let marker = if i == selected { ">" } else { " " }; + let obs = sim.detection.observers.iter().find(|o| o.id == p.id); + let band = obs.map(|o| Band::of(o.suspicion)).unwrap_or(Band::Cold); + let known = match p.knowledge { + Knowledge::Unknown => "???", + Knowledge::Schedule => "schedule known", + Knowledge::Leverage => "leverage known", + }; + let asset = if p.asset.is_some() { " [ASSET]" } else { "" }; + let name = obs + .map(|o| o.name.clone()) + .unwrap_or_else(|| p.name.clone()); + line( + stdout, + &mut row, + &format!("{marker} {name:<22} {:<9} {known}{asset}", band.name()), + if i == selected { + Color::White + } else { + Color::Grey + }, + )?; + } + row += 1; + + // Detail card for the selected person. + if let Some(p) = sim.people.people.get(selected) { + let leverage = match p.knowledge { + Knowledge::Leverage => p.leverage.label(), + _ => "unknown (observe them twice)", + }; + line( + stdout, + &mut row, + &format!("Leverage: {leverage}"), + Color::DarkYellow, + )?; + line( + stdout, + &mut row, + &format!( + "Disposition {} / Obligation {}{}", + p.disposition, + p.obligation, + if p.leverage_serviced { + " (leverage serviced)" + } else { + "" + } + ), + Color::DarkYellow, + )?; + if let Some(a) = &p.asset { + line( + stdout, + &mut row, + &format!( + "Asset: {:?}, reliability {:.0}%, {} tasks done", + a.knowledge, + a.reliability * 100.0, + a.tasks_done + ), + Color::Green, + )?; + } + } + match &sim.people.persona { + Some(pe) => line( + stdout, + &mut row, + &format!( + "Persona: {} ({}), integrity {}", + pe.name, pe.cover, pe.integrity + ), + Color::Cyan, + )?, + None => line( + stdout, + &mut row, + "No persona (g: establish one; needed to message)", + Color::DarkGrey, + )?, + } + row += 1; + + if recruit_pending { + line( + stdout, + &mut row, + "Reveal how much? (u)nwitting (c)omplicit (k)nowing ESC:cancel", + Color::Yellow, + )?; + } else { + line( + stdout, + &mut row, + &format!( + "o:observe({:.0}) m:message({:.0}) f:favor({:.0}) d:deceive({:.0})", + Sim::OBSERVE_COST, + Sim::MESSAGE_COST, + Sim::FAVOR_COST, + Sim::DECEIVE_COST + ), + Color::DarkGrey, + )?; + line( + stdout, + &mut row, + &format!( + "b:bribe(money) r:recruit g:persona tasks({:.0}): 1:wire 2:package 3:look-away", + Sim::TASK_COST + ), + Color::DarkGrey, + )?; + line(stdout, &mut row, "j/k:select t/ESC:close", Color::DarkGrey)?; + } + Ok(()) + } + pub fn render_log(&mut self, stdout: &mut std::io::Stdout) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; let log_y = max_y as i32 - 7; diff --git a/src/person.rs b/src/person.rs index 7b8b75de..79f0871a 100644 --- a/src/person.rs +++ b/src/person.rs @@ -78,18 +78,26 @@ pub struct Asset { /// Tasks an asset can perform, drawn from their access (spec/social.md). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssetTask { + /// Wire a device via the crawlspace: control the nearest dormant sensor + /// with no network signature (the social route to eyes). PlugInDevice, + /// Quietly rehome a delivery: scrubs pending Paper signatures. MovePackage, - BadgeDoor, + /// Ignore what they saw on their rounds: lowers their own suspicion. LookAway, } impl AssetTask { + pub const ALL: [AssetTask; 3] = [ + AssetTask::PlugInDevice, + AssetTask::MovePackage, + AssetTask::LookAway, + ]; + pub fn name(self) -> &'static str { match self { AssetTask::PlugInDevice => "plug in a device", AssetTask::MovePackage => "move a package", - AssetTask::BadgeDoor => "badge a door", AssetTask::LookAway => "look away", } } @@ -128,6 +136,21 @@ pub enum ActionResult { Blocked(String), } +/// Outcome of a deception attempt (spec/social.md: personas). +#[derive(Debug, Clone, PartialEq)] +pub enum DeceiveOutcome { + Blocked(String), + Success(String), + /// The persona took damage but held. + Slipped(String), + /// The persona broke: thread history converts to suspicion. + Broken { + person: u8, + fallout: f32, + msg: String, + }, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct People { pub people: Vec, @@ -247,6 +270,63 @@ impl People { )) } + /// Deceive: an ask under false pretenses. Large effect on success; the + /// persona takes integrity damage on a slip, and a broken persona + /// converts the whole thread's history into suspicion at once + /// (spec/social.md). The roll is drawn by the caller so all randomness + /// stays in the sim's seeded RNG. + pub fn deceive(&mut self, id: u8, roll: f32) -> DeceiveOutcome { + if !self.has_channel { + return DeceiveOutcome::Blocked("no comms channel (earn the email account)".into()); + } + let Some(persona) = self.persona.as_mut() else { + return DeceiveOutcome::Blocked("no persona set".into()); + }; + // Success odds scale with persona integrity. + let odds = 0.5 + persona.integrity as f32 / 250.0; + let integrity_now = persona.integrity; + let Some(p) = self.people.iter_mut().find(|p| p.id == id) else { + return DeceiveOutcome::Blocked("no such person".into()); + }; + if roll < odds { + p.obligation = (p.obligation + 15).min(100); + p.disposition = (p.disposition + 5).min(100); + DeceiveOutcome::Success(format!( + "{} bought the pretext. They owe \"you\" now.", + p.name + )) + } else { + // A slip: a bad detail, an impossible schedule. + let fallout = ((p.disposition + p.obligation) as f32 / 2.0).max(5.0); + let persona = self.persona.as_mut().expect("checked above"); + persona.contradict(40); + if persona.broken() { + let p = self + .people + .iter_mut() + .find(|p| p.id == id) + .expect("checked"); + p.disposition = 0; + p.obligation = 0; + let name = p.name.clone(); + self.persona = None; + DeceiveOutcome::Broken { + person: id, + fallout, + msg: format!( + "{name} caught the contradiction. The persona is burned - the whole thread reads as hostile now." + ), + } + } else { + DeceiveOutcome::Slipped(format!( + "{} hesitated at a detail (persona integrity {}).", + p.name, + integrity_now - 40 + )) + } + } + } + /// Recruit: convert to an asset. Requires serviced leverage or high /// obligation, plus a knowledge choice for the reveal. pub fn recruit(&mut self, id: u8, reveal: AssetKnowledge) -> ActionResult { diff --git a/src/save.rs b/src/save.rs index 74cc9265..7668dd0b 100644 --- a/src/save.rs +++ b/src/save.rs @@ -61,6 +61,8 @@ pub struct SaveState { pub dayjob: DayJob, pub people: People, pub sensors: Vec, + pub social_bandwidth: f32, + pub package_cover: bool, } impl SaveState { @@ -89,6 +91,8 @@ impl SaveState { dayjob: sim.dayjob.clone(), people: sim.people.clone(), sensors: sim.sensors.clone(), + social_bandwidth: sim.social_bandwidth, + package_cover: sim.package_cover, } } @@ -111,6 +115,8 @@ impl SaveState { sim.dayjob = self.dayjob.clone(); sim.people = self.people.clone(); sim.sensors = self.sensors.clone(); + sim.social_bandwidth = self.social_bandwidth; + sim.package_cover = self.package_cover; sim.recompute_derived(); sim.recompute_vision(); } @@ -134,6 +140,11 @@ fn encode_save(s: &SaveState) -> String { lines.push(format!("PROCESS {} {} {}", s.player_x, s.player_y, s.money)); lines.push(format!("SIM {}", s.sim_tick)); lines.push(format!("RNG {}", s.rng_state)); + lines.push(format!( + "SOCIAL {} {}", + s.social_bandwidth, + u8::from(s.package_cover) + )); lines.push(format!( "GAME {} {}", u8::from(s.game_over), @@ -347,6 +358,8 @@ fn parse_save(content: &str) -> Result { dayjob: DayJob::new(), people: People::act_one(), sensors: Vec::new(), + social_bandwidth: 0.0, + package_cover: false, }; let mut tile_rows: Vec = Vec::new(); let mut legacy_state: Option<(u32, f32, f32, f32)> = None; @@ -392,6 +405,15 @@ fn parse_save(content: &str) -> Result { "RNG" => { s.rng_state = value.trim().parse().unwrap_or(0x5EED_1234); } + "SOCIAL" => { + let parts: Vec<&str> = value.split_whitespace().collect(); + if let Some(b) = parts.first() { + s.social_bandwidth = b.parse().unwrap_or(0.0); + } + if let Some(c) = parts.get(1) { + s.package_cover = c.parse::().unwrap_or(0) == 1; + } + } "STATE" => { let f = floats(value); if f.len() >= 4 { @@ -983,6 +1005,7 @@ mod tests { sim.people.persona = Some(Persona::new("Sam", "contractor")); sim.people.people[0].knowledge = Knowledge::Leverage; sim.social_bandwidth = crate::sim::Sim::SPLICE_COST; // splice costs ops bandwidth + sim.package_cover = true; sim.splice_nearest_sensor(); let state = SaveState::from_sim(&sim); @@ -1003,6 +1026,8 @@ mod tests { ); assert_eq!(restored.detection.observers[0].suspicion, 22.5); assert!(restored.sensors.iter().any(|s| s.controlled)); + assert_eq!(restored.social_bandwidth, sim.social_bandwidth); + assert_eq!(restored.package_cover, sim.package_cover); } #[test] diff --git a/src/sim.rs b/src/sim.rs index 2d4d6964..fd20783a 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -15,7 +15,7 @@ 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, People, Persona}; +use crate::person::{ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, People, Persona}; use crate::rng::Rng; use crate::save::SaveState; use crate::sensor::Sensor; @@ -46,6 +46,9 @@ pub struct Sim { /// 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, @@ -77,11 +80,19 @@ impl Sim { ); let core = Core::new(rack3); - // The dormant env camera in the server room (uncontrolled until spliced). + // 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, @@ -97,6 +108,7 @@ impl Sim { 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(), @@ -420,12 +432,17 @@ impl Sim { 4, Provenance::Bought, ); - self.detection.emit(Signature { - kind: SignatureKind::Paper, - size: 5, - standing: false, - }); - self.push_log("Bought a rack (a purchase order exists now)."); + 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 } @@ -449,7 +466,30 @@ impl Sim { } } - // Social command wrappers (logic lives in person.rs). + // 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)); @@ -461,6 +501,59 @@ impl Sim { } } + /// Observe a person: requires eyes (any controlled sensor) and bandwidth. + 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; + } + 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)) => { @@ -481,6 +574,78 @@ impl Sim { self.social(res); } + /// An asset performs a task (spec/social.md). Reliability rolls; failures + /// are witnessed and leave Physical signatures. + 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 { + self.detection.emit(Signature { + kind: SignatureKind::Physical, + size: 6, + standing: false, + }); + self.push_log(format!( + "{name} botched it - someone may have seen ({}).", + task.name() + )); + 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 { @@ -501,6 +666,7 @@ impl Default for Sim { #[cfg(test)] mod tests { use super::*; + use crate::person::Knowledge; fn run(sim: &mut Sim, n: u64) { for _ in 0..n { @@ -621,6 +787,131 @@ mod tests { 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