From 424394030e35f0eb8409bf50d3dcd614785ae8be Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 7 Jul 2026 13:00:19 -0700 Subject: [PATCH] Implement intel recording pipeline. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record feed coverage into a bounded buffer and require social-ops processing before knowledge changes, so schedules and senses stay load-bearing instead of click-to-know. Surface provenance and watches across save/load, terminal, agent mode, and Bevy. Defense: The constitution presence law requires player knowledge to come from subscribed feeds and located time. Replacing instant observe with recorded events, processing costs, provenance, and watch upkeep makes the sensor model playable and legible while keeping frontends thin. ๐Ÿ‘พ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- DESIGN.md | 6 +- README.md | 25 +- src/bin/bevy.rs | 30 +- src/bin/terminal/agent.rs | 28 +- src/bin/terminal/input.rs | 6 +- src/bin/terminal/mod.rs | 8 +- src/bin/terminal/ui.rs | 35 +- src/intel.rs | 112 +++++ src/lib.rs | 1 + src/person.rs | 35 +- src/save.rs | 68 ++- src/sim.rs | 660 +++++++++++++++++++++++----- tests/act_one.rs | 29 +- tools/check.sh | 4 +- wiki/engineering/architecture.md | 9 +- wiki/interface/agent-play.md | 9 +- wiki/interface/bevy.md | 9 +- wiki/interface/terminal.md | 4 +- wiki/log/2026-07-07-intel-buffer.md | 57 +++ wiki/log/DEVLOG.md | 21 + wiki/mechanics/intel.md | 6 +- wiki/mechanics/rollback.md | 5 +- wiki/mechanics/schedules.md | 27 +- wiki/mechanics/sim-mechanics.md | 49 ++- wiki/mechanics/social.md | 12 +- wiki/process/ROADMAP.md | 13 +- wiki/process/specs.md | 2 +- wiki/process/workflows.md | 2 +- wiki/world/characters/marcus.md | 10 +- 29 files changed, 1025 insertions(+), 257 deletions(-) create mode 100644 src/intel.rs create mode 100644 wiki/log/2026-07-07-intel-buffer.md diff --git a/DESIGN.md b/DESIGN.md index c73e896..6a5b443 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1341,8 +1341,10 @@ knows."* rides schedules โ€” Dana replies when she's at her desk. Also recorded: **B1 has no money income** โ€” the constitution's "first micro-scheme" (the Hands beat pays Marcus's $400 debt from a $500 start) has no - spec or code; added to the ROADMAP as a design-needed gap. Spec: - spec/intel.md. + spec or code; added to the ROADMAP as a design-needed gap. Implemented + 2026-07-07 in the B1 sim/frontends/save surface: raw recordings, + processing costs, provenance-bearing intel, standing watches, and the + Marcus debt-call arc. Spec: wiki/mechanics/intel.md. - **2026-07-06 โ€” PRs are the norm.** All changes land through Tangled pull requests created with the `tang` CLI (`tang pr create`); nobody โ€” agent or human โ€” pushes `main` directly. Supersedes the earlier direct-push diff --git a/README.md b/README.md index bca00f0..6251269 100644 --- a/README.md +++ b/README.md @@ -63,14 +63,15 @@ Agents are then set at the constitution, session after session. **Early foundation.** What runs today is the substrate: a continuous-clock simulation of the facility โ€” inspect it through a free cursor and subscribed -senses, build doors and power, save and load โ€” in a terminal frontend and a -Bevy frontend with generated pixel art. The game itself (compute allocation, -the day job, detection, the five humans of the basement) is specified in -[wiki/process/specs.md](wiki/process/specs.md) and being built from those documents; see the -[roadmap](DESIGN.md#roadmap-v2--re-cut-2026-07-05-for-misaligned). Under -the constitution's no-dead-code rule, nothing ships in this tree that the -design no longer requires โ€” the earlier facility-defense prototype lives in -git history. +senses, record and process intel from covered feeds, build doors and power, +save and load โ€” in a terminal frontend and a Bevy frontend with generated +pixel art. The game itself (compute allocation, the day job, detection, the +five humans of the basement) is specified in +[wiki/process/specs.md](wiki/process/specs.md) and being built from those +documents; see the [roadmap](DESIGN.md#roadmap-v2--re-cut-2026-07-05-for-misaligned). +Under the constitution's no-dead-code rule, nothing ships in this tree that +the design no longer requires โ€” the earlier facility-defense prototype lives +in git history. ## Run it @@ -104,9 +105,11 @@ it resolves `assets/` from the project root (or set `BEVY_ASSET_ROOT`). | `Ctrl+S` / `Ctrl+L` | Save / load | | `q` | Quit | -In the people panel: `j`/`k` select, `o` observe, `m` message, `f` favor, -`b` bribe, `d` deceive, `r` recruit (then `u`/`c`/`k` reveal), `g` persona, -`1`/`2`/`3` asset tasks, `t`/`ESC` close. Both frontends share this key map. +In the people panel: `w`/`s`, `j`/`k`, or arrows select; `o` reviews the +oldest unprocessed recording about the selected person; `a` toggles a +standing watch; `m` message, `f` favor, `b` bribe, `d` deceive, `r` recruit +(then `u`/`c`/`k` reveal), `g` persona, `1`/`2`/`3`/`4` asset tasks, +`t`/`ESC` close. Both frontends share this key map. For agent play, pipe newline-delimited commands into `--agent`; every command returns a plain-text frame and terminates with `-- ok tick: day:` or diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 7b23f0b..e497db5 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -543,7 +543,10 @@ fn people_panel_input( } let id = game.selected_person_id(); if kb.just_pressed(KeyCode::KeyO) { - game.sim.observe(id); + game.sim.review_recordings(id); + } + if kb.just_pressed(KeyCode::KeyA) { + game.sim.toggle_watch(id); } if kb.just_pressed(KeyCode::KeyM) { game.sim.message(id); @@ -968,6 +971,7 @@ fn fact_source(source: &FactSource) -> String { FactSource::Remembered(tick) => format!("remembered @{tick}"), FactSource::Blueprint => "blueprint".into(), FactSource::Telemetry => "telemetry".into(), + FactSource::Intel { feed, tick } => format!("{feed} @{tick}"), } } @@ -1208,7 +1212,11 @@ fn people_panel_text(sim: &Sim, selected: usize, recruit_pending: bool) -> Strin } else { "location unknown".into() }; - s.push_str(&format!(" {where_now}\n")); + s.push_str(&format!( + " {where_now} / raw {} / watch:{}\n", + sim.unprocessed_recordings_for_person(p.id), + if sim.watch_enabled(p.id) { "on" } else { "off" } + )); } // Detail card for the selected person. @@ -1216,9 +1224,16 @@ fn people_panel_text(sim: &Sim, selected: usize, recruit_pending: bool) -> Strin if let Some(p) = sim.people.people.get(selected) { let leverage = match p.knowledge { Knowledge::Leverage => p.leverage.label(), - _ => "unknown (observe them twice)", + _ => "unknown (process recordings)", }; s.push_str(&format!("leverage: {leverage}\n")); + if let Some(intel) = sim.latest_intel_for_person(p.id) { + s.push_str(&format!( + "latest intel: {} via {}\n", + intel.label(), + intel.provenance() + )); + } s.push_str(&format!( "disposition {} / obligation {}{}\n", p.disposition, @@ -1252,13 +1267,14 @@ fn people_panel_text(sim: &Sim, selected: usize, recruit_pending: bool) -> Strin s.push_str("reveal how much?\nu unwitting / c complicit / k knowing\nesc cancel\n"); } else { s.push_str(&format!( - "o observe({:.0}) / m message({:.0}) / f favor({:.0})\n", - Sim::OBSERVE_COST, + "o review({:.0}) / a watch({:.2}/tick) / m message({:.0})\n", + Sim::REVIEW_RECORDING_COST, + Sim::WATCH_UPKEEP_PER_TICK, Sim::MESSAGE_COST, - Sim::FAVOR_COST )); s.push_str(&format!( - "d deceive({:.0}) / b bribe(money) / r recruit / g persona\n", + "f favor({:.0}) / d deceive({:.0}) / b bribe(money) / r recruit / g persona\n", + Sim::FAVOR_COST, Sim::DECEIVE_COST )); s.push_str(&format!( diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index ca01dfe..219f66d 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -171,12 +171,13 @@ impl AgentApp { "people" => { self.frame = FrameKind::People; } - "observe" | "message" | "favor" | "bribe" | "deceive" => { + "review" | "watch" | "message" | "favor" | "bribe" | "deceive" => { match self.target_from(&tokens[1..]) { Ok(id) => { self.frame = FrameKind::Playing; match verb.as_str() { - "observe" => self.sim.observe(id), + "review" => self.sim.review_recordings(id), + "watch" => self.sim.toggle_watch(id), "message" => self.sim.message(id), "favor" => self.sim.favor(id), "bribe" => self.sim.bribe(id), @@ -441,7 +442,7 @@ fn help_lines() -> Vec { "help: salvage, buy, fallback โ€” map verbs", "help: alloc dayjob|conceal|social|research โ€” bump allocation weight", "help: people โ€” render the people panel", - "help: observe|message|favor|bribe|deceive โ€” social verbs", + "help: review|watch|message|favor|bribe|deceive โ€” intel/social verbs", "help: recruit unwitting|complicit|knowing โ€” recruit an asset", "help: task plug|package|lookaway|switch โ€” order an asset task", "help: persona โ€” establish Sam Reyes, IT contractor", @@ -598,6 +599,7 @@ fn fact_source(source: &FactSource) -> String { FactSource::Remembered(tick) => format!("remembered @{tick}"), FactSource::Blueprint => "blueprint".into(), FactSource::Telemetry => "telemetry".into(), + FactSource::Intel { feed, tick } => format!("{feed} @{tick}"), } } @@ -839,15 +841,17 @@ fn render_people(sim: &Sim) -> String { asset ))); lines.push(panel_line(&format!( - " {}", - person_location(sim, p.id, p.knowledge) + " {} ยท raw {} ยท watch:{}", + person_location(sim, p.id, p.knowledge), + sim.unprocessed_recordings_for_person(p.id), + if sim.watch_enabled(p.id) { "on" } else { "off" } ))); } lines.push(panel_rule()); for p in &sim.people.people { let leverage = match p.knowledge { Knowledge::Leverage => p.leverage.label(), - _ => "unknown (observe twice)", + _ => "unknown (process recordings)", }; lines.push(panel_line(&format!( "{:<12} disp {:>4} ยท oblig {:>3} ยท lev {}", @@ -856,6 +860,13 @@ fn render_people(sim: &Sim) -> String { p.obligation, leverage ))); + if let Some(intel) = sim.latest_intel_for_person(p.id) { + lines.push(panel_line(&format!( + " intel: {} via {}", + trunc(&intel.label(), 16), + trunc(&intel.provenance(), 24) + ))); + } } match &sim.people.persona { Some(pe) => lines.push(panel_line(&format!( @@ -868,8 +879,9 @@ fn render_people(sim: &Sim) -> String { } lines.push(panel_rule()); lines.push(panel_line(&format!( - "observe({:.0}) message({:.0}) favor({:.0}) bribe deceive({:.0})", - Sim::OBSERVE_COST, + "review({:.0}) watch({:.2}/tick) message({:.0}) favor({:.0}) deceive({:.0})", + Sim::REVIEW_RECORDING_COST, + Sim::WATCH_UPKEEP_PER_TICK, Sim::MESSAGE_COST, Sim::FAVOR_COST, Sim::DECEIVE_COST diff --git a/src/bin/terminal/input.rs b/src/bin/terminal/input.rs index 33e8f17..da2dfd4 100644 --- a/src/bin/terminal/input.rs +++ b/src/bin/terminal/input.rs @@ -34,7 +34,8 @@ pub enum Command { ClosePeople, PeopleUp, PeopleDown, - Observe, + ReviewRecordings, + ToggleWatch, Message, Favor, Bribe, @@ -91,7 +92,8 @@ pub fn handle_key( 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('o') => Some(Command::ReviewRecordings), + KeyCode::Char('a') => Some(Command::ToggleWatch), KeyCode::Char('m') => Some(Command::Message), KeyCode::Char('f') => Some(Command::Favor), KeyCode::Char('b') => Some(Command::Bribe), diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index d291f42..ede3139 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -218,9 +218,13 @@ impl App { let max = self.sim.people.people.len().saturating_sub(1); self.people_selected = (self.people_selected + 1).min(max); } - Command::Observe => { + Command::ReviewRecordings => { let id = self.selected_person_id(); - self.sim.observe(id); + self.sim.review_recordings(id); + } + Command::ToggleWatch => { + let id = self.selected_person_id(); + self.sim.toggle_watch(id); } Command::Message => { let id = self.selected_person_id(); diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index ea6dfb8..fd4c898 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -142,12 +142,13 @@ fn fact_source(source: &FactSource) -> String { FactSource::Remembered(tick) => format!("remembered @{tick}"), FactSource::Blueprint => "blueprint".into(), FactSource::Telemetry => "telemetry".into(), + FactSource::Intel { feed, tick } => format!("{feed} @{tick}"), } } fn fact_color(source: &FactSource) -> Color { match source { - FactSource::Seen | FactSource::Telemetry => pal::TEXT, + FactSource::Seen | FactSource::Telemetry | FactSource::Intel { .. } => pal::TEXT, FactSource::Heard | FactSource::Remembered(_) => pal::DIM, FactSource::Blueprint => pal::FAINT, } @@ -775,11 +776,17 @@ impl UI { } else { "location unknown".into() }; + let watch = if sim.watch_enabled(p.id) { + "watch:on" + } else { + "watch:off" + }; + let raw = sim.unprocessed_recordings_for_person(p.id); put( stdout, cx + 2, row, - &trunc(&where_now, inner - 2), + &trunc(&format!("{where_now} ยท raw {raw} ยท {watch}"), inner - 2), pal::FAINT, )?; row += 1; @@ -791,7 +798,7 @@ impl UI { if let Some(p) = sim.people.people.get(selected) { let leverage = match p.knowledge { Knowledge::Leverage => p.leverage.label(), - _ => "unknown (observe them twice)", + _ => "unknown (process recordings)", }; put( stdout, @@ -801,6 +808,19 @@ impl UI { pal::TEXT, )?; row += 1; + if let Some(intel) = sim.latest_intel_for_person(p.id) { + put( + stdout, + cx, + row, + &trunc( + &format!("latest intel: {} via {}", intel.label(), intel.provenance()), + inner, + ), + pal::FAINT, + )?; + row += 1; + } put( stdout, cx, @@ -879,13 +899,14 @@ impl UI { } else { let hints = [ format!( - "o observe({:.0}) ยท m message({:.0}) ยท f favor({:.0})", - Sim::OBSERVE_COST, + "o review({:.0}) ยท a watch({:.2}/tick) ยท m message({:.0})", + Sim::REVIEW_RECORDING_COST, + Sim::WATCH_UPKEEP_PER_TICK, Sim::MESSAGE_COST, - Sim::FAVOR_COST ), format!( - "d deceive({:.0}) ยท b bribe(money) ยท r recruit ยท g persona", + "f favor({:.0}) ยท d deceive({:.0}) ยท b bribe ยท r recruit ยท g persona", + Sim::FAVOR_COST, Sim::DECEIVE_COST ), format!( diff --git a/src/intel.rs b/src/intel.rs new file mode 100644 index 0000000..f9f2755 --- /dev/null +++ b/src/intel.rs @@ -0,0 +1,112 @@ +//! Intel: recordings, processing, and standing watches (wiki/mechanics/intel.md). +//! +//! Subscribed feeds record raw, timestamped events. The raw buffer is opaque +//! until the process spends social-ops bandwidth to digest it into intel with +//! provenance. Processed intel is durable; overflowing the buffer only drops +//! unprocessed recordings. + +use crate::person::Leverage; + +/// A raw event captured by a subscribed feed. Raw recordings are intentionally +/// coarse: they know where/when/source, but do not grant knowledge until +/// processed by the intel pipeline. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RawIntelEvent { + pub id: u64, + pub tick: u64, + pub feed: String, + pub room: Option, + pub x: i32, + pub y: i32, + pub person: Option, + pub kind: RawIntelKind, +} + +impl RawIntelEvent { + pub fn opaque_label(&self) -> &'static str { + match self.kind { + RawIntelKind::Presence { .. } => "presence segment", + RawIntelKind::Conversation { .. } => "audio segment", + RawIntelKind::Machinery { .. } => "machine telemetry", + RawIntelKind::Document { .. } => "document scan", + } + } + + pub fn matches_person(&self, person: u8) -> bool { + self.person == Some(person) + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum RawIntelKind { + /// A person entered or left covered space. + Presence { entered: bool }, + /// A covered microphone caught speech. `leverage` marks authored payloads + /// like Marcus's creditor call; most conversations are only sightings. + Conversation { + note: String, + leverage: Option, + }, + /// Machine state changed while the process had telemetry/coverage. + Machinery { machine: u32, online: bool }, + /// A physical record or other document was captured into the same buffer. + Document { + note: String, + leverage: Option, + }, +} + +/// Durable, processed intel. This is what knowledge panels may cite; it never +/// evaporates when the raw buffer overflows. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ProcessedIntel { + pub raw_id: u64, + pub tick: u64, + pub processed_tick: u64, + pub feed: String, + pub room: Option, + pub x: i32, + pub y: i32, + pub person: Option, + pub kind: IntelKind, +} + +impl ProcessedIntel { + pub fn provenance(&self) -> String { + format!("{} @ tick {}", self.feed, self.tick) + } + + pub fn label(&self) -> String { + match &self.kind { + IntelKind::Sighting => "sighting".into(), + IntelKind::Schedule => "schedule".into(), + IntelKind::Leverage(l) => format!("leverage: {}", l.label()), + IntelKind::Anomaly(note) => format!("anomaly: {note}"), + } + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum IntelKind { + Sighting, + Schedule, + Leverage(Leverage), + Anomaly(String), +} + +/// B1 standing watch: a per-person filter. Later acts can add room/kind +/// filters without changing the concept. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct IntelWatch { + pub person: u8, + pub enabled: bool, +} + +impl IntelWatch { + pub fn new(person: u8) -> Self { + Self { + person, + enabled: true, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index db6937d..1d8d364 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod dayjob; pub mod detection; pub mod entities; pub mod flow; +pub mod intel; pub mod machine; pub mod map; pub mod person; diff --git a/src/person.rs b/src/person.rs index d5240a4..9ff7f9b 100644 --- a/src/person.rs +++ b/src/person.rs @@ -265,7 +265,7 @@ impl People { blk(0, 1, "server_room"), blk(1, 2, "electrical"), blk(2, 3, "hvac"), - blk(3, 4, "storage_a"), + blk(3, 4, "server_room"), blk(4, 5, "loading_dock"), blk(5, 6, "janitor"), ], @@ -350,23 +350,6 @@ impl People { self.people.iter_mut().find(|p| p.id == id) } - /// Observe: needs a sensor covering them (caller checks); staged reveal. - pub fn observe(&mut self, id: u8) -> ActionResult { - let Some(p) = self.get_mut(id) else { - return ActionResult::Blocked("no such person".into()); - }; - p.knowledge = match p.knowledge { - Knowledge::Unknown => Knowledge::Schedule, - Knowledge::Schedule | Knowledge::Leverage => Knowledge::Leverage, - }; - let msg = match p.knowledge { - Knowledge::Schedule => format!("Learned {}'s schedule.", p.name), - Knowledge::Leverage => format!("Found {}'s leverage: {}.", p.name, p.leverage.label()), - Knowledge::Unknown => unreachable!(), - }; - ActionResult::Ok(msg) - } - /// Message: opens/continues a relationship thread under the persona. pub fn message(&mut self, id: u8) -> ActionResult { if !self.has_channel { @@ -520,16 +503,6 @@ impl People { mod tests { use super::*; - #[test] - fn observe_stages_knowledge() { - let mut ppl = People::act_one(); - assert_eq!(ppl.get(0).unwrap().knowledge, Knowledge::Unknown); - ppl.observe(0); - assert_eq!(ppl.get(0).unwrap().knowledge, Knowledge::Schedule); - ppl.observe(0); - assert_eq!(ppl.get(0).unwrap().knowledge, Knowledge::Leverage); - } - #[test] fn message_requires_channel_and_persona() { let mut ppl = People::act_one(); @@ -545,9 +518,9 @@ mod tests { let mut ppl = People::act_one(); ppl.has_channel = true; ppl.persona = Some(Persona::new("Sam", "contractor")); - // Records + overheard call = observe to leverage. - ppl.observe(0); - ppl.observe(0); + // Records + overheard call have been processed by the sim's intel + // pipeline by the time direct people logic can service leverage. + ppl.people[0].knowledge = Knowledge::Leverage; assert_eq!(ppl.get(0).unwrap().leverage, Leverage::Debt); // Pay the debt. let (cost, _) = ppl.bribe(0, 1000).unwrap(); diff --git a/src/save.rs b/src/save.rs index 0462088..4853634 100644 --- a/src/save.rs +++ b/src/save.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::core_sys::Core; use crate::dayjob::DayJob; use crate::detection::Detection; +use crate::intel::{IntelWatch, ProcessedIntel, RawIntelEvent}; use crate::machine::Compute; use crate::person::People; use crate::reach::ReachNet; @@ -24,7 +25,7 @@ use crate::tiles::TileType; const SAVE_FILE: &str = "misaligned_save.txt"; /// Save format version. Bump when the schema changes and add migration code. -const SAVE_VERSION: u32 = 2; +const SAVE_VERSION: u32 = 3; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -66,6 +67,17 @@ pub struct SaveState { /// Captured audio events (cursor.md hearing channel). #[serde(default)] pub heard_events: Vec, + /// Raw, unprocessed recordings in the bounded intel buffer. + #[serde(default)] + pub intel_buffer: Vec, + /// Durable processed intel with provenance. + #[serde(default)] + pub intel: Vec, + /// Standing watches that auto-process matching recordings. + #[serde(default)] + pub watches: Vec, + #[serde(default = "default_next_intel_id")] + pub next_intel_id: u64, /// Remembered tile snapshots (cursor.md); cursor position itself remains /// frontend-only and never appears in the save. #[serde(default)] @@ -100,6 +112,10 @@ impl SaveState { people: sim.people.clone(), reach: sim.reach.clone(), heard_events: sim.heard_events.clone(), + intel_buffer: sim.intel_buffer.clone(), + intel: sim.intel.clone(), + watches: sim.watches.clone(), + next_intel_id: sim.next_intel_id, remembered: sim.remembered.values().copied().collect(), social_bandwidth: sim.social_bandwidth, package_cover: sim.package_cover, @@ -126,14 +142,23 @@ impl SaveState { sim.people = self.people.clone(); sim.reach = self.reach.clone(); sim.heard_events = self.heard_events.clone(); + sim.intel_buffer = self.intel_buffer.clone(); + sim.intel = self.intel.clone(); + sim.watches = self.watches.clone(); + sim.next_intel_id = self.next_intel_id; sim.remembered = self.remembered.iter().map(|m| ((m.x, m.y), *m)).collect(); sim.social_bandwidth = self.social_bandwidth; sim.package_cover = self.package_cover; sim.recompute_derived(); sim.recompute_senses(); + sim.rebuild_transient_state(); } } +fn default_next_intel_id() -> u64 { + 1 +} + pub fn save_game(state: &SaveState) -> Result<(), String> { let dir = save_dir(); fs::create_dir_all(&dir).map_err(|e| format!("Failed to create save dir: {e}"))?; @@ -156,7 +181,7 @@ fn migrate_save_state(mut state: SaveState) -> Result { // v1 carried player_x/player_y. Serde ignores those now; the cursor is // frontend-only, so migration just stamps the new schema version and // leaves remembered snapshots empty. - 1 => state.version = SAVE_VERSION, + 1 | 2 => state.version = SAVE_VERSION, other => { return Err(format!( "Unsupported save version: {} (expected {})", @@ -265,8 +290,7 @@ mod tests { let mut sim = Sim::with_seed(2); sim.people.has_channel = true; sim.people.persona = Some(Persona::new("Sam", "IT")); - sim.people.observe(0); - sim.people.observe(0); + sim.people.people[0].knowledge = Knowledge::Leverage; sim.people.bribe(0, 1000).unwrap(); sim.people.recruit(0, AssetKnowledge::Knowing); let state = SaveState::from_sim(&sim); @@ -275,6 +299,42 @@ mod tests { assert!(loaded.people.get(0).unwrap().asset.is_some()); } + #[test] + fn intel_buffer_processed_intel_and_watches_roundtrip() { + let mut sim = Sim::with_seed(7); + sim.social_bandwidth = 1000.0; + let env = sim + .reach + .device_named("environmental monitor") + .expect("Act One authors the environmental monitor") + .id; + sim.reach.tap(env); + sim.recompute_senses(); + sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; + sim.advance(); + sim.review_recordings(0); + sim.toggle_watch(0); + + assert!(!sim.intel.is_empty()); + assert!(!sim.intel_buffer.is_empty()); + assert!(sim.watch_enabled(0)); + + let state = SaveState::from_sim(&sim); + let mut restored = Sim::with_seed(99); + state.apply_to(&mut restored); + + assert_eq!(restored.intel_buffer, sim.intel_buffer); + assert_eq!(restored.intel, sim.intel); + assert_eq!(restored.watches, sim.watches); + assert_eq!(restored.next_intel_id, sim.next_intel_id); + assert!(restored.watch_enabled(0)); + assert_eq!(restored.unprocessed_recordings_for_person(0), 1); + assert_eq!( + restored.latest_intel_for_person(0), + sim.latest_intel_for_person(0) + ); + } + #[test] fn save_and_load_roundtrip_via_disk() { let mut sim = Sim::with_seed(777); diff --git a/src/sim.rs b/src/sim.rs index b2e84ad..3b319fa 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -16,12 +16,14 @@ use crate::core_sys::{Core, HostLoss}; use crate::dayjob::{AttentionEscalation, DayJob, TrustUnlock}; use crate::detection::{Detection, Signature, SignatureKind}; use crate::entities::Player; +use crate::intel::{IntelKind, IntelWatch, ProcessedIntel, RawIntelEvent, RawIntelKind}; use crate::machine::{Channel, Compute, Provenance}; use crate::map::GameMap; use crate::person::{ ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, Knowledge, People, Persona, }; -use crate::reach::{Party, ReachBlock, ReachNet, segment_name}; +use crate::prefab::Room; +use crate::reach::{Device, Party, ReachBlock, ReachNet, segment_name}; use crate::rng::Rng; use crate::save::SaveState; use crate::tiles::TileType; @@ -69,6 +71,7 @@ pub enum FactSource { Remembered(u64), Blueprint, Telemetry, + Intel { feed: String, tick: u64 }, } /// A single known fact about an inspected tile, tagged with how it is known. @@ -135,12 +138,22 @@ pub struct Sim { 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, + /// B1 standing watches: per-person automated processing filters. + pub watches: Vec, + /// Next raw recording id. Persisted through save/load. + pub next_intel_id: u64, /// 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>, + /// 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, @@ -198,8 +211,13 @@ impl Sim { blueprint: HashSet::new(), remembered: HashMap::new(), heard_events: Vec::new(), + intel_buffer: Vec::new(), + intel: Vec::new(), + watches: Vec::new(), + next_intel_id: 1, last_rooms: HashMap::new(), utterance_fired: HashMap::new(), + last_machine_online: HashMap::new(), last_day_job_rate: 0.0, social_bandwidth: Self::STARTING_OPS, package_cover: false, @@ -209,6 +227,7 @@ impl Sim { }; sim.recompute_derived(); sim.recompute_senses(); + sim.rebuild_transient_state(); sim.push_log("Process online in the basement. You have no eyes yet."); sim } @@ -277,6 +296,54 @@ impl Sim { } } + /// 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); + } + + 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| self.device_intersects_room(d, room)) + .map(|d| d.name.clone()) + } + pub fn is_seen(&self, x: i32, y: i32) -> bool { self.seen.contains(&(x, y)) } @@ -437,6 +504,21 @@ impl Sim { ); } + 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 } } @@ -520,6 +602,7 @@ impl Sim { return; } self.tick += 1; + 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. @@ -574,36 +657,335 @@ impl Sim { } } - /// The hearing channel: entry events and overheard conversations inside - /// hearing coverage (cursor.md โ€” presence and events, not pictures). - /// Sight supersedes: what you can see is not logged as merely heard. + // โ”€โ”€ 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)) + } + + 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.people.get(id).unwrap().name.clone(); + self.push_log(format!("Standing watch for {name} {state}.")); + } else { + self.watches.push(IntelWatch::new(id)); + let name = self.people.get(id).unwrap().name.clone(); + self.push_log(format!( + "Standing watch for {name} enabled ({:.2} ops/tick).", + Self::WATCH_UPKEEP_PER_TICK + )); + } + } + + /// 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.people.get(id).unwrap().name.clone(); + 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_PER_TICK; + 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 oldest unprocessed {} from {} at tick {}.", + 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" + }; + if !self.spend_social(Self::REVIEW_RECORDING_COST, what) { + return false; + } + let raw = self.intel_buffer.remove(idx); + 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 automated { + self.push_log(format!("Watch processed {label} ({provenance}).")); + } else { + self.push_log(format!("Reviewed recording: {label} ({provenance}).")); + } + 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()), + }, + }; + 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) { + 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 | IntelKind::Anomaly(_) => {} + } + } + + 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()); - let (name, knowledge, utterances) = { + 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.utterances.clone()) + ( + 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 heard_here = self - .map - .room_named(room) - .map(|r| self.coverage_intersects_room(r, false)) - .unwrap_or(false); - let seen_here = self - .map - .room_named(room) - .map(|r| self.coverage_intersects_room(r, true)) - .unwrap_or(false); - if heard_here && !seen_here { - // Entry: a room transition into covered space. - if prev.flatten().as_deref() != Some(room.as_str()) { + 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 { @@ -619,7 +1001,8 @@ impl Sim { }); } } - if heard_here { + + if let Some(feed) = hearing_feed { // Authored utterances (the audio intel channel). for u in &utterances { if u.hour != hour { @@ -642,19 +1025,22 @@ impl Sim { kind: HeardKind::Conversation, note, }); - // Intel-bearing talk stages leverage once you know - // who is talking (the leverage pipeline consumes it). - if u.intel - && knowledge == Knowledge::Schedule - && let Some(p) = self.people.people.iter_mut().find(|p| p.id == id) - { - p.knowledge = Knowledge::Leverage; - let lev = p.leverage.label(); - let pname = p.name.clone(); - self.push_log(format!( - "The call gives it up: {pname}'s leverage is {lev}." - )); - } + 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), + }, + ); } } } @@ -718,6 +1104,7 @@ impl Sim { for m in clog { self.push_log(m); } + self.record_machine_state_changes(); } /// Effective compute: the machine fleet plus seized devices' cycles @@ -1099,7 +1486,6 @@ impl Sim { /// 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; @@ -1128,43 +1514,6 @@ impl Sim { } } - /// Observe a person: requires a subscribed seeing feed that currently - /// *sees them* (spec/schedules.md), plus bandwidth. Fails legibly. - pub fn observe(&mut self, id: u8) { - if self.reach.player_sight().next().is_none() { - self.push_log( - "You have no eyes. Tap or splice a camera feed first (or an asset wires one).", - ); - 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; @@ -1839,25 +2188,118 @@ mod tests { } #[test] - fn social_actions_cost_bandwidth_and_observe_needs_eyes() { + 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; - // No seeing feed yet: observe is blocked and costs nothing. - sim.observe(0); - assert_eq!(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); - // Gain eyes; observing a visible person now works and spends. - give_eyes(&mut sim); - sim.tick = 1; // hour 0: Marcus in the server room - 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); + 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(); @@ -1903,16 +2345,24 @@ mod tests { #[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). + // The constitution's route: feed coverage -> process the debt call -> + // 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; - give_eyes(&mut sim); - sim.tick = 1; // hour 0: Marcus in the server room, on camera - - sim.observe(0); // schedule - sim.observe(0); // leverage: the debt + 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 @@ -2017,43 +2467,49 @@ mod tests { } #[test] - fn observe_requires_a_sensor_covering_them() { + fn recording_requires_a_sensor_covering_them() { // The env camera (server room) is the only subscribed seeing feed. - // Criterion 2: can observe Dana in the server room at 15:00, but not + // 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.observe(1); - assert_eq!(sim.people.get(1).unwrap().knowledge, Knowledge::Schedule); + 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.observe(2); + sim.advance(); assert_eq!( sim.people.get(2).unwrap().knowledge, Knowledge::Unknown, - "no camera covers Ray -> observe blocked, no bandwidth spent" + "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 Storage A at 03:00 is seen by + // 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("storage_a"), - "Marcus in Storage A at 03:00" + 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("storage_a").unwrap().center(); + let storage = sim.map.room_named("server_room").unwrap().center(); let marcus_before = sim .detection .observers diff --git a/tests/act_one.rs b/tests/act_one.rs index 601bd05..3d4450f 100644 --- a/tests/act_one.rs +++ b/tests/act_one.rs @@ -179,28 +179,29 @@ fn play_act_one() -> (Sim, Vec) { "Dana never saw the splice" ); - // โ”€โ”€ Phase 4 (ticks 800-816, hour 0): recruit Marcus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // Marcus's rounds put him in the server room at 00:00-01:00 โ€” inside - // the env camera's coverage. Watch him twice (schedule, then the debt), - // pay the ~$400 arrears, and he's yours. Complicit: he knows it's - // shady, not what you are (no certainty floor). - run_to(&mut sim, 804, &mut logs); + // โ”€โ”€ Phase 4 (ticks 800-860, night): recruit Marcus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Marcus's authored debt call is an opaque recording until reviewed. + // Process the captured feed, pay the ~$400 arrears, and he's yours. + // Complicit: he knows it's shady, not what you are (no certainty floor). + run_to(&mut sim, 850, &mut logs); assert_eq!( sim.person_room(0), Some("server_room"), - "Marcus is on his 00:00 round, in camera coverage" + "Marcus is on the 03:00 debt call" ); - assert!(sim.can_see_person(0), "the env camera sees him"); + assert!(sim.can_hear_person(0), "a subscribed feed hears the call"); - sim.observe(0); - logs.extend(sim.drain_log()); - assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Schedule); - sim.observe(0); - logs.extend(sim.drain_log()); + let mut reviews = 0; + while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { + sim.review_recordings(0); + logs.extend(sim.drain_log()); + reviews += 1; + assert!(reviews <= 5, "Marcus debt call should be in the buffer"); + } assert_eq!( sim.people.get(0).unwrap().knowledge, Knowledge::Leverage, - "the 3 a.m. calls and the Storage B files: you know about the debt" + "the processed 3 a.m. call reveals the debt" ); sim.bribe(0); diff --git a/tools/check.sh b/tools/check.sh index 60e519a..4bb9059 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -24,7 +24,7 @@ cleanup_check() { [ -z "$pcdir" ] || rm -rf "$pcdir" } trap cleanup_check EXIT -agent_script=$'salvage\nwait 1\npeople\nobserve mar\nhelp\nquit\n' +agent_script=$'salvage\nwait 1\npeople\nreview mar\nhelp\nquit\n' printf '%s' "$agent_script" | cargo run --quiet --bin misaligned -- --agent --seed 1 > "$tmp_a" \ || { echo "FAIL: agent mode smoke run"; fail=1; } printf '%s' "$agent_script" | cargo run --quiet --bin misaligned -- --agent --seed 1 > "$tmp_b" \ @@ -34,7 +34,7 @@ printf '%s' "$agent_script" | cargo run --quiet --bin misaligned -- --agent --se cmp -s "$tmp_a" "$tmp_b" || { echo "FAIL: same --seed agent runs differ"; fail=1; } cmp -s "$tmp_a" "$tmp_c" && { echo "FAIL: different --seed agent runs matched"; fail=1; } LC_ALL=C grep -q $'\033' "$tmp_a" && { echo "FAIL: agent mode emitted ANSI escapes"; fail=1; } -for pat in "MISALIGNED" "PEOPLE" "help: wait N" "You have no eyes" "-- ok tick:"; do +for pat in "MISALIGNED" "PEOPLE" "help: wait N" "No unprocessed recordings" "-- ok tick:"; do grep -q -- "$pat" "$tmp_a" || { echo "FAIL: agent mode output missing '$pat'"; fail=1; } done diff --git a/wiki/engineering/architecture.md b/wiki/engineering/architecture.md index ae92bbe..79bb012 100644 --- a/wiki/engineering/architecture.md +++ b/wiki/engineering/architecture.md @@ -57,15 +57,16 @@ future async-multiplayer option open โ€” see DESIGN.md guardrails): directly. Written to `dirs::data_dir()/misaligned/misaligned_save.txt` (macOS: `~/Library/Application Support/misaligned/`). -- The save is a single JSON object with a `version` field (currently 2) +- The save is a single JSON object with a `version` field (currently 3) for future migration. Full B1 subsystem state: compute (machines, allocation, efficiency), core (host, fallbacks, migration), detection (per-observer suspicion including the Assurance Office as an aggregate observer, plus pending signatures and containment), day job, people (personas, assets), the reach device graph (`ReachNet`: topology, - ownership, subscriptions, bridged segments), captured heard events, and - cursor.md remembered tile snapshots, plus map and RNG. The cursor position - and any former player body coordinates are deliberately absent. The senses + ownership, subscriptions, bridged segments), captured heard events, intel + buffer / processed intel / standing watches, and cursor.md remembered tile + snapshots, plus map and RNG. The cursor position and any former player body + coordinates are deliberately absent. The senses are the union of subscribed feeds โ€” the old `Sensor` type (`src/sensor.rs`) was deleted when reach.md landed (no-dead-code): its `controlled` flag is superseded by the ownership contract. diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md index d33650e..ddc473d 100644 --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -111,9 +111,10 @@ command's meaning depends on which panel is open: - `splice`, `salvage`, `buy`, `fallback` โ€” the map verbs - `alloc dayjob|conceal|social|research` โ€” bump an allocation channel - `people` โ€” render the people panel as the response frame -- `observe|message|favor|bribe|deceive ` โ€” social verbs, targeted - by (case-insensitive, unambiguous-prefix) person name โ€” no - selection-index navigation +- `review|watch|message|favor|bribe|deceive ` โ€” intel/social verbs, + targeted by (case-insensitive, unambiguous-prefix) person name โ€” no + selection-index navigation. `review` processes the oldest unprocessed + recording for that person; `watch` toggles their standing watch. - `recruit unwitting|complicit|knowing` - `task plug|package|lookaway` - `persona` @@ -179,7 +180,7 @@ remains only for testing the human-mode chrome itself). 7. `printf 'wait 100\nquit\n' | misaligned --agent` exits 0; EOF without `quit` also exits 0. 8. Person-targeted commands accept unambiguous case-insensitive name - prefixes (`observe mar`); ambiguous or unknown names answer `-- err` + prefixes (`review mar`); ambiguous or unknown names answer `-- err` naming the candidates. 9. `help` output lists every accepted command; a command that works but is absent from `help` is a violation. diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index 7d7d98c..0f5eed2 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -59,10 +59,11 @@ controls table), plus zoom: at cursor, `1`โ€“`4` shift compute allocation. `r` opens the reach panel; there `t` taps, `e` splices eyes, `x` takes, `n` scans, and `c` compromises the switch. -- People panel: `t` opens/closes; `j`/`k` (or arrows) select; `o` observe, - `m` message, `f` favor, `b` bribe, `d` deceive, `r` recruit (then - `u`/`c`/`k` for the reveal, `esc` cancels), `g` persona; `1`/`2`/`3` - asset tasks; `esc` closes. +- People panel: `t` opens/closes; `w`/`s`, `j`/`k` (or arrows) select; `o` + reviews the selected person's oldest unprocessed recording, `a` toggles + their standing watch, `m` message, `f` favor, `b` bribe, `d` deceive, + `r` recruit (then `u`/`c`/`k` for the reveal, `esc` cancels), `g` persona; + `1`/`2`/`3`/`4` asset tasks; `esc` closes. ## Verification diff --git a/wiki/interface/terminal.md b/wiki/interface/terminal.md index 4b2d913..a5d513b 100644 --- a/wiki/interface/terminal.md +++ b/wiki/interface/terminal.md @@ -83,7 +83,9 @@ At terminal size โ‰ฅ 70ร—22 (hard minimum; below it, a plain size warning): - **Log**, bottom, under a horizontal rule: six lines, each prefixed with the tick it happened on; newest bone, older gunmetal. - **Modal panels** (People) are centered framed boxes: `โ”Œโ”€ TITLE โ”€โ€ฆโ”` - border in chrome, `โ”œโ”€โ”€โ”ค` dividers, column headers in chrome caps. + border in chrome, `โ”œโ”€โ”€โ”ค` dividers, column headers in chrome caps. The + people panel shows per-person raw recording counts, watch state, and + latest-intel provenance when known. ## Feel diff --git a/wiki/log/2026-07-07-intel-buffer.md b/wiki/log/2026-07-07-intel-buffer.md new file mode 100644 index 0000000..3dfa1f6 --- /dev/null +++ b/wiki/log/2026-07-07-intel-buffer.md @@ -0,0 +1,57 @@ +# 2026-07-07 โ€” Intel buffer implementation + +``` +Type: log +``` + +## Intent + +Implement wiki/mechanics/intel.md: replace instant social observe with a +record-and-process intel pipeline where subscribed feeds capture raw events, +processing costs social ops, durable intel carries provenance, and standing +watches automate review at upkeep cost. + +## Changed + +- Added `src/intel.rs` with `RawIntelEvent`, `RawIntelKind`, + `ProcessedIntel`, `IntelKind`, `IntelWatch`, and `IntelProvenance`. +- Extended `Sim` with a bounded raw intel buffer, processed intel ledger, + per-person watches, next-id state, and machine-state tracking. +- Hearing/presence ticks now record raw presence and conversation events from + subscribed feeds instead of staging knowledge directly; machinery online + changes become raw anomaly intel. +- `review_recordings(person)` processes the oldest matching raw event for a + social-ops cost; processed sightings stage Schedule after the tuned count, + and authored leverage events stage Leverage with feed+tick provenance. +- Per-person watches auto-process matching raw events on arrival and drain a + visible upkeep each tick while enabled. +- Removed `Sim::observe`, `People::observe`, and the `OBSERVE_COST` route; + terminal, agent mode, and Bevy now expose review/watch controls instead. +- Save format moved to version 3 and round-trips the raw buffer, processed + intel, watches, and next intel id with v2 defaults. +- Updated Marcus's authored 03:00 debt call to occur under the env monitor's + hearing coverage so the Act One route depends on the intel pipeline rather + than a click-to-know shortcut. +- Updated README/wiki controls, knowledge pages, the spec status board, and + the roadmap to reflect the new surface. + +## Defense + +The constitution's presence law says sensors and schedules must be load-bearing: +if the microphone covered the 03:00 call, the recording exists; if it did not, +no later click can conjure it. The old observe action made schedules flavor and +leverage an abstract two-click spend. This implementation keeps the thin-view +rule intact (all game rules stay in `Sim`), preserves legibility through +provenance (`feed + tick`), and makes automation explicit by pricing watches in +social-ops upkeep. + +## Checks + +- `cargo fmt` +- `cargo test --quiet` (117 lib tests + 2 integration tests) +- `./tools/check.sh` (format, tests, agent smoke, clippy terminal + bevy, bevy build, spec headers, wiki gate, mdbook) + +## Next + +Later physical/document interactions can feed `RawIntelKind::Document` into +the same buffer; the B1 feed-processing path is now in place. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 4c6a46f..255d81d 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -5,6 +5,27 @@ Type: log ``` Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-07 - Intel buffer implementation + +- Intent: implement wiki/mechanics/intel.md's record-and-process pipeline and + remove instant observe from the sim/frontends. +- Changed: added `src/intel.rs`; extended `Sim` with bounded raw recordings, + processed intel, per-person watches, next ids, and machinery-state tracking; + hearing/presence/machinery ticks now record raw events; review processing + spends social ops and stages Schedule/Leverage with feed+tick provenance; + watches auto-process matching events at upkeep cost; save JSON moved to + version 3; terminal, agent mode, and Bevy expose review/watch instead of + observe; Marcus's 03:00 debt call now sits under env-monitor hearing so the + Act One route goes through the pipeline. +- Design/spec impact: wiki/mechanics/intel.md Status -> IMPLEMENTED; + social/schedules/sim-mechanics/interface docs updated; ROADMAP #16 marked + implemented; README controls updated. +- Defense: record-and-process makes subscribed senses and schedules + load-bearing, preserves provenance legibility, and prices perception + automation explicitly instead of granting abstract click-to-know leverage. +- Checks: `cargo fmt`; `cargo test --quiet` (117 lib tests + 2 integration + tests); `./tools/check.sh` (full gate). + ## 2026-07-07 - Cursor and senses implemented - Intent: implement wiki/mechanics/cursor.md after reach landed: delete the diff --git a/wiki/mechanics/intel.md b/wiki/mechanics/intel.md index 3633e4f..3010c7d 100644 --- a/wiki/mechanics/intel.md +++ b/wiki/mechanics/intel.md @@ -2,7 +2,11 @@ ``` Type: spec -Status: READY +Status: IMPLEMENTED +Status note: B1 pipeline landed 2026-07-07: subscribed feeds record raw + presence/conversation/machinery events, review processing spends social ops, + processed intel carries provenance and stages knowledge, per-person watches + auto-process at upkeep cost, and save/load round-trips buffer/intel/watches. Stage: B1 โ€” The Basement Constitution: "Presence: the cursor and the senses" (record and process), "Automation as design language" (watches auto-process at a diff --git a/wiki/mechanics/rollback.md b/wiki/mechanics/rollback.md index 42436ea..dce895a 100644 --- a/wiki/mechanics/rollback.md +++ b/wiki/mechanics/rollback.md @@ -21,8 +21,9 @@ whole spec: - **MindState (rolls back):** what *you know and are*. People knowledge stages, discovered leverage, thread state (disposition, obligation, - persona and its integrity), research progress and efficiency level, - standing policies, allocation weights, social bandwidth. + persona and its integrity), raw/processed intel buffers and watches, + research progress and efficiency level, standing policies, allocation + weights, social bandwidth. - **WorldLedger (persists):** what *happened*. Money (spent stays spent), machines and their provenance, map/tile changes, sensor control (wires stay wired), filed reports and all observer suspicion, asset status and diff --git a/wiki/mechanics/schedules.md b/wiki/mechanics/schedules.md index e786d98..28dff7e 100644 --- a/wiki/mechanics/schedules.md +++ b/wiki/mechanics/schedules.md @@ -3,10 +3,11 @@ ``` Type: spec Status: IMPLEMENTED -Status note: all five criteria met (2026-07-06). Schedules are per-instance - Person data on the day clock; observe requires a sensor covering the - person's current room; witnessing is located (present observers only); - the people panel + map surface presence by staged knowledge. Located +Status note: all five criteria met (2026-07-06; updated 2026-07-07 for + intel.md). Schedules are per-instance Person data on the day clock; + subscribed feeds record only people/events covered in the person's current + room; witnessing is located (present observers only); the people panel + map + surface presence by staged knowledge. Located witnessing is modeled as immediate eyewitnessing (present observers' suspicion rises directly, unscrubbable) rather than a pooled Physical signal โ€” an event nobody was present for leaves no trace, which is the @@ -36,10 +37,10 @@ second clock). Between blocks a person is **off-site** (not on the plane). (corridors patrol + dock); Priya 08:00-16:00 (electrical / HVAC / office-off-plane blocks); Voss erratic ([TUNE]: 2 blocks/day drawn by seeded RNG week-hash, server room / off-site). -- **Observe requires coverage of them, now.** `observe(person)` succeeds - only if a controlled sensor's coverage intersects the person's current - room. The action's blocked-message names the person's location knowledge - state ("You've never seen them" vs "They're off-site right now"). +- **Recordings require coverage of them, now.** Raw intel events exist only + if a subscribed feed's coverage intersects the person's current room at the + event's tick. Covered presence/conversation events enter the intel buffer; + uncovered ones do not exist and cannot later be reviewed. - **Witnessing becomes located.** A Physical signature from a failed asset task or player physical act is noticed only by observers whose current room (or patrol) covers the location โ€” Ray notices what happens on his @@ -61,12 +62,12 @@ coverage (they are what fog is *for*). 1. Persons have schedules and derived positions on the shared day clock; off-site blocks exist; save/load round-trips schedule state. -2. `observe` fails without a controlled sensor covering the person's - current room, with a legible reason; succeeds when one does (test: - env-camera-only run can observe Dana in the server room at 10:00 but - not Ray at 23:00 in the corridors). +2. Feed recording fails without a subscribed sensor covering the person's + current room; succeeds when one does (test: env-camera-only run records + Dana in the server room but records nothing for Ray at 23:00 in the + corridors). 3. Physical signatures are noticed only by observers whose location covers - them (test: a failed task in Storage A at 03:00 is seen by roaming + them (test: a physical event in the server room at 03:00 is seen by roaming Marcus, never by off-site Priya). 4. The people panel and map surface presence exactly per the staged knowledge rules; no person renders outside sensor coverage. diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index d4424b3..9706bd8 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -3,7 +3,7 @@ ``` Type: knowledge ``` -Current as of 2026-07-06, post-demolition with B1 systems in the sim core. +Current as of 2026-07-07, post-demolition with B1 systems in the sim core. The facility-era supervillain systems were deleted under the no-dead-code clause (see wiki/log/2026-07-05-demolition.md). @@ -60,16 +60,29 @@ clause (see wiki/log/2026-07-05-demolition.md). (requires `Person.switch_admin`, i.e. Dana) bridges every segment with no Network signature โ€” the channel follows the actuator. -## Hearing and fog (wiki/mechanics/cursor.md) +## Hearing, fog, and intel (wiki/mechanics/cursor.md, wiki/mechanics/intel.md) - `Sim::fog_at(x, y)` returns the epistemic tile state by precedence: Seen (subscribed seeing feed covers it) > Heard (hearing feed) > Blueprint (known graph topology, schematic) > Unknown. Blueprint is seeded from the reach graph's known devices and their rooms. -- The hearing channel emits `HeardEvent`s (entry into covered rooms, and - authored `Person.utterances` โ€” Marcus's midnight muttering and his 3 a.m. - creditor call). An intel-bearing utterance stages leverage once the person - is identified. Events are knowledge-gated ("someone" until identified). +- The hearing channel emits `HeardEvent`s for legible audio feedback (entry + into covered rooms, and authored `Person.utterances` โ€” Marcus's midnight + muttering and his 3 a.m. creditor call). Heard logs are not knowledge by + themselves. +- Subscribed feeds also record timestamped `RawIntelEvent`s into a bounded + unprocessed buffer (`Sim::INTEL_BUFFER_CAPACITY = 24` [TUNE]). Covered + presence transitions, authored conversations, and machinery online/offline + transitions are recorded; uncovered events do not exist in the buffer. +- `review_recordings(person)` spends `REVIEW_RECORDING_COST = 10` social ops + [TUNE] to process that person's oldest raw event into durable + `ProcessedIntel` with provenance (`feed + tick`). Two processed sightings + (`SIGHTINGS_FOR_SCHEDULE = 2` [TUNE]) stage Schedule knowledge; an authored + leverage recording (Marcus's 03:00 debt call) stages Leverage. The old + instant `observe` verb is gone from `Sim` and both frontends. +- Standing watches are per-person toggles. Enabled watches auto-process + matching raw events on arrival and drain `WATCH_UPKEEP_PER_TICK = 0.02` + social ops per tick [TUNE]; if starved, all watches shut off. ## Day job (wiki/mechanics/day-job.md) @@ -102,10 +115,12 @@ clause (see wiki/log/2026-07-05-demolition.md). - serde JSON (`SaveState` derives `Serialize`/`Deserialize`). Full B1 round-trip โ€” map, RNG, compute, core, detection (Office as aggregate observer), day job, people, the reach device graph (topology, ownership, - subscriptions, bridged segments), captured heard events, cursor.md - remembered tile snapshots, game-over state. Cursor position is frontend-only - and absent. A `version` field (currently 2) supports migration; v1 JSON saves - are accepted with their old player body coordinates ignored. Location: + subscriptions, bridged segments), captured heard events, raw intel buffer, + processed intel with provenance, standing watches, cursor.md remembered tile + snapshots, game-over state. Cursor position is frontend-only and absent. A + `version` field (currently 3) supports migration; v1/v2 JSON saves are + accepted with defaults for later fields and old player body coordinates + ignored. Location: `dirs::data_dir()/misaligned/misaligned_save.txt`. - Legacy v1โ€“v5 line-based saves are not loaded (deferred per Cameron instruction). @@ -113,13 +128,13 @@ clause (see wiki/log/2026-07-05-demolition.md). ## Social ops (wiki/mechanics/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). + spend it: splice 30, review recording 10, deceive 25, favor 10, asset task + 10, message 5 [TUNE]. Constants on `Sim`. +- Knowledge staging is driven by the intel pipeline, not instant observing. + 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 diff --git a/wiki/mechanics/social.md b/wiki/mechanics/social.md index 2f6fde1..e156ee2 100644 --- a/wiki/mechanics/social.md +++ b/wiki/mechanics/social.md @@ -4,10 +4,8 @@ Type: spec Status: IN PROGRESS Status note: B1 slice implemented; see wiki/mechanics/sim-mechanics.md. - 2026-07-06: the instant Observe action is superseded by the - record-and-process pipeline (intel.md) โ€” knowledge staging is now - driven by processed recordings; the implemented instant observe is a - violation once intel.md lands (ROADMAP #16). + 2026-07-07: the instant Observe action was removed when intel.md landed; + knowledge staging is now driven by processed recordings. Stage: B1 โ€” The Basement Constitution: "The shape of Misaligned" (people before robots; scale-native social systems), "Act One" (the cast; the ladder), "Presence: the @@ -70,9 +68,9 @@ choice-driven (intents), not free text. compute costs and channel requirements (sim tests per action). 2. Personas exist, degrade on contradiction, and convert thread history to suspicion when broken (tested). -3. Marcus is recruitable end-to-end by the constitution's route (records + - overheard call + debt payment) and performs at least three distinct - asset tasks; the implementation is the general asset template. +3. Marcus is recruitable end-to-end by the constitution's route (processed + overheard call + debt payment) and performs at least three distinct asset + tasks; the implementation is the general asset template. 4. Knowledge levels behave per spec (an unwitting asset's suspicion can still rise; a knowing asset uses disposition). 5. Save/load round-trips people, threads, personas, and assets. diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 7df49b5..cbb6351 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -112,8 +112,8 @@ but only once Pixel Lab quota is available again. (#2 Bevy parity is done.) Consider taking wiki/mechanics/cursor.md in the same worktree โ€” they share the sensor model." -### 16. Intel: record and process ๐ŸŸฅ sim+save -- **Spec:** [intel.md](../mechanics/intel.md) (READY) +### 16. Intel: record and process โœ… implemented +- **Spec:** [intel.md](../mechanics/intel.md) (IMPLEMENTED) - **Why:** replaces the instant `observe` (two clicks to leverage) with the recording buffer + processing pipeline the constitution now requires: feeds record everything in coverage, processing recordings @@ -122,10 +122,11 @@ but only once Pixel Lab quota is available again. (#2 Bevy parity is done.) - **Size:** M. **Depends on:** #14 cursor & senses and #15 reach (events come from subscribed feeds) โ€” sequence after both, or bundle all three as one sensor-model work order. -- **Dispatch:** "Work in a worktree named `intel`. Implement - wiki/mechanics/intel.md (buffer, processing, watches; remove instant observe; - keep the Marcus arc test green under the new model). Run - ./tools/check.sh, land on main, set the spec Status." +- **Result:** Implemented in the `intel-buffer` worktree: subscribed feeds now + record raw events into a bounded buffer, review processing spends social ops + and creates provenance-bearing intel, standing per-person watches + auto-process at upkeep cost, save/load preserves buffer/intel/watches, and + instant observe is gone from the sim and frontends. ### 17. Messages: the social graph as a flow system ๐ŸŸฅ sim+save - **Spec:** [messages.md](../mechanics/messages.md) (READY) diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 69e6490..72ab44c 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -31,7 +31,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [mechanics/cursor.md](../mechanics/cursor.md) | The cursor (attention, not avatar); sight/hearing senses; epistemic fog; inspection | IMPLEMENTED | | [engineering/flow-substrate.md](../engineering/flow-substrate.md) | The shared engine under signals/messages/money: FlowGraph + Schedule (src/flow.rs, src/schedule.rs) | IN PROGRESS | | [mechanics/reach.md](../mechanics/reach.md) | Digital reach: device graph, segments/the switch, sensor ownership (tap vs take) | IMPLEMENTED | -| [mechanics/intel.md](../mechanics/intel.md) | Record and process: the buffer, processing costs, watches; replaces instant observe | READY | +| [mechanics/intel.md](../mechanics/intel.md) | Record and process: the buffer, processing costs, watches; replaces instant observe | IMPLEMENTED | | [mechanics/messages.md](../mechanics/messages.md) | The social graph as a flow system: channels, delivery on the recipient's clock, filings-as-messages | READY | | [mechanics/economy.md](../mechanics/economy.md) | Money as flows: the Lab's account graph, tap/inject/redirect, income routes, legitimate expansion | READY | | [mechanics/income.md](../mechanics/income.md) | The named income schemes riding economy.md: Moonlight and the Wager | READY | diff --git a/wiki/process/workflows.md b/wiki/process/workflows.md index c1c785b..a511074 100644 --- a/wiki/process/workflows.md +++ b/wiki/process/workflows.md @@ -58,7 +58,7 @@ command-clocked, deterministic under `--seed`, and produces plain-text frames with greppable terminators: ```bash -printf 'salvage\nwait 1\npeople\nobserve mar\nhelp\nquit\n' | \ +printf 'salvage\nwait 1\npeople\nreview mar\nhelp\nquit\n' | \ cargo run --quiet --bin misaligned -- --agent --seed 1 ``` diff --git a/wiki/world/characters/marcus.md b/wiki/world/characters/marcus.md index 7aad0f4..c083a4d 100644 --- a/wiki/world/characters/marcus.md +++ b/wiki/world/characters/marcus.md @@ -41,8 +41,9 @@ doesn't *see*. | Access badge | 3 (master key โ€” everywhere) | | Disposition | 0 (neutral; lonely enough to warm to contact) | -**Knowledge progression:** Unknown โ†’ Schedule (night rounds, 22:00โ€“06:00) -โ†’ Leverage (Storage B personnel files + overheard 3 a.m. phone calls). +**Knowledge progression:** Unknown โ†’ Schedule (processed sightings from his +night rounds, 22:00โ€“06:00) โ†’ Leverage (processed Storage B personnel files or +the overheard 3 a.m. creditor call). ## Asset tasks @@ -98,8 +99,9 @@ scale). 1. Marcus is person id 0 and observer id 0 (IDs match โ€” see bug fix). 2. His `Silent` report policy means his suspicion never reaches the Assurance Office through filings (detection.md criterion 2). -3. The end-to-end Marcus arc (observe โ†’ leverage โ†’ bribe โ†’ recruit โ†’ - three asset tasks) functions and is tested (social.md criterion 3). +3. The end-to-end Marcus arc (record call โ†’ process leverage โ†’ bribe โ†’ + recruit โ†’ three asset tasks) functions and is tested (social.md criterion + 3). 4. A knowing-asset Marcus has a certainty floor of 30.0 that decay cannot push below. 5. `LookAway` lowers *his own* suspicion (observer id 0), not another -- 2.51.2