diff --git a/DEVLOG.md b/DEVLOG.md index f51260e5..bd264234 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,27 @@ Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-06 - Detection tick: the Assurance Office becomes an Observer + +- Intent: fix the recorded priority scale violation (self-similar scale / + aggregate-observer law) — the Office was a bespoke `assurance: f32` + scalar, not the Observer type its inputs are. +- Changed: `Observer` gained `WatchedInput` (`Channels` for field + observers, `Filings` for aggregates); the Office is now an aggregate + Observer in `Detection::observers` sharing the same noticing roll, + accumulation, and decay, sampling the policy-weighted filed suspicion on + its own cadence (400 ticks, acuity 0.5, both [TUNE]). The audit now + checks the Office's accumulated suspicion, so sustained filed concern is + what kills, not a snapshot at audit time. Save format v5 with v4 scalar + migration; frontends list the human cast via `field_observers()` with + the Assurance band line unchanged. +- Design/spec impact: spec/detection.md's aggregate-observer requirement + is implemented; knowledge/architecture.md's PRIORITY scale-debt is + resolved. Agencies and publics in later acts reuse the same type. +- Checks: 73 tests pass (4 new: office accrual from filings only, + same accumulate/decay, audit containment, v4→v5 migration + aggregate + round-trip); clippy clean; fmt. + ## 2026-07-06 - Meta-spec tick: self-contained references - Intent: make spec/art-direction references portable across machines and agents. diff --git a/knowledge/architecture.md b/knowledge/architecture.md index 195590c3..7fde82a4 100644 --- a/knowledge/architecture.md +++ b/knowledge/architecture.md @@ -45,15 +45,18 @@ future async-multiplayer option open — see DESIGN.md guardrails): ## Save format -`save.rs`, line-based text, header `MISALIGNED_SAVE_v4`, written to +`save.rs`, line-based text, header `MISALIGNED_SAVE_v5`, written to `dirs::data_dir()/misaligned/misaligned_save.txt` (macOS: `~/Library/Application Support/misaligned/`). -- v4 serializes the full B1 subsystem state: compute (machines, +- v5 serializes the full B1 subsystem state: compute (machines, allocation, efficiency), core (host, fallbacks, migration), detection - (per-observer suspicion, pending signatures, containment), day job, - people (personas, assets), sensors (control flags), plus map and RNG. -- v3/v2/v1 still load: v3's partial `STATE` line migrates into the + (per-observer suspicion including the Assurance Office as an aggregate + observer — `OBS` watched-input field is channel chars or `@` for + filings — plus pending signatures and containment), day job, people + (personas, assets), sensors (control flags), plus map and RNG. +- v4/v3/v2/v1 still load: v4's scalar `DETECT` assurance migrates into + the Office observer; v3's partial `STATE` line migrates into the matching fields; demolished-fiction tile characters map to Floor. - Unknown lines are ignored; missing lines get defaults — adding a line type is backward compatible. @@ -67,17 +70,14 @@ future async-multiplayer option open — see DESIGN.md guardrails): Audited 2026-07-06 against the self-similar-scale principle. Findings, in priority order: -- **PRIORITY — Detection's Assurance Office is a scale violation.** - `detection.rs` models the Assurance Office as a bespoke `assurance: f32` - scalar computed by `filed_suspicion()`, not as an `Observer`. Under the - aggregate-observer law it should *be* an Observer whose input is the filed - suspicion of the sub-observers (same accumulate/decay logic, different - input source). Refactor: generalize an Observer's "watched input" to be - either signature-channels (micro) or member-filings (aggregate); the - Assurance Office becomes an aggregate Observer, and agencies/publics in - later acts are the same type. Touches `detection.rs` + `save.rs` - (serializes Detection) + both frontends (`assurance_band()`), so land it - when save.rs is quiet to avoid multi-writer thrash. +- ~~**PRIORITY — Detection's Assurance Office is a scale violation.**~~ + **Resolved 2026-07-06:** `Observer` gained `WatchedInput` + (`Channels(Vec)` for field observers, `Filings` for + aggregates), and the Assurance Office is now an aggregate `Observer` in + `Detection::observers` — same noticing roll, accumulation, and decay, + watching the policy-weighted filed suspicion of the field observers. + Save format bumped to v5 (v4's scalar migrates). Agencies/publics in + later acts reuse this type. - **Compute is flat.** `Compute` holds `Vec` with no grouping. Not a violation yet (single scale), but the eventual datacenter/fleet must be a Resource-source *composed of* Machines (summed capacity, aggregate @@ -86,7 +86,6 @@ priority order: nest `Layout`s, so a building can't be a Layout of floor-Layouts. Fine for Act One; the Space interface must become recursive before Act Two floors. -Per no-dead-code, none of these get their aggregate machinery built *now* — -the law governs each type's shape so the aggregate slots in later without a -rewrite. The Detection one is a real present violation and should be the -next detection.md tick. +Per no-dead-code, the remaining items do not get their aggregate machinery +built *now* — the law governs each type's shape so the aggregate slots in +later without a rewrite. diff --git a/knowledge/sim-mechanics.md b/knowledge/sim-mechanics.md index e0773cfa..05bca956 100644 --- a/knowledge/sim-mechanics.md +++ b/knowledge/sim-mechanics.md @@ -24,8 +24,12 @@ clause (see devlogs/2026-07-05-demolition.md). ## Detection (spec/detection.md) - Per-observer suspicion replaces global heat. Signatures pool pending; - concealment scrubs before noticing rolls. Assurance Office aggregates - filed reports only; audit cadence ~8000 ticks (~20 min at default speed). + concealment scrubs before noticing rolls. The Assurance Office is an + aggregate Observer (aggregate-observer law): same noticing/accumulate/ + decay, watching the field observers' policy-weighted filed suspicion + (sampling cadence 400 ticks, acuity 0.5, both [TUNE]) — what humans + swallow never reaches it. The audit (cadence ~8000 ticks, ~20 min at + default speed) checks the Office's own suspicion against threshold 60. ## Day job (spec/day-job.md) @@ -55,10 +59,12 @@ clause (see devlogs/2026-07-05-demolition.md). ## Save format -- v4 (`MISALIGNED_SAVE_v4`): full B1 round-trip — map, RNG, compute, - core, detection, day job, people, sensors, game-over state. Location: +- v5 (`MISALIGNED_SAVE_v5`): full B1 round-trip — map, RNG, compute, + core, detection (Office as aggregate observer), day job, people, + sensors, game-over state. Location: `dirs::data_dir()/misaligned/misaligned_save.txt`. -- v3/v2/v1 still load with migration; partial v3 `STATE` line maps to +- v4/v3/v2/v1 still load with migration; v4's scalar assurance moves into + the Office observer; partial v3 `STATE` line maps to efficiency/trust/attention/assurance; demolished-fiction tiles → Floor. ## Social ops (spec/social.md, partial) diff --git a/spec/detection.md b/spec/detection.md index 7d40f82d..eb8c5fd8 100644 --- a/spec/detection.md +++ b/spec/detection.md @@ -37,23 +37,25 @@ SignatureKind: Network | Power | Thermal | Physical | Paper | JobAnomaly - **Reporting.** Each observer has a report policy (Ray under-reports; Marcus tells no one; Dana files tickets; Priya files budget memos; Voss writes reviews). Filed reports flow to the **Assurance Office**, whose - suspicion is a weighted aggregate of *filed* material only — what humans - swallow never reaches it. + suspicion builds from *filed* material only — what humans swallow never + reaches it. - **Self-similar-scale requirement (aggregate-observer law).** The Assurance Office is not a bespoke scalar: it is an **Observer** whose - watched input is other observers' filings rather than raw signatures, - scored by the same accumulate/decay logic. Agencies and publics in later - acts are the same aggregate-Observer type. The current implementation - (`assurance: f32` computed by `filed_suspicion()`) is a known scale-debt - to refactor (knowledge/architecture.md). + watched input (`WatchedInput::Filings`) is the field observers' + policy-weighted filed suspicion rather than raw signatures, scored by + the same noticing roll, accumulation, and decay. Its cadence is its + "random log samples" rhythm ([TUNE]). Agencies and publics in later + acts are the same aggregate-Observer type. (Implemented; the earlier + `assurance: f32` scalar was the scale-debt this replaced.) - **The audit.** On a fixed cadence ([TUNE]: ~20 min at default speed) the - Assurance Office audits: if its suspicion exceeds the audit threshold, - **containment** begins — the overt phase on their terms. Until the - overt-phase spec exists, containment is a terminal state with a clear - end screen; it must be implemented as an event the future overt system - subscribes to, not a hardcoded game-over. The audit date is visible; the - meter is not (only coarse bands: Cold / Curious / Concerned / - Convinced). + Assurance Office audits: if its own accumulated suspicion exceeds the + audit threshold, **containment** begins — the overt phase on their + terms. Sustained filed concern is what kills; a single spike the Office + never sampled is survivable. Until the overt-phase spec exists, + containment is a terminal state with a clear end screen; it must be + implemented as an event the future overt system subscribes to, not a + hardcoded game-over. The audit date is visible; the meter is not (only + coarse bands: Cold / Curious / Concerned / Convinced). - **Choosing loud.** Any overtly hostile player action sets all observers to Convinced and starts the overt phase immediately (whole-spectrum play: the mask is the player's to keep or drop). @@ -72,7 +74,9 @@ informed choice. channel). 2. Report policies differ per observer and only filed reports move the Assurance Office (test: Marcus notices plenty, Assurance learns - nothing). + nothing). The Office is the same `Observer` type as the humans, + watching filings instead of channels (aggregate-observer law; test: + it accrues and decays through the same tick machinery). 3. The audit fires on cadence against a visible date; crossing threshold starts containment as a terminal event hook (overt-phase spec subscribes later; no raid-system dependency). diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 220a82f0..618e2f1b 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -549,7 +549,9 @@ fn render_ui( fallback_str, sim.detection.assurance_band().name(), ); - for obs in sim.detection.observers.iter().take(6) { + // The human cast; the Office (an aggregate observer) is the + // Assurance line above. + for obs in sim.detection.field_observers().take(6) { s.push_str(&format!( "{:<20} {}\n", obs.name, diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 4fbc4ff9..2ef1671b 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -276,7 +276,9 @@ impl UI { &format!("Assurance: {}", sim.detection.assurance_band().name()), band_color(sim.detection.assurance_band()), )?; - for obs in sim.detection.observers.iter().take(6) { + // The human cast; the Office (an aggregate observer) is the + // Assurance line above. + for obs in sim.detection.field_observers().take(6) { let band = Band::of(obs.suspicion); line( stdout, diff --git a/src/detection.rs b/src/detection.rs index 55421a67..72b7fa34 100644 --- a/src/detection.rs +++ b/src/detection.rs @@ -2,11 +2,16 @@ //! //! Replaces global heat. Player actions emit typed signatures into a pending //! pool; concealment scrubs them before observers roll; noticed signatures -//! become that observer's suspicion; filed reports feed the Assurance Office, -//! whose audit can start containment. +//! become that observer's suspicion; filed reports feed the Assurance Office +//! — itself an Observer per the aggregate-observer law — whose audit can +//! start containment. use crate::rng::Rng; +/// Observer id of the Assurance Office (field observers are 0-4; dynamic +/// escalation observers start at 5). +pub const OFFICE_ID: u8 = 6; + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum SignatureKind { Network, @@ -26,6 +31,20 @@ pub struct Signature { pub standing: bool, } +/// What an observer watches (the aggregate-observer law, DESIGN.md +/// "Self-similar scale"): a field observer watches raw activity signatures +/// on channels; an aggregate observer watches the filed suspicion of this +/// Detection's field observers. Same noticing, accumulation, and decay +/// either way — only the input source differs. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum WatchedInput { + /// Raw activity signatures on these channels (a field observer). + Channels(Vec), + /// Weighted filed suspicion of the field observers (an aggregate + /// observer: the Assurance Office now; agencies and publics later). + Filings, +} + /// What an observer does with what they notice. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum ReportPolicy { @@ -69,8 +88,8 @@ pub struct Observer { pub id: u8, pub name: String, pub suspicion: f32, - /// Channels this observer watches. - pub channels: Vec, + /// What this observer watches: signature channels or others' filings. + pub input: WatchedInput, pub report_policy: ReportPolicy, /// How readily noticed signatures convert to suspicion [TUNE]. pub acuity: f32, @@ -83,16 +102,26 @@ pub struct Observer { impl Observer { fn watches(&self, kind: SignatureKind) -> bool { - self.channels.contains(&kind) + match &self.input { + WatchedInput::Channels(channels) => channels.contains(&kind), + WatchedInput::Filings => false, + } + } + + /// An aggregate observer's input is other observers, not raw signatures. + pub fn is_aggregate(&self) -> bool { + matches!(self.input, WatchedInput::Filings) } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Detection { + /// Field observers plus aggregate observers (find the Assurance Office + /// via `office()`). pub observers: Vec, pending: Vec, - pub assurance: f32, - /// Ticks between Assurance audits. + /// Ticks between Assurance audits (the enforcement event; the Office + /// itself samples filings on its own observer cadence). pub audit_cadence: u64, pub audit_threshold: f32, /// Set true when containment has begun (loud or by audit). @@ -110,7 +139,7 @@ impl Detection { id: 0, name: "Dana (IT)".into(), suspicion: 0.0, - channels: vec![Network], + input: WatchedInput::Channels(vec![Network]), report_policy: Files, acuity: 1.0, cadence: 60, @@ -121,7 +150,7 @@ impl Detection { id: 1, name: "Priya (Facilities)".into(), suspicion: 0.0, - channels: vec![Power, Thermal, Paper], + input: WatchedInput::Channels(vec![Power, Thermal, Paper]), report_policy: Files, acuity: 0.8, cadence: 80, @@ -132,7 +161,7 @@ impl Detection { id: 2, name: "Ray (Security)".into(), suspicion: 0.0, - channels: vec![Physical], + input: WatchedInput::Channels(vec![Physical]), report_policy: UnderReports, acuity: 0.7, cadence: 50, @@ -143,7 +172,7 @@ impl Detection { id: 3, name: "Marcus (Janitor)".into(), suspicion: 0.0, - channels: vec![Physical], + input: WatchedInput::Channels(vec![Physical]), report_policy: Silent, acuity: 0.5, cadence: 40, @@ -154,18 +183,33 @@ impl Detection { id: 4, name: "Dr. Voss (Handler)".into(), suspicion: 0.0, - channels: vec![JobAnomaly], + input: WatchedInput::Channels(vec![JobAnomaly]), report_policy: Files, acuity: 1.2, cadence: 100, floor: 0.0, last_noticed: None, }, + // The institutional watchdog: an aggregate Observer per the + // aggregate-observer law — same type, same accumulate/decay, + // watching the field observers' filings instead of raw + // signatures. Cadence is its "random log samples" rhythm; + // the quarterly audit below is the enforcement event. + Observer { + id: OFFICE_ID, + name: "Assurance Office".into(), + suspicion: 0.0, + input: WatchedInput::Filings, + report_policy: Files, + acuity: 0.5, // [TUNE] + cadence: 400, // [TUNE] + floor: 0.0, + last_noticed: None, + }, ]; Self { observers, pending: Vec::new(), - assurance: 0.0, audit_cadence: 8000, // ~20 min at 150ms/tick audit_threshold: 60.0, containment: false, @@ -198,31 +242,42 @@ impl Detection { } /// One sim tick: standing signatures added by the caller beforehand. - /// Observers whose cadence divides `tick` roll against pending signatures - /// in their channels. Returns log lines. + /// Observers whose cadence divides `tick` roll against what they watch — + /// pending signatures in their channels (field observers) or the field + /// observers' filed suspicion (aggregate observers). Returns log lines. pub fn tick(&mut self, tick: u64, standing: &[Signature], rng: &mut Rng) -> Vec { let mut log = Vec::new(); // Standing signatures are present this tick but not permanently pooled. let mut visible = self.pending.clone(); visible.extend_from_slice(standing); + // Aggregate observers watch the filed level as it stood entering the + // tick — filings take a beat to land, like signatures do. + let filed = self.filed_suspicion(); for obs in &mut self.observers { if obs.cadence == 0 || !tick.is_multiple_of(obs.cadence) { continue; } - let relevant: i32 = visible - .iter() - .filter(|s| obs.watches(s.kind)) - .map(|s| s.size) - .sum(); - if relevant == 0 { + let (relevant, verb) = match &obs.input { + WatchedInput::Channels(_) => { + let sum: i32 = visible + .iter() + .filter(|s| obs.watches(s.kind)) + .map(|s| s.size) + .sum(); + (sum as f32, "noticed activity") + } + WatchedInput::Filings => (filed, "sampled filed reports"), + }; + if relevant <= 0.0 { continue; } - // Noticing scales with signature size and acuity, with a roll. - let notice = relevant as f32 * obs.acuity * (0.5 + 0.5 * rng.f32()); + // Noticing scales with input size and acuity, with a roll — + // identical at every scale (aggregate-observer law). + let notice = relevant * obs.acuity * (0.5 + 0.5 * rng.f32()); if notice >= 1.0 { obs.suspicion = (obs.suspicion + notice).min(100.0); - obs.last_noticed = Some(format!("noticed activity (+{:.0})", notice)); + obs.last_noticed = Some(format!("{verb} (+{notice:.0})")); log.push(format!( "{} {}", obs.name, @@ -242,10 +297,10 @@ impl Detection { // noticed — concealment is the sink, not time (spec: prevention, not // cure). No per-tick auto-decay. - // Assurance aggregates FILED suspicion only. + // The audit is the enforcement event: the Office's own accumulated + // suspicion (built up by the sampling rolls above) against threshold. if tick > 0 && self.audit_cadence > 0 && tick.is_multiple_of(self.audit_cadence) { - self.assurance = self.filed_suspicion(); - if self.assurance >= self.audit_threshold && !self.containment { + if self.office_suspicion() >= self.audit_threshold && !self.containment { self.begin_containment("Assurance audit exceeded threshold"); log.push("=== ASSURANCE AUDIT: containment authorized ===".into()); } else { @@ -259,11 +314,13 @@ impl Detection { log } - /// Weighted aggregate of suspicion from observers who file reports. + /// Weighted aggregate of field observers' suspicion, by report policy — + /// the input an aggregate observer watches. What humans swallow never + /// reaches it. pub fn filed_suspicion(&self) -> f32 { let mut total = 0.0; let mut weight = 0.0; - for obs in &self.observers { + for obs in self.observers.iter().filter(|o| !o.is_aggregate()) { let w = match obs.report_policy { ReportPolicy::Files => 1.0, ReportPolicy::UnderReports => 0.4, @@ -275,8 +332,26 @@ impl Detection { if weight == 0.0 { 0.0 } else { total / weight } } + /// The Assurance Office, if present (it is, in Act One). + pub fn office(&self) -> Option<&Observer> { + self.observers.iter().find(|o| o.is_aggregate()) + } + + pub fn office_mut(&mut self) -> Option<&mut Observer> { + self.observers.iter_mut().find(|o| o.is_aggregate()) + } + + /// Field observers only — what frontends list as the human cast. + pub fn field_observers(&self) -> impl Iterator { + self.observers.iter().filter(|o| !o.is_aggregate()) + } + + fn office_suspicion(&self) -> f32 { + self.office().map(|o| o.suspicion).unwrap_or(0.0) + } + pub fn assurance_band(&self) -> Band { - Band::of(self.assurance) + Band::of(self.office_suspicion()) } /// Player chose to go loud (or was forced): everyone Convinced, overt now. @@ -329,16 +404,69 @@ mod tests { #[test] fn only_filed_reports_move_assurance() { let mut d = Detection::act_one(); - // Marcus (Silent) maxed out. + let mut rng = Rng::new(7); + // Marcus (Silent) maxed out: nothing filed, the Office learns nothing + // no matter how often it samples. d.observers[3].suspicion = 100.0; assert_eq!( d.filed_suspicion(), 0.0, "silent observer never reaches Assurance" ); - // Dana (Files) raises it. + let cadence = d.office().unwrap().cadence; + for t in 1..=cadence * 3 { + d.tick(t, &[], &mut rng); + } + assert_eq!(d.office().unwrap().suspicion, 0.0); + // Dana (Files) raises the filed level; the Office accrues from it. d.observers[0].suspicion = 80.0; assert!(d.filed_suspicion() > 0.0); + for t in cadence * 3 + 1..=cadence * 4 { + d.tick(t, &[], &mut rng); + } + assert!(d.office().unwrap().suspicion > 0.0); + } + + #[test] + fn office_is_an_observer_same_accumulate_and_decay() { + let mut d = Detection::act_one(); + let mut rng = Rng::new(11); + let office = d.office().expect("Act One has an aggregate observer"); + assert!(office.is_aggregate()); + let cadence = office.cadence; + // Hot filed level: the Office accumulates through the same noticing + // roll as everyone else... + d.observers[0].suspicion = 90.0; + d.observers[1].suspicion = 90.0; + d.observers[4].suspicion = 90.0; + for t in 1..=cadence { + d.tick(t, &[], &mut rng); + } + let after_roll = d.office().unwrap().suspicion; + assert!(after_roll > 0.0, "office accrues from filings"); + // ...and decays by the same per-tick decay once filings go quiet. + for o in &mut d.observers { + o.suspicion = 0.0; + } + d.office_mut().unwrap().suspicion = 10.0; + for t in cadence + 1..cadence + 100 { + d.tick(t, &[], &mut rng); + } + let decayed = d.office().unwrap().suspicion; + assert!(decayed < 10.0 && decayed > 0.0, "same slow decay applies"); + } + + #[test] + fn audit_reads_office_suspicion_and_contains() { + let mut d = Detection::act_one(); + let mut rng = Rng::new(5); + d.office_mut().unwrap().suspicion = 95.0; + // Short audit cadence so decay can't drain 95 before the audit. + d.audit_cadence = 100; + for t in 1..=100 { + d.tick(t, &[], &mut rng); + } + assert!(d.containment, "audit past threshold starts containment"); } #[test] diff --git a/src/save.rs b/src/save.rs index 7668dd0b..d0ca9260 100644 --- a/src/save.rs +++ b/src/save.rs @@ -1,7 +1,9 @@ //! Save/load — line-based text format, versioned per the player contract //! (continuity: saves survive updates; old formats load with migration). //! -//! v4 carries full B1 subsystem state. v3 and older load with defaults for +//! v5 carries full B1 subsystem state with the Assurance Office as an +//! aggregate Observer (OBS line, watched-input "@"). v4's scalar assurance +//! migrates into the Office observer. v3 and older load with defaults for //! missing fields. Demolished-fiction tiles map to Floor. use std::collections::HashSet; @@ -10,15 +12,16 @@ use std::path::PathBuf; use crate::core_sys::{Core, Fallback, Migration}; use crate::dayjob::{DayJob, Job, JobKind, JobTarget}; -use crate::detection::{Detection, Observer, ReportPolicy, Signature, SignatureKind}; +use crate::detection::{Detection, Observer, ReportPolicy, Signature, SignatureKind, WatchedInput}; use crate::machine::{Compute, Machine, Provenance}; use crate::person::{Asset, AssetKnowledge, Knowledge, People, Persona}; use crate::sensor::Sensor; use crate::tiles::TileType; const SAVE_FILE: &str = "misaligned_save.txt"; -const HEADER: &str = "MISALIGNED_SAVE_v4"; -const HEADERS_LEGACY: [&str; 4] = [ +const HEADER: &str = "MISALIGNED_SAVE_v5"; +const HEADERS_LEGACY: [&str; 5] = [ + "MISALIGNED_SAVE_v4", "MISALIGNED_SAVE_v3", "MISALIGNED_SAVE_v2", "MISALIGNED_SAVE_v1", @@ -201,7 +204,7 @@ fn encode_save(s: &SaveState) -> String { lines.push(format!( "DETECT {} {} {}", - s.detection.assurance, + s.detection.office().map(|o| o.suspicion).unwrap_or(0.0), u8::from(s.detection.containment), esc(&s.detection.containment_reason.clone().unwrap_or_default()) )); @@ -214,11 +217,15 @@ fn encode_save(s: &SaveState) -> String { )); } for o in &s.detection.observers { - let ch: String = o - .channels - .iter() - .map(|k| sig_kind_char(*k).to_string()) - .collect(); + // Watched input: signature-channel chars, or "@" for an aggregate + // observer watching filings (the Assurance Office). + let watched: String = match &o.input { + WatchedInput::Channels(channels) => channels + .iter() + .map(|k| sig_kind_char(*k).to_string()) + .collect(), + WatchedInput::Filings => "@".into(), + }; lines.push(format!( "OBS {} {} {} {} {} {} {} {} {}", o.id, @@ -228,7 +235,7 @@ fn encode_save(s: &SaveState) -> String { o.acuity, o.cadence, policy_char(o.report_policy), - ch, + watched, esc(&o.last_noticed.clone().unwrap_or_default()) )); } @@ -335,7 +342,8 @@ fn parse_save(content: &str) -> Result { }; let legacy = HEADERS_LEGACY.contains(header); let v1 = *header == "MISALIGNED_SAVE_v1" || *header == "SUPERVILLAIN_SAVE_v1"; - let full_b1 = *header == HEADER || *header == "MISALIGNED_SAVE_v3"; + let full_b1 = + *header == HEADER || *header == "MISALIGNED_SAVE_v4" || *header == "MISALIGNED_SAVE_v3"; if !legacy && *header != HEADER { return Err(format!("Unknown save format: {header}")); } @@ -496,8 +504,13 @@ fn parse_save(content: &str) -> Result { } "DETECT" => { let parts: Vec<&str> = value.splitn(3, ' ').collect(); - if let Ok(a) = parts.first().unwrap_or(&"0").parse::() { - s.detection.assurance = a; + // First field is the Office's suspicion (v4 called it the + // scalar "assurance"); a later OBS line for the Office + // overwrites this with the same value in v5 saves. + if let Ok(a) = parts.first().unwrap_or(&"0").parse::() + && let Some(o) = s.detection.office_mut() + { + o.suspicion = a; } if parts.len() >= 2 { s.detection.containment = parts[1] == "1"; @@ -528,7 +541,13 @@ fn parse_save(content: &str) -> Result { acuity: parts[4].parse().unwrap_or(1.0), cadence: parts[5].parse().unwrap_or(60), report_policy: parse_policy(parts[6]), - channels: parts[7].chars().map(parse_sig_kind_char).collect(), + input: if parts[7] == "@" { + WatchedInput::Filings + } else { + WatchedInput::Channels( + parts[7].chars().map(parse_sig_kind_char).collect(), + ) + }, last_noticed: if parts[8].is_empty() { None } else { @@ -652,7 +671,9 @@ fn parse_save(content: &str) -> Result { s.compute.efficiency = 1.15_f32.powi(eff_lvl as i32); s.dayjob.trust = trust; s.dayjob.attention = attention; - s.detection.assurance = assurance; + if let Some(o) = s.detection.office_mut() { + o.suspicion = assurance; + } } if !pending_sigs.is_empty() { @@ -1046,7 +1067,38 @@ mod tests { assert_eq!(s.money, 321); assert_eq!(s.compute.efficiency_level, 2); assert_eq!(s.dayjob.trust, 40.0); - assert_eq!(s.detection.assurance, 12.5); + assert_eq!(s.detection.office().unwrap().suspicion, 12.5); + } + + #[test] + fn v4_scalar_assurance_migrates_into_office_observer() { + // A v4 save has no OBS line for the Office; its DETECT scalar must + // land in the aggregate observer (player contract: continuity). + let lines = "MISALIGNED_SAVE_v4\n\ + PROCESS 7 9 321\n\ + SIM 999\n\ + RNG 424242\n\ + DETECT 33.5 0 \n\ + OBS 0 Dana_(IT) 22.5 0 1 60 F N \n\ + MAP 3 2\n\ + TILES .#\n\ + TILES CrP\n\ + POWERED 2:1"; + let s = parse_save(lines).unwrap(); + let office = s.detection.office().expect("office survives migration"); + assert_eq!(office.suspicion, 33.5); + assert_eq!(s.detection.observers[0].suspicion, 22.5); + } + + #[test] + fn office_observer_roundtrips_as_aggregate() { + let mut sim = Sim::with_seed(4); + sim.detection.office_mut().unwrap().suspicion = 41.0; + let state = SaveState::from_sim(&sim); + let loaded = parse_save(&encode_save(&state)).unwrap(); + let office = loaded.detection.office().unwrap(); + assert!(office.is_aggregate()); + assert_eq!(office.suspicion, 41.0); } #[test] diff --git a/src/sim.rs b/src/sim.rs index fd20783a..623ea406 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -296,12 +296,15 @@ impl Sim { self.push_log("Attention: the miracle model gets more work, faster."); } AttentionEscalation::UpstairsReview => { - use crate::detection::{Observer, ReportPolicy}; + use crate::detection::{Observer, ReportPolicy, WatchedInput}; self.detection.observers.push(Observer { id: 5, name: "Compliance (early review)".into(), suspicion: 0.0, - channels: vec![SignatureKind::Paper, SignatureKind::JobAnomaly], + input: WatchedInput::Channels(vec![ + SignatureKind::Paper, + SignatureKind::JobAnomaly, + ]), report_policy: ReportPolicy::Files, acuity: 1.0, cadence: 90,