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