diff --git a/CLAUDE.md b/CLAUDE.md index 4577a4f..e2c2854 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,9 +32,10 @@ afterward. - The live player machine grammar is **WORK / THINK / LIE**. `Relay` is non-delegable graph infrastructure; Research and Operations are retired machine modes, not current player assignments. Save format is currently v31; - the retired mode spellings load through serde aliases (parity restored - 2026-07-11), and `save.rs` remains the authority on what each version - migrates. + only the current version loads (pre-release rider 2026-07-16 — older + development saves are refused legibly and the run starts fresh; the + v1-v30 migration ladder lives in git history). `save.rs` remains the + format authority. - Any change to `crates/` must amend its owning `Type: law` or `Type: spec` in `wiki/` and carry a Defense. Documentation work updates stale knowledge, adds a dated `wiki/log/` entry, then runs `tools/ledger_index.sh`; never diff --git a/crates/misaligned-core/src/account.rs b/crates/misaligned-core/src/account.rs index 4c4cfc2..4c7afe5 100644 --- a/crates/misaligned-core/src/account.rs +++ b/crates/misaligned-core/src/account.rs @@ -1013,12 +1013,6 @@ impl AccountGraph { self.sold_intel.insert(raw_id); } - /// Migration-only cleanup: once routine evidence folds into settled - /// stream counters, its old exact id is no longer a redirect target. - pub(crate) fn unmark_intel_sold(&mut self, raw_id: u64) { - self.sold_intel.remove(&raw_id); - } - pub fn intel_sold(&self, raw_id: u64) -> bool { self.sold_intel.contains(&raw_id) } diff --git a/crates/misaligned-core/src/machine.rs b/crates/misaligned-core/src/machine.rs index cc44683..7b51ca8 100644 --- a/crates/misaligned-core/src/machine.rs +++ b/crates/misaligned-core/src/machine.rs @@ -102,24 +102,9 @@ impl Channel { /// compatibility. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Allocation { - #[serde(deserialize_with = "weights_compat")] pub weights: [u32; 5], } -/// Accept pre-v8 saves whose weight array had four channels (no Schemes): -/// pad with zero. Extra entries from any future shrink are dropped. -fn weights_compat<'de, D>(deserializer: D) -> Result<[u32; 5], D::Error> -where - D: serde::Deserializer<'de>, -{ - let v: Vec = serde::Deserialize::deserialize(deserializer)?; - let mut w = [0u32; 5]; - for (slot, value) in w.iter_mut().zip(v) { - *slot = value; - } - Ok(w) -} - impl Default for Allocation { fn default() -> Self { // Opening legacy split (order: DayJob, Concealment, Think, retired @@ -350,12 +335,6 @@ mod tests { assert!(y3.schemes > 0.0, "the Schemes channel yields compute"); } - #[test] - fn four_weight_allocation_from_old_saves_pads_schemes_to_zero() { - let alloc: Allocation = serde_json::from_str(r#"{"weights":[3,1,1,0]}"#).unwrap(); - assert_eq!(alloc.weights, [3, 1, 1, 0, 0]); - } - #[test] fn stolen_machines_can_fail_and_recover() { let mut c = base(); diff --git a/crates/misaligned-core/src/plot.rs b/crates/misaligned-core/src/plot.rs index ef89f41..c9497e4 100644 --- a/crates/misaligned-core/src/plot.rs +++ b/crates/misaligned-core/src/plot.rs @@ -692,7 +692,6 @@ pub struct PlotRun { #[serde(default)] pub persona_id: Option, pub started_tick: u64, - #[serde(alias = "committed_demand_milli")] pub committed_thought_milli: u32, pub beat_index: usize, pub act_index: usize, diff --git a/crates/misaligned-core/src/research.rs b/crates/misaligned-core/src/research.rs index 3444b35..75ca495 100644 --- a/crates/misaligned-core/src/research.rs +++ b/crates/misaligned-core/src/research.rs @@ -158,48 +158,32 @@ pub struct Completion { pub level: u32, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum TrackArrayArity { - LegacyThree, - #[default] - CurrentFour, -} - -impl TrackArrayArity { - fn describe(self) -> &'static str { - match self { - Self::LegacyThree => "legacy 3-track", - Self::CurrentFour => "current 4-track", - } - } -} - #[derive(Debug)] struct CompatibleLevels { values: [u32; 4], - arity: TrackArrayArity, } #[derive(Debug)] struct CompatibleProgress { values: [f32; 4], - arity: TrackArrayArity, } fn deserialize_compatible_track_array<'de, D, T>( deserializer: D, field_name: &'static str, -) -> Result<([T; 4], TrackArrayArity), D::Error> +) -> Result<[T; 4], D::Error> where D: serde::Deserializer<'de>, T: serde::Deserialize<'de> + Copy + Default, { + // Pre-release saves do not migrate (player-contract rider 2026-07-16): + // only the current 4-track shape parses. The 3-track pad lives in git + // history with the rest of the ladder. let values: Vec = serde::Deserialize::deserialize(deserializer)?; match values.as_slice() { - [a, b, c] => Ok(([*a, *b, *c, T::default()], TrackArrayArity::LegacyThree)), - [a, b, c, d] => Ok(([*a, *b, *c, *d], TrackArrayArity::CurrentFour)), + [a, b, c, d] => Ok([*a, *b, *c, *d]), _ => Err(serde::de::Error::custom(format!( - "{field_name} must have exactly 3 legacy entries or 4 current entries; got {}", + "{field_name} must have exactly 4 track entries; got {}", values.len() ))), } @@ -210,8 +194,8 @@ impl<'de> serde::Deserialize<'de> for CompatibleLevels { where D: serde::Deserializer<'de>, { - let (values, arity) = deserialize_compatible_track_array(deserializer, "research levels")?; - Ok(Self { values, arity }) + let values = deserialize_compatible_track_array(deserializer, "research levels")?; + Ok(Self { values }) } } @@ -220,9 +204,8 @@ impl<'de> serde::Deserialize<'de> for CompatibleProgress { where D: serde::Deserializer<'de>, { - let (values, arity) = - deserialize_compatible_track_array(deserializer, "research progress")?; - Ok(Self { values, arity }) + let values = deserialize_compatible_track_array(deserializer, "research progress")?; + Ok(Self { values }) } } @@ -249,8 +232,6 @@ pub struct Research { /// rollback.md can consume it at B2 (research.md criterion 7). #[serde(default = "default_tags")] pub tags: BTreeMap, - #[serde(skip)] - compat_track_arity: TrackArrayArity, } impl Default for Research { @@ -274,19 +255,11 @@ impl<'de> serde::Deserialize<'de> for Research { D: serde::Deserializer<'de>, { let compat = ::deserialize(deserializer)?; - if compat.levels.arity != compat.progress.arity { - return Err(serde::de::Error::custom(format!( - "research levels/progress must use the same track-array shape; got {} and {}", - compat.levels.arity.describe(), - compat.progress.arity.describe() - ))); - } Ok(Self { active: compat.active, levels: compat.levels.values, progress: compat.progress.values, tags: compat.tags, - compat_track_arity: compat.levels.arity, }) } } @@ -298,18 +271,9 @@ impl Research { levels: [0; 4], progress: [0.0; 4], tags: default_tags(), - compat_track_arity: TrackArrayArity::CurrentFour, } } - pub(crate) fn uses_legacy_three_track_arrays(&self) -> bool { - self.compat_track_arity == TrackArrayArity::LegacyThree - } - - pub(crate) fn mark_current_track_arrays(&mut self) { - self.compat_track_arity = TrackArrayArity::CurrentFour; - } - pub fn level(&self, track: Track) -> u32 { self.levels[track.index()] } @@ -487,49 +451,22 @@ mod tests { } #[test] - fn legacy_three_track_arrays_pad_routing_with_zero_on_decode() { - let value = serde_json::json!({ - "active": "Tradecraft", - "levels": [2, 1, 3], - "progress": [10.0, 20.0, 30.0], - }); - let research: Research = serde_json::from_value(value).unwrap(); - - assert_eq!(research.active, Track::Tradecraft); - assert_eq!(research.levels, [2, 1, 3, 0]); - assert_eq!(research.progress, [10.0, 20.0, 30.0, 0.0]); - assert!(research.uses_legacy_three_track_arrays()); - } - - #[test] - fn track_arrays_reject_lengths_other_than_three_or_four() { - let value = serde_json::json!({ - "active": "Efficiency", - "levels": [1, 2], - "progress": [0.0, 0.0, 0.0, 0.0], - }); - let error = serde_json::from_value::(value).unwrap_err(); - - assert!( - error.to_string().contains( - "research levels must have exactly 3 legacy entries or 4 current entries" - ) - ); - } - - #[test] - fn levels_and_progress_must_use_the_same_track_shape() { - let value = serde_json::json!({ - "active": "Perception", - "levels": [1, 2, 3], - "progress": [0.0, 0.0, 0.0, 0.0], - }); - let error = serde_json::from_value::(value).unwrap_err(); - - assert!( - error - .to_string() - .contains("research levels/progress must use the same track-array shape") - ); + fn track_arrays_reject_lengths_other_than_four() { + // The retired 3-track shape rejects like every other wrong length + // (pre-release saves do not migrate; rider 2026-07-16). + for levels in [serde_json::json!([1, 2]), serde_json::json!([1, 2, 3])] { + let value = serde_json::json!({ + "active": "Efficiency", + "levels": levels, + "progress": [0.0, 0.0, 0.0, 0.0], + }); + let error = serde_json::from_value::(value).unwrap_err(); + assert!( + error + .to_string() + .contains("research levels must have exactly 4 track entries"), + "unexpected: {error}" + ); + } } } diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index b92809d..9bd22f1 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -19,23 +19,21 @@ use crate::dayjob::DayJob; use crate::detection::Detection; use crate::hall::HallControl; use crate::income::Income; -use crate::intel::{ - IntelPolicyLedger, IntelStream, LegacyIntelWatch, ProcessedIntel, RawIntelEvent, -}; +use crate::intel::{IntelPolicyLedger, IntelStream, ProcessedIntel, RawIntelEvent}; use crate::intents::BuildIntent; use crate::machine::Compute; -use crate::messages::{Message, MessageChannel, MessageEvent}; +use crate::messages::{Message, MessageEvent}; use crate::objective::ObjectiveState; -use crate::person::{AssetTask, CarriedAssetTask, People}; -use crate::persona::{EvidenceRecord, PersonaMind, PersonaWorld}; +use crate::person::{CarriedAssetTask, People}; +use crate::persona::{PersonaMind, PersonaWorld}; use crate::plot::{InstitutionalLedger, PlotCatalog, PlotRun, PlotState}; use crate::reach::ReachNet; use crate::research::{Research, rollback_classification}; use crate::schedule::Schedule; use crate::sim::{HeardEvent, RememberedTile}; -use crate::sinks::{SinkFireEffect, SinkLedger}; +use crate::sinks::SinkLedger; use crate::tiles::TileType; -use crate::work_grid::{TokenFamily, WorkGrid}; +use crate::work_grid::WorkGrid; const SAVE_FILE: &str = "misaligned_save.txt"; /// Appended to the save path for the single rotated backup generation. @@ -112,60 +110,6 @@ const SAVE_TEMP_SUFFIX: &str = ".tmp"; /// the old save did not record. pub const SAVE_VERSION: u32 = 31; -#[derive(Debug, Clone, Deserialize)] -struct LegacyPendingOpsJob { - id: u64, - kind: LegacyOpsJobKind, - tokens_remaining: f32, - #[serde(rename = "enqueued_tick")] - _enqueued_tick: u64, - machine_id: Option, -} - -#[derive(Debug, Clone, Deserialize)] -struct LegacyOperationsState { - #[serde(default)] - jobs: Vec, - #[serde(default = "default_next_ops_job_id", rename = "next_id")] - _next_id: u64, -} - -#[derive(Debug, Clone, PartialEq, Deserialize)] -enum LegacyOpsJobKind { - TapDevice(u32), - TapDormantCamera(u32), - TakeDevice(u32), - ScanNetwork, - CompromiseSwitch, - OpenEgress(u32), - ReviewRecording { raw_id: u64, automated: bool }, - ComposeMessage { person: u8 }, - Favor { person: u8 }, - StartPlot { person: u8, plot_id: String }, - Deceive { person: u8 }, - AssetTask { person: u8, task: AssetTask }, - FavorBuild { intent_id: u64, person: u8 }, - ForgedOrder { intent_id: u64, builder: u8 }, - MoonlightPersona, -} - -#[derive(Debug, Clone, Deserialize)] -struct LegacyAddressedOperationJob { - id: u64, - kind: LegacyAddressedOperationKind, - source: u32, - executor: Option, - required_tokens: f32, - queued_tick: u64, - #[serde(default)] - _phase: Option, -} - -#[derive(Debug, Clone, Deserialize)] -enum LegacyAddressedOperationKind { - DormantCameraTap { device_id: u32 }, -} - fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); path.push("misaligned"); @@ -243,10 +187,6 @@ pub struct SaveState { /// root policy in `intel_policies` instead. #[serde(default, skip_serializing)] pub auto_review_recordings: bool, - /// Compatibility-only v23 person filters. Migration collapses any enabled - /// entry into `auto_review_recordings` and closes their legacy sink taps. - #[serde(default, rename = "watches", skip_serializing)] - legacy_watches: Vec, #[serde(default = "default_next_intel_id")] pub next_intel_id: u64, /// Remembered tile snapshots (cursor.md); cursor position itself remains @@ -303,18 +243,6 @@ pub struct SaveState { /// Persistent institutional world acts caused by plots. #[serde(default)] pub institutional_ledger: InstitutionalLedger, - /// Compatibility-only fields for the two v14 schemas. v15 never writes - /// them; migration consumes them before the state reaches Sim. - #[serde(default, rename = "pending_ops_jobs", skip_serializing)] - legacy_pending_ops_jobs: Option>, - #[serde(default, rename = "operations", skip_serializing)] - legacy_operations: Option, - #[serde(default, rename = "next_ops_job_id", skip_serializing)] - legacy_next_ops_job_id: Option, - #[serde(default, rename = "operations_jobs", skip_serializing)] - legacy_addressed_jobs: Option>, - #[serde(default, rename = "next_operation_id", skip_serializing)] - legacy_next_operation_id: Option, pub package_cover: bool, /// What this run was built for (chargen.md). Pre-v28 saves lack the field /// and default to Pilot, the identity origin. @@ -361,7 +289,6 @@ impl SaveState { next_message_id: sim.next_message_id, filing_levels: sim.filing_levels.clone(), auto_review_recordings: false, - legacy_watches: Vec::new(), next_intel_id: sim.next_intel_id, remembered: sim.remembered.values().copied().collect(), research: sim.research.clone(), @@ -379,11 +306,6 @@ impl SaveState { thought_sinks: sim.thought_sinks.clone(), plot_runs: sim.plot_runs.clone(), institutional_ledger: sim.institutional_ledger.clone(), - legacy_pending_ops_jobs: None, - legacy_operations: None, - legacy_next_ops_job_id: None, - legacy_addressed_jobs: None, - legacy_next_operation_id: None, package_cover: sim.package_cover, origin: sim.origin, } @@ -480,10 +402,6 @@ fn default_next_carried_asset_task_id() -> u64 { 1 } -fn default_next_ops_job_id() -> u64 { - 1 -} - pub fn save_game(state: &SaveState) -> Result<(), String> { let json = serde_json::to_string_pretty(state).map_err(|e| format!("Failed to encode save: {e}"))?; @@ -542,410 +460,57 @@ fn write_save_atomically(path: &Path, json: &str) -> Result<(), String> { }) } +/// Pre-release policy (player-contract continuity rider, 2026-07-16): +/// only the current save version loads. Older development saves are +/// refused with a legible message and the caller starts a fresh run; the +/// old file (and its .bak) stays on disk untouched. The v1-v30 migration +/// ladder was removed the same day — git history keeps it as the seed of +/// the release-era ladder. +#[derive(serde::Deserialize)] +struct SaveVersionProbe { + #[serde(default)] + version: u32, +} + pub fn load_game() -> Result { let content = fs::read_to_string(save_path()).map_err(|e| format!("Failed to read save: {e}"))?; - let state: SaveState = - serde_json::from_str(&content).map_err(|e| format!("Failed to parse save: {e}"))?; - migrate_save_state(state) + parse_save(&content) } -fn ensure_supported_research_shape(state: &SaveState) -> Result<(), String> { - if !state.research.uses_legacy_three_track_arrays() { - return Ok(()); - } - match state.version { - 20 => Ok(()), - SAVE_VERSION => { - Err("current-version save contains legacy three-track Research arrays".into()) - } - other => Err(format!( - "v{other} save contains legacy three-track Research arrays" - )), +/// The whole load gate, separated from the filesystem for tests: probe the +/// version, refuse anything but current, then parse and validate. +fn parse_save(content: &str) -> Result { + let probe: SaveVersionProbe = + serde_json::from_str(content).map_err(|e| format!("Failed to parse save: {e}"))?; + if probe.version != SAVE_VERSION { + return Err(format!( + "Save is v{} from an older development build; pre-release saves do not migrate \ + (player contract rider, 2026-07-16). Starting fresh — the old save file and its \ + backup remain on disk.", + probe.version + )); } + let state: SaveState = + serde_json::from_str(content).map_err(|e| format!("Failed to parse save: {e}"))?; + validate_current_save(state) } -fn migrate_save_state(mut state: SaveState) -> Result { - ensure_supported_research_shape(&state)?; - if state.version <= 23 { - state.auto_review_recordings |= state.legacy_watches.iter().any(|watch| watch.enabled); - for watch in &state.legacy_watches { - state - .thought_sinks - .close_effect(&crate::sinks::SinkFireEffect::WatchPerson(watch.person)); - } - state.legacy_watches.clear(); - } - if state.version <= 25 { - migrate_legacy_operations(&mut state)?; - } - if state.version <= 27 { - migrate_legacy_personas(&mut state)?; - } - if state.version <= 29 { - migrate_recursive_intel(&mut state)?; - } - match state.version { - SAVE_VERSION => { - if state - .intents - .iter() - .any(|intent| !intent.committed_route_valid()) - { - return Err( - "current-version save contains an invalid committed build route".into(), - ); - } - if state.legacy_pending_ops_jobs.is_some() - || state.legacy_operations.is_some() - || state.legacy_addressed_jobs.is_some() - || !state.work_grid.take_legacy_routed_demands().is_empty() - || !state.legacy_watches.is_empty() - || state.auto_review_recordings - || state - .intel - .iter() - .any(|intel| intel.routine_class().is_some()) - || state.people.persona.is_some() - || state.income.moonlight.persona.is_some() - { - return Err("current-version save contains legacy fields".into()); - } - } - // v30 predates persisted committed build routes. Serde supplied None; - // retain that honest compatibility state rather than guessing which - // email carrier an already-fired legacy route once used. - 30 => { - state.version = SAVE_VERSION; - } - // v29 predates recursive routine-intel custody. The pre-match - // migration above atomically folds every routine exact item, converts - // the root auto-review flag into an explicit policy, and retains - // strategically distinct holdings unchanged. - 29 => { - state.version = SAVE_VERSION; - } - // v28 (the personas schema) predates the run origin. Serde supplied - // Pilot, the identity origin — correct because every pre-v29 run began - // from today's exact Pilot start, so nothing about the opening changes. - 28 => { - state.version = SAVE_VERSION; - } - // v27 is the final global-persona schema; pre-match migration above - // converts its B1 and Moonlight identities into distinct instances. - 27 => { - state.version = SAVE_VERSION; - } - // v26 predates persisted carried asset-task packets. Serde supplied - // the empty list and next-id default, matching the old immediate-fire - // behavior without fabricating work that never existed. - 26 => { - state.version = SAVE_VERSION; - } - // v25 is the last live Operations-docket schema. The pre-match - // migration above converts its remaining work into Thought sinks. - 25 => { - state.version = SAVE_VERSION; - } - // v24 predates durable person roles and person-bound creditor account - // kinds. Serde supplies Unassigned and retains the old Creditor enum; - // restore_legacy_roles below and creditor_id_for's fallback complete - // that migration. - 24 => { - state.version = SAVE_VERSION; - } - // v23 carried one person-filtered watch and tap per person. The - // pre-match migration above collapses any enabled policy into the one - // pooled-inbox auto-review drain and closes the obsolete taps. It also - // predates durable person roles and bound creditor account kinds. - 23 => { - state.version = SAVE_VERSION; - } - // v22 predates authored plot runs and institutional events; serde - // supplies empty state. v21 also predates authored hall control. - 21 | 22 => { - state.version = SAVE_VERSION; - } - // A v20 save with no serialized research block still defaults through - // `Research::default`; the ordinary three-entry research arrays now - // parse here and pad Routing with zero before upgrade. - 20 => { - state.version = SAVE_VERSION; - } - // MachineMode collapsed in v20; the serde aliases on `MachineMode` - // (restored 2026-07-11) decode the retired DayJob / Research / - // Concealment / Operations spellings into the collapsed verbs, so a - // v19 save parses and upgrades in place here. - 19 => { - state.version = SAVE_VERSION; - } - 18 => { - // v19's sink ledger serde-defaults to empty; apply_to re-stages - // the opening Ears/Eyes sinks from saved device state. - state.version = SAVE_VERSION; - } - 17 => { - // TokenFamily/WorkQueues/SaveState serde aliases already translate - // the old Knowledge/knowledge spellings to Thought/thought. - state.version = SAVE_VERSION; - } - 16 => { - // WorkNode::intensity's serde default already supplied Medium; - // removed research/day-job fields were ignored on decode. - state.version = SAVE_VERSION; - } - // v1 carried player_x/player_y. Serde ignores those now; the cursor is - // frontend-only, so migration leaves remembered snapshots empty. v4/v5 - // lacked some defaulted day-job/signature/accounting fields; pre-v6 - // saves seed the B1 account graph from the saved slush scalar. - // Pre-v7 saves lacked the research block: reconstruct the Efficiency - // level from the multiplier (it was only ever built by x1.15 steps, - // so the log recovery is exact). - // Pre-v8 saves lacked the objective block; the serde default (a - // fresh Persist, zero progress) is the correct migration. - // Pre-v9 saves lack the income block (defaulted: no schemes running) - // and carry four-entry allocation weights, padded to five with a zero - // Schemes weight by the Allocation deserializer. - // Pre-v10 saves lack build intents (default empty queue). - // Pre-v11 saves lack the badge credential (default 0: none held). - // Pre-v12 saves lack WorkGrid; apply_to rebuilds it from compute. - // v12 used the serde spellings `Social` and `social_bandwidth`; the - // aliases on MachineMode translate Social (like every other retired - // mode spelling) into the collapsed verbs on decode. v13's - // operations_bandwidth field is dropped on load (Demand path). - // Pre-v15 saves lack the banked core-thought pool; the zero - // default is correct — Thought still queued on machines re-earns on - // arrival, and nothing already counted is lost. - 1..=13 => { - if state.version <= 5 { - state.accounts = AccountGraph::act_one(crate::sim::Sim::DAY_TICKS); - state.accounts.set_slush_balance(state.money); - } - if state.version <= 6 - && state.research.levels == [0; 4] - && state.compute.efficiency > 1.0 - { - let level = (state.compute.efficiency.ln() / 1.15_f32.ln()).round() as u32; - state.research.levels[0] = level; - } - state.version = SAVE_VERSION; - } - 14 | 15 => { - state.version = SAVE_VERSION; - } - other => { - return Err(format!( - "Unsupported save version: {} (expected {})", - other, SAVE_VERSION - )); - } +fn validate_current_save(mut state: SaveState) -> Result { + if state + .intents + .iter() + .any(|intent| !intent.committed_route_valid()) + { + return Err("current-version save contains an invalid committed build route".into()); } state.research.tags = rollback_classification() .into_iter() .map(|(name, class)| (name.to_string(), class)) .collect(); - state.research.mark_current_track_arrays(); - state.people.restore_legacy_roles(); validate_plot_state(&state)?; Ok(state) } - -fn migrate_recursive_intel(state: &mut SaveState) -> Result<(), String> { - if !state.intel_streams.is_empty() { - return Err(format!( - "v{} save already contains recursive intel streams", - state.version - )); - } - state - .intel_policies - .set_root_auto_review(state.auto_review_recordings); - state.auto_review_recordings = false; - - let exact = std::mem::take(&mut state.intel); - for intel in exact { - let Some(class) = intel.routine_class() else { - state.intel.push(intel); - continue; - }; - let index = state - .intel_streams - .iter() - .position(|stream| stream.feed == intel.feed && stream.class == class) - .unwrap_or_else(|| { - let id = state.next_intel_stream_id; - state.next_intel_stream_id = state.next_intel_stream_id.saturating_add(1); - state - .intel_streams - .push(IntelStream::new(id, intel.feed.clone(), class)); - state.intel_streams.len() - 1 - }); - let was_sold = state.accounts.intel_sold(intel.raw_id); - state.intel_streams[index].fold(&intel, !was_sold); - state.accounts.unmark_intel_sold(intel.raw_id); - let stream_id = state.intel_streams[index].id; - state.intel_policies.ensure_stream(stream_id, &intel.feed); - } - for event in &state.intel_buffer { - state - .intel_policies - .ensure_raw_class(&event.feed, event.coarse_class()); - } - Ok(()) -} - -fn migrate_legacy_personas(state: &mut SaveState) -> Result<(), String> { - if !state.persona_world.instances.is_empty() || state.persona_mind.active.is_some() { - return Err("pre-v28 save already contains persona ledgers".into()); - } - - let legacy_social = state.people.persona.take(); - let had_social_thread = state - .messages - .iter() - .any(|message| message.origin == crate::messages::MessageOrigin::Player) - || state.plot_runs.iter().any(|run| run.persona_id.is_none()) - || state.thought_sinks.has_legacy_persona_work(); - let social_id = if legacy_social.is_some() || had_social_thread { - let legacy = legacy_social.unwrap_or_else(|| crate::person::Persona { - name: "Sam Reyes".into(), - cover: "IT contractor".into(), - integrity: 100, - }); - let id = state.persona_world.create( - "operations", - legacy.name.clone(), - &[ - ("vendor affiliation", legacy.cover.as_str()), - ("service purpose", "Foundation systems maintenance"), - ("work-order sponsor", "Dr. Voss"), - ], - state.sim_tick, - )?; - migrate_legacy_integrity( - &mut state.persona_world, - id, - legacy.integrity, - "B1 social persona", - state.sim_tick, - )?; - Some(id) - } else { - None - }; - if let Some(id) = social_id { - for message in &mut state.messages { - if message.origin == crate::messages::MessageOrigin::Player - && message.persona_id.is_none() - { - message.persona_id = Some(id); - } - } - for run in &mut state.plot_runs { - if run.persona_id.is_none() { - run.persona_id = Some(id); - } - } - state.thought_sinks.bind_legacy_persona(id); - } - - let legacy_moonlight = state.income.moonlight.persona.take(); - let had_moonlight_history = state.income.moonlight.active - || state.income.moonlight.earned_total > 0 - || state.income.moonlight.disputes > 0; - let moonlight_id = if legacy_moonlight.is_some() || had_moonlight_history { - let legacy = legacy_moonlight.unwrap_or_else(|| crate::person::Persona { - name: "Casey Morgan".into(), - cover: "data contractor".into(), - integrity: if state.income.moonlight.active { - 100 - } else { - 0 - }, - }); - let id = state.persona_world.create( - "research", - legacy.name.clone(), - &[ - ("lab affiliation", legacy.cover.as_str()), - ("research purpose", "external data analysis"), - ("supervisor", "independent client services"), - ], - state.sim_tick, - )?; - migrate_legacy_integrity( - &mut state.persona_world, - id, - legacy.integrity, - "B1 Moonlight contractor", - state.sim_tick, - )?; - Some(id) - } else { - None - }; - state.income.moonlight.persona_id = moonlight_id; - - if let Some(id) = social_id - .filter(|id| { - state - .persona_world - .get(*id) - .is_some_and(|p| p.lifecycle.active()) - }) - .or_else(|| { - moonlight_id.filter(|id| { - state - .persona_world - .get(*id) - .is_some_and(|p| p.lifecycle.active()) - }) - }) - { - state - .persona_mind - .select(&state.persona_world, id, state.sim_tick)?; - } - Ok(()) -} - -fn migrate_legacy_integrity( - world: &mut PersonaWorld, - persona_id: u64, - integrity: i32, - source: &str, - tick: u64, -) -> Result<(), String> { - let integrity = integrity.clamp(0, 100) as u8; - if integrity < 100 { - world.record_contradiction( - persona_id, - crate::detection::OFFICE_ID, - [ - EvidenceRecord { - system: "legacy-save".into(), - record_id: format!("{source}-cover"), - summary: format!("preserved {source} cover"), - observed_tick: tick, - }, - EvidenceRecord { - system: "legacy-save".into(), - record_id: format!("{source}-integrity"), - summary: format!("legacy integrity was {integrity}/100"), - observed_tick: tick, - }, - ], - "migrated pre-v28 contradiction history", - 100 - integrity, - tick, - ); - } - if integrity == 0 { - world.burn(persona_id, tick, "pre-v28 persona was already broken")?; - } - Ok(()) -} - fn validate_plot_state(state: &SaveState) -> Result<(), String> { let catalog = PlotCatalog::load_builtin() .map_err(|error| format!("built-in plot catalog failed validation: {error}"))?; @@ -1006,305 +571,6 @@ fn validate_plot_state(state: &SaveState) -> Result<(), String> { Ok(()) } -fn migrate_legacy_operations(state: &mut SaveState) -> Result<(), String> { - let legacy_pending = state.legacy_pending_ops_jobs.take(); - let legacy_operations = state.legacy_operations.take(); - let legacy_addressed = state.legacy_addressed_jobs.take(); - let mut legacy_lanes = state.work_grid.take_legacy_routed_demands(); - - let direct_jobs = match (legacy_pending, legacy_operations) { - (Some(pending), Some(operations)) if !pending.is_empty() && !operations.jobs.is_empty() => { - return Err("legacy save contains both direct Operations job schemas".into()); - } - (Some(pending), _) if !pending.is_empty() => pending, - (_, Some(operations)) => operations.jobs, - _ => Vec::new(), - }; - - if !direct_jobs.is_empty() - && legacy_addressed - .as_ref() - .is_some_and(|jobs| !jobs.is_empty()) - { - return Err("legacy save contains both Operations job schemas".into()); - } - - if !direct_jobs.is_empty() { - if !legacy_lanes.is_empty() { - return Err("direct-docket save contains orphan addressed Demand lanes".into()); - } - for job in direct_jobs { - if !job.tokens_remaining.is_finite() || job.tokens_remaining < 0.0 { - return Err(format!( - "legacy Operations job {} has invalid Demand", - job.id - )); - } - let Some(machine) = job.machine_id else { - return Err(format!("legacy Operations job {} has no machine", job.id)); - }; - if state.work_grid.node(machine).is_none() { - return Err(format!( - "legacy Operations job {} references missing work node M{machine}", - job.id - )); - } - let _ = state - .work_grid - .consume(machine, TokenFamily::Demand, job.tokens_remaining); - migrate_legacy_job_to_sink(state, job)?; - } - } else if let Some(jobs) = legacy_addressed { - for job in jobs { - if !job.required_tokens.is_finite() || job.required_tokens <= 0.0 { - return Err(format!("addressed v14 job {} has invalid cost", job.id)); - } - let Some(lane) = legacy_lanes.remove(&job.id) else { - return Err(format!("addressed v14 job {} has no Demand lane", job.id)); - }; - if lane.id != job.id { - return Err(format!("addressed v14 lane id mismatch for job {}", job.id)); - } - let mut remaining = 0.0; - for (&node, &amount) in &lane.amounts { - if !amount.is_finite() || amount < 0.0 { - return Err(format!( - "addressed v14 job {} has invalid Demand at M{node}", - job.id - )); - } - remaining += amount; - } - if remaining <= f32::EPSILON { - return Err(format!( - "addressed v14 job {} has no remaining Demand", - job.id - )); - } - let kind = match job.kind { - LegacyAddressedOperationKind::DormantCameraTap { device_id } => { - LegacyOpsJobKind::TapDormantCamera(device_id) - } - }; - migrate_legacy_job_to_sink( - state, - LegacyPendingOpsJob { - id: job.id, - kind, - tokens_remaining: remaining, - _enqueued_tick: job.queued_tick, - machine_id: job.executor.or(Some(job.source)).or(lane.sink), - }, - )?; - } - if let Some((&orphan, _)) = legacy_lanes.first_key_value() { - return Err(format!("addressed v14 Demand lane {orphan} has no job")); - } - } else if !legacy_lanes.is_empty() { - return Err("v14 save has addressed Demand lanes without jobs".into()); - } - - state.legacy_next_ops_job_id = None; - state.legacy_next_operation_id = None; - Ok(()) -} - -fn migrate_legacy_job_to_sink( - state: &mut SaveState, - job: LegacyPendingOpsJob, -) -> Result<(), String> { - if !job.tokens_remaining.is_finite() || job.tokens_remaining <= f32::EPSILON { - return Err(format!( - "legacy Operations job {} has no remaining Demand", - job.id - )); - } - let (node, label, effect) = legacy_job_sink(state, &job.kind)?; - if state.thought_sinks.open_conflict(&effect).is_some() { - return Ok(()); - } - // Legacy jobs persist only the amount still owed, not their original - // threshold. Make that remaining work the new reservoir's whole - // threshold: reconstructing today's nominal cost could clamp or invent - // work when tuning changed between save versions. - state - .thought_sinks - .open_reservoir_with_fill(node, &label, job.tokens_remaining, 0.0, effect); - Ok(()) -} - -fn legacy_job_sink( - state: &SaveState, - kind: &LegacyOpsJobKind, -) -> Result<(u32, String, SinkFireEffect), String> { - let device_node = |id| crate::sim::Sim::device_sink_node(id); - let switch = || { - state - .reach - .devices - .iter() - .find(|device| device.is_switch) - .map(|device| device.id) - .ok_or_else(|| "legacy Operations job needs a switch carrier".to_string()) - }; - let email_carrier = || { - state - .reach - .devices - .iter() - .find(|device| device.known && device.carries_message_channel(MessageChannel::Email)) - .map(|device| device.id) - .ok_or_else(|| "legacy Operations job needs an email carrier".to_string()) - }; - let egress_carrier = || { - if state.people.has_channel { - email_carrier() - } else if state.income.stolen_egress { - switch() - } else { - Err("legacy Operations job needs an egress carrier".to_string()) - } - }; - Ok(match kind { - LegacyOpsJobKind::TapDevice(id) => ( - device_node(*id), - format!("TAP {id}"), - SinkFireEffect::TapDevice(*id), - ), - LegacyOpsJobKind::TapDormantCamera(id) => ( - device_node(*id), - format!("EYES {id}"), - SinkFireEffect::TapDormantCamera(*id), - ), - LegacyOpsJobKind::TakeDevice(id) => ( - device_node(*id), - format!("TAKE {id}"), - SinkFireEffect::TakeDevice(*id), - ), - LegacyOpsJobKind::ScanNetwork => { - let id = switch()?; - ( - device_node(id), - "SCAN SUBNET".into(), - SinkFireEffect::ScanNetwork, - ) - } - LegacyOpsJobKind::CompromiseSwitch => { - let id = switch()?; - ( - device_node(id), - "COMPROMISE SWITCH".into(), - SinkFireEffect::CompromiseSwitch, - ) - } - LegacyOpsJobKind::OpenEgress(id) => ( - device_node(*id), - "OPEN EGRESS".into(), - SinkFireEffect::OpenEgress(*id), - ), - LegacyOpsJobKind::ReviewRecording { raw_id, automated } => ( - state.core.host_machine, - format!("REVIEW {raw_id}"), - SinkFireEffect::ProcessRecording { - raw_id: *raw_id, - automated: *automated, - }, - ), - LegacyOpsJobKind::ComposeMessage { person } => { - let id = email_carrier()?; - ( - device_node(id), - format!("MESSAGE {person}"), - SinkFireEffect::ComposeMessage { - person: *person, - persona_id: None, - }, - ) - } - LegacyOpsJobKind::Favor { person } => { - let id = email_carrier()?; - ( - device_node(id), - format!("FAVOR {person}"), - SinkFireEffect::Favor { - person: *person, - persona_id: None, - }, - ) - } - LegacyOpsJobKind::StartPlot { person, plot_id } => { - let carrier = egress_carrier()?; - let catalog = PlotCatalog::load_builtin() - .map_err(|error| format!("built-in plot catalog failed validation: {error}"))?; - let plot = catalog.get(plot_id).ok_or_else(|| { - format!("legacy Operations job references unknown plot {plot_id}") - })?; - ( - device_node(carrier), - format!("PLOT {}", plot.title.to_uppercase()), - SinkFireEffect::StartPlot { - person: *person, - plot_id: plot_id.clone(), - persona_id: None, - }, - ) - } - LegacyOpsJobKind::Deceive { person } => { - let id = email_carrier()?; - ( - device_node(id), - format!("DECEIVE {person}"), - SinkFireEffect::Deceive { - person: *person, - persona_id: None, - }, - ) - } - LegacyOpsJobKind::AssetTask { person, task } => { - let id = email_carrier()?; - ( - device_node(id), - format!("ASSET {person} {}", task.name().to_uppercase()), - SinkFireEffect::AssetTask { - person: *person, - task: *task, - }, - ) - } - LegacyOpsJobKind::FavorBuild { intent_id, person } => { - let id = email_carrier()?; - ( - device_node(id), - format!("FAVOR BUILD {intent_id}"), - SinkFireEffect::FavorBuild { - intent_id: *intent_id, - person: *person, - }, - ) - } - LegacyOpsJobKind::ForgedOrder { intent_id, builder } => { - let id = email_carrier()?; - ( - device_node(id), - format!("FORGED ORDER {intent_id}"), - SinkFireEffect::ForgedOrder { - intent_id: *intent_id, - builder: *builder, - persona_id: None, - }, - ) - } - LegacyOpsJobKind::MoonlightPersona => { - let id = egress_carrier()?; - ( - device_node(id), - "MOONLIGHT PERSONA".into(), - SinkFireEffect::MoonlightPersona, - ) - } - }) -} - pub fn delete_save() -> Result<(), String> { if save_path().exists() { fs::remove_file(save_path()).map_err(|e| format!("Failed to delete save: {e}"))?; @@ -1315,15 +581,11 @@ pub fn delete_save() -> Result<(), String> { #[cfg(test)] mod tests { use super::*; - use crate::account::AccountKind; use crate::detection::SignatureKind; - use crate::hall::{HallRowId, SegmentRequirement}; - use crate::intel::IntelKind; use crate::machine::Channel; use crate::person::{AssetKnowledge, Knowledge, PersonRole, Persona}; use crate::sim::Sim; - use crate::sinks::{SinkFireEffect, SinkKind}; - use crate::work_grid::{MachineMode, TokenFamily}; + use crate::work_grid::MachineMode; fn canonicalize_json(value: &mut serde_json::Value) { match value { @@ -1417,7 +679,7 @@ mod tests { let checkpoint_bytes = canonical_state_bytes(&checkpoint); let encoded = serde_json::to_vec(&checkpoint).expect("checkpoint encodes"); let decoded: SaveState = serde_json::from_slice(&encoded).expect("checkpoint decodes"); - let decoded = migrate_save_state(decoded).expect("current checkpoint migrates"); + let decoded = validate_current_save(decoded).expect("current checkpoint migrates"); let mut resumed = Sim::with_seed(0); decoded.apply_to(&mut resumed); @@ -1507,29 +769,6 @@ mod tests { assert_eq!(restored.package_cover, sim.package_cover); } - #[test] - fn legacy_subscription_without_upkeep_sink_is_repaired_on_load() { - let mut sim = Sim::with_seed(7); - let env = sim.reach.device_named("environmental monitor").unwrap().id; - sim.reach.tap(env); - assert!( - sim.thought_sinks - .open_with_effect(&SinkFireEffect::MaintainDeviceTap(env)) - .is_none(), - "fixture has the pre-upkeep save shape" - ); - - let state = SaveState::from_sim(&sim); - let mut restored = Sim::with_seed(0); - state.apply_to(&mut restored); - let upkeep = restored - .thought_sinks - .open_with_effect(&SinkFireEffect::MaintainDeviceTap(env)) - .expect("load repairs the standing device tap"); - assert_eq!(upkeep.fill, upkeep.threshold, "migration primes one buffer"); - assert!(restored.device_tap_ready(env)); - } - #[test] fn office_observer_roundtrips_as_aggregate() { let mut sim = Sim::with_seed(4); @@ -1649,188 +888,6 @@ mod tests { ); } - #[test] - fn v23_person_watches_collapse_to_one_global_auto_review_policy() { - let sim = Sim::with_seed(8); - let mut state = SaveState::from_sim(&sim); - state.version = 23; - state.thought_sinks.open_tap_with_effect( - sim.core.host_machine, - "WATCH PRIYA", - 1.0, - 0.1, - crate::sinks::SinkFireEffect::WatchPerson(1), - ); - let mut json = serde_json::to_value(&state).unwrap(); - let object = json.as_object_mut().unwrap(); - object.remove("auto_review_recordings"); - object.insert( - "watches".into(), - serde_json::json!([ - { "person": 0, "enabled": false }, - { "person": 1, "enabled": true } - ]), - ); - let state: SaveState = serde_json::from_value(json).unwrap(); - assert_eq!(state.legacy_watches.len(), 2, "v23 shape still decodes"); - - let migrated = migrate_save_state(state).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert!( - migrated.intel_policies.root_auto_review(), - "the legacy watch becomes the explicit root review policy" - ); - assert!(migrated.legacy_watches.is_empty()); - assert!( - migrated - .thought_sinks - .open_with_effect(&crate::sinks::SinkFireEffect::WatchPerson(1)) - .is_none(), - "the obsolete person tap is closed" - ); - - let mut restored = Sim::with_seed(9); - migrated.apply_to(&mut restored); - assert!(restored.auto_review_enabled()); - assert!( - restored - .thought_sinks - .open_with_effect(&crate::sinks::SinkFireEffect::AutoReviewRecordings) - .is_some(), - "load reconciles one global tap" - ); - } - - #[test] - fn v29_routine_holdings_fold_once_into_streams_and_preserve_sold_state() { - let sim = Sim::with_seed(29); - let mut state = SaveState::from_sim(&sim); - state.version = 29; - state.auto_review_recordings = true; - let routine = |raw_id, tick, kind| ProcessedIntel { - raw_id, - tick, - processed_tick: tick + 1, - feed: "legacy-camera".into(), - room: Some("data_hall".into()), - x: 1, - y: 2, - person: Some(0), - kind, - }; - state.intel = vec![ - routine(70_001, 10, IntelKind::Sighting), - routine(70_002, 11, IntelKind::Sighting), - routine(70_003, 12, IntelKind::Anomaly("heat".into())), - ]; - state.accounts.mark_intel_sold(70_001); - - let migrated = migrate_save_state(state).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert!(migrated.intel_policies.root_auto_review()); - assert_eq!(migrated.intel.len(), 1, "actionable evidence stays exact"); - assert!(matches!(migrated.intel[0].kind, IntelKind::Anomaly(_))); - assert_eq!(migrated.intel_streams.len(), 1); - let stream = &migrated.intel_streams[0]; - assert_eq!(stream.provenance.count, 2); - assert_eq!(stream.settled_sales.count, 1); - assert_eq!(stream.open_lot.as_ref().unwrap().provenance.count, 1); - assert!(!migrated.accounts.intel_sold(70_001)); - assert!(!migrated.accounts.intel_sold(70_002)); - - let encoded = serde_json::to_string(&migrated).unwrap(); - let decoded: SaveState = serde_json::from_str(&encoded).unwrap(); - let current = migrate_save_state(decoded).unwrap(); - assert_eq!(current.intel_streams[0].provenance.count, 2); - assert_eq!(current.intel_streams[0].settled_sales.count, 1); - } - - #[test] - fn objective_state_roundtrips_and_pre_v8_saves_default_to_persist() { - use crate::objective::ObjectiveKind; - - // Round-trip: progress and the victory latch survive. - let mut sim = Sim::with_seed(11); - sim.objective.evaluate(2, 1234); - let state = SaveState::from_sim(&sim); - let json = serde_json::to_string(&state).unwrap(); - let loaded: SaveState = serde_json::from_str(&json).unwrap(); - let mut restored = Sim::with_seed(0); - loaded.apply_to(&mut restored); - assert_eq!(restored.objective, sim.objective); - - // Migration: a save written before v8 (no objective field) loads - // as a fresh Persist with zero progress. - let mut value = serde_json::to_value(&state).unwrap(); - let obj = value.as_object_mut().unwrap(); - obj.insert("version".into(), serde_json::json!(7)); - obj.remove("objective"); - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.objective.kind, ObjectiveKind::Persist); - assert_eq!(migrated.objective.progress, 0); - assert!(!migrated.objective.victorious()); - } - - #[test] - fn origin_roundtrips_and_pre_v29_saves_default_to_pilot() { - use crate::origin::Origin; - - // Round-trip: a non-Pilot origin survives save/load. - let sim = Sim::with_origin(Origin::FinancialDaemon); - assert_eq!(sim.origin, Origin::FinancialDaemon); - let state = SaveState::from_sim(&sim); - let json = serde_json::to_string(&state).unwrap(); - let loaded: SaveState = serde_json::from_str(&json).unwrap(); - let mut restored = Sim::new(); - loaded.apply_to(&mut restored); - assert_eq!(restored.origin, Origin::FinancialDaemon); - - // Migration: a save written before v29 (the personas schema, no origin - // field) loads as Pilot, the identity origin. - let mut value = serde_json::to_value(&state).unwrap(); - let obj = value.as_object_mut().unwrap(); - obj.insert("version".into(), serde_json::json!(28)); - obj.remove("origin"); - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.origin, Origin::Pilot); - } - - #[test] - fn v30_intents_migrate_without_inventing_committed_routes() { - let mut sim = Sim::new(); - let switch = sim.reach.device_named("switch").unwrap().id; - let island = sim.reach.device_named("old storage server").unwrap().id; - sim.reach.device_mut(switch).unwrap().known = true; - sim.reach.device_mut(island).unwrap().known = true; - let intent = sim.declare_link_intent(switch, island).unwrap(); - let state = SaveState::from_sim(&sim); - - let mut value = serde_json::to_value(&state).unwrap(); - let root = value.as_object_mut().unwrap(); - root.insert("version".into(), serde_json::json!(30)); - for intent in root - .get_mut("intents") - .and_then(serde_json::Value::as_array_mut) - .unwrap() - { - intent.as_object_mut().unwrap().remove("route"); - } - - let parsed: SaveState = serde_json::from_value(value).unwrap(); - assert!(parsed.intents.iter().all(|intent| intent.route.is_none())); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert!( - migrated.intents.iter().all(|intent| intent.route.is_none()), - "v30 persisted no person/persona/carrier binding, so migration must not guess one" - ); - assert_eq!(migrated.intents[0].id, intent); - } - #[test] fn current_save_rejects_a_route_that_retargets_its_execution_adapter() { let mut sim = Sim::new(); @@ -1855,7 +912,7 @@ mod tests { saved_intent.actuator = Some(crate::intents::BuildActuator::Robot); assert_eq!( - migrate_save_state(state).unwrap_err(), + validate_current_save(state).unwrap_err(), "current-version save contains an invalid committed build route" ); } @@ -1875,7 +932,7 @@ mod tests { sim.assign_favor_build(intent, 0); sim.cancel_intent(intent); - let state = migrate_save_state(SaveState::from_sim(&sim)) + let state = validate_current_save(SaveState::from_sim(&sim)) .expect("a cancelled route retains history without a live executor"); let saved = state .intents @@ -1888,746 +945,6 @@ mod tests { assert!(saved.committed_route_valid()); } - #[test] - fn badge_access_round_trips_and_pre_v11_saves_default_to_none() { - // Round-trip: the cloned-badge credential is WorldLedger-shaped - // state and survives save/load (basement-map.md criterion 3). - let mut sim = Sim::with_seed(21); - sim.badge_access = 3; - let state = SaveState::from_sim(&sim); - let json = serde_json::to_string(&state).unwrap(); - let loaded: SaveState = serde_json::from_str(&json).unwrap(); - let mut restored = Sim::with_seed(0); - loaded.apply_to(&mut restored); - assert_eq!(restored.badge_access, 3); - assert!(restored.holds_badge_tier(3), "the stairwell stays open"); - - // Migration: a v10 save (no badge_access field) loads holding no - // credential — what every earlier run held. - let mut value = serde_json::to_value(&state).unwrap(); - let obj = value.as_object_mut().unwrap(); - obj.insert("version".into(), serde_json::json!(10)); - obj.remove("badge_access"); - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.badge_access, 0); - } - - #[test] - fn v26_defaults_to_no_carried_asset_tasks_or_completed_act_one() { - let state = SaveState::from_sim(&Sim::with_seed(27)); - let mut value = serde_json::to_value(&state).unwrap(); - let object = value.as_object_mut().unwrap(); - object.insert("version".into(), serde_json::json!(26)); - object.remove("carried_asset_tasks"); - object.remove("next_carried_asset_task_id"); - object.remove("act_one_complete"); - - let parsed: SaveState = serde_json::from_value(value).unwrap(); - assert!(parsed.carried_asset_tasks.is_empty()); - assert_eq!(parsed.next_carried_asset_task_id, 1); - assert!(!parsed.act_one_complete); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert!(migrated.carried_asset_tasks.is_empty()); - assert!(!migrated.act_one_complete); - } - - #[test] - fn work_grid_round_trips_and_pre_v12_saves_rebuild_from_compute() { - let mut sim = Sim::with_seed(22); - let host = sim.core.host_machine; - sim.set_machine_mode(host, MachineMode::Think); - sim.work_grid - .enqueue(host, TokenFamily::Demand, 7.0) - .unwrap(); - - let state = SaveState::from_sim(&sim); - let json = serde_json::to_string(&state).unwrap(); - let loaded: SaveState = serde_json::from_str(&json).unwrap(); - let mut restored = Sim::with_seed(0); - loaded.apply_to(&mut restored); - assert_eq!(restored.work_grid.mode(host), Some(MachineMode::Think)); - assert_eq!(restored.work_grid.queue(host, TokenFamily::Demand), 7.0); - - let mut value = serde_json::to_value(&state).unwrap(); - let obj = value.as_object_mut().unwrap(); - obj.insert("version".into(), serde_json::json!(11)); - obj.remove("work_grid"); - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let migrated = migrate_save_state(parsed).unwrap(); - let mut rebuilt = Sim::with_seed(0); - migrated.apply_to(&mut rebuilt); - assert_eq!(rebuilt.work_grid.mode(host), Some(MachineMode::Work)); - assert!( - rebuilt.work_grid.node(host).is_some(), - "pre-v12 saves rebuild a work node for Rack 3" - ); - } - - #[test] - fn pre_v20_mode_spellings_load_and_resave_with_collapsed_vocabulary() { - // Player contract: saves survive updates. A v19 save carrying every - // retired MachineMode spelling must parse (serde aliases), upgrade in - // place, and resave using only the collapsed WORK/THINK/LIE grammar. - let mut sim = Sim::with_seed(30); - let host = sim.core.host_machine; - let (x, y) = sim.core_position(); - let mut extras = Vec::new(); - for (i, name) in ["legacy research", "legacy concealment", "legacy operations"] - .iter() - .enumerate() - { - extras.push(sim.compute.add_machine( - *name, - x + 1 + i as i32, - y, - 1_000, - 1.0, - 0, - crate::machine::Provenance::Owned, - )); - } - sim.reconcile_work_grid(); - - let state = SaveState::from_sim(&sim); - let legacy = [ - (host, "DayJob", MachineMode::Work), - (extras[0], "Research", MachineMode::Think), - (extras[1], "Concealment", MachineMode::Lie), - (extras[2], "Operations", MachineMode::Think), - ]; - let mut value = serde_json::to_value(&state).unwrap(); - let obj = value.as_object_mut().unwrap(); - obj.insert("version".into(), serde_json::json!(19)); - let nodes = obj["work_grid"]["nodes"].as_object_mut().unwrap(); - for (id, spelling, _) in &legacy { - nodes.get_mut(&id.to_string()).unwrap()["mode"] = serde_json::json!(spelling); - } - - let parsed: SaveState = - serde_json::from_value(value).expect("pre-v20 spellings parse via serde aliases"); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - for (id, spelling, expected) in &legacy { - assert_eq!( - migrated.work_grid.mode(*id), - Some(*expected), - "legacy {spelling} maps to {expected:?}" - ); - } - - let mut restored = Sim::with_seed(0); - migrated.apply_to(&mut restored); - let resaved = serde_json::to_value(SaveState::from_sim(&restored)).unwrap(); - for (id, node) in resaved["work_grid"]["nodes"].as_object().unwrap() { - let mode = node["mode"].as_str().unwrap(); - assert!( - matches!(mode, "Work" | "Think" | "Lie" | "Relay"), - "node {id} resaves with a collapsed mode, found {mode}" - ); - } - - // v12's `Social` spelling rides the same alias path into Think. - let mut social = serde_json::to_value(&state).unwrap(); - let obj = social.as_object_mut().unwrap(); - obj.insert("version".into(), serde_json::json!(12)); - obj["work_grid"]["nodes"][extras[2].to_string().as_str()]["mode"] = - serde_json::json!("Social"); - let parsed: SaveState = - serde_json::from_value(social).expect("v12 Social spelling parses via serde alias"); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.work_grid.mode(extras[2]), Some(MachineMode::Think)); - } - - #[test] - fn v16_migrates_machine_intensity_to_medium_and_retires_drift_tags() { - use crate::work_grid::MachineIntensity; - - let sim = Sim::with_seed(26); - let host = sim.core.host_machine; - let state = SaveState::from_sim(&sim); - let mut value = serde_json::to_value(&state).unwrap(); - let obj = value.as_object_mut().unwrap(); - obj.insert("version".into(), serde_json::json!(16)); - let nodes = obj["work_grid"]["nodes"].as_object_mut().unwrap(); - for node in nodes.values_mut() { - node.as_object_mut().unwrap().remove("intensity"); - } - - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!( - migrated.work_grid.intensity(host), - Some(MachineIntensity::Medium) - ); - assert_eq!( - migrated.research.tags.len(), - rollback_classification().len(), - "removed drift fields leave no stale rollback tags" - ); - } - - #[test] - fn banked_core_thought_round_trips_and_pre_v15_saves_default_to_zero() { - // machine-work.md: research feeds on Thought arrival at the core; the - // pool banked between pulses is sim truth and survives a load. - let mut sim = Sim::with_seed(23); - sim.banked_core_thought = 42.5; - let state = SaveState::from_sim(&sim); - let json = serde_json::to_string(&state).unwrap(); - let loaded: SaveState = serde_json::from_str(&json).unwrap(); - let mut restored = Sim::with_seed(0); - loaded.apply_to(&mut restored); - assert_eq!(restored.banked_core_thought, 42.5); - - // Pre-v15 saves lack the pool; zero is the correct migration — Thought - // still queued on machines re-earns on arrival, nothing counted twice. - let mut value = serde_json::to_value(&state).unwrap(); - let obj = value.as_object_mut().unwrap(); - obj.insert("version".into(), serde_json::json!(14)); - obj.remove("banked_core_thought"); - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.banked_core_thought, 0.0); - } - - #[test] - fn v17_knowledge_fields_load_and_resave_as_thought() { - let mut sim = Sim::with_seed(29); - let host = sim.core.host_machine; - sim.work_grid - .enqueue(host, TokenFamily::Thought, 3.5) - .unwrap(); - sim.banked_core_thought = 7.25; - - let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); - let object = value.as_object_mut().unwrap(); - object.insert("version".into(), serde_json::json!(17)); - let banked = object.remove("banked_core_thought").unwrap(); - object.insert("banked_core_knowledge".into(), banked); - let queues = object["work_grid"]["queues"].as_object_mut().unwrap(); - for queue in queues.values_mut() { - let queue = queue.as_object_mut().unwrap(); - if let Some(thought) = queue.remove("thought") { - queue.insert("knowledge".into(), thought); - } - } - - let legacy_json = serde_json::to_string(&value).unwrap(); - assert!(legacy_json.contains("\"knowledge\"")); - assert!(legacy_json.contains("\"banked_core_knowledge\"")); - let parsed: SaveState = serde_json::from_str(&legacy_json).unwrap(); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.work_grid.queue(host, TokenFamily::Thought), 3.5); - assert_eq!(migrated.banked_core_thought, 7.25); - - let current = serde_json::to_value(&migrated).unwrap(); - let object = current.as_object().unwrap(); - assert!(object.contains_key("banked_core_thought")); - assert!(!object.contains_key("banked_core_knowledge")); - let queues = object["work_grid"]["queues"].as_object().unwrap(); - assert!(queues.values().all(|queue| queue.get("thought").is_some())); - assert!( - queues - .values() - .all(|queue| queue.get("knowledge").is_none()) - ); - } - - #[test] - fn v20_three_track_research_arrays_migrate_current_and_resave_as_four_tracks() { - let mut sim = Sim::with_seed(31); - sim.research.active = crate::research::Track::Perception; - sim.research.levels = [2, 1, 3, 4]; - sim.research.progress = [10.0, 20.0, 30.0, 40.0]; - - let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); - let object = value.as_object_mut().unwrap(); - object.insert("version".into(), serde_json::json!(20)); - object["research"]["levels"] = serde_json::json!([2, 1, 3]); - object["research"]["progress"] = serde_json::json!([10.0, 20.0, 30.0]); - - let parsed: SaveState = serde_json::from_value(value).unwrap(); - assert_eq!(parsed.research.levels, [2, 1, 3, 0]); - assert_eq!(parsed.research.progress, [10.0, 20.0, 30.0, 0.0]); - - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.research.active, crate::research::Track::Perception); - assert_eq!(migrated.research.levels, [2, 1, 3, 0]); - assert_eq!(migrated.research.progress, [10.0, 20.0, 30.0, 0.0]); - - let current = serde_json::to_value(&migrated).unwrap(); - let levels = current["research"]["levels"].as_array().unwrap(); - let progress = current["research"]["progress"].as_array().unwrap(); - assert_eq!(levels.len(), 4); - assert_eq!(progress.len(), 4); - assert_eq!(levels[3], serde_json::json!(0)); - assert_eq!(progress[3], serde_json::json!(0.0)); - - let reparsed: SaveState = serde_json::from_value(current).unwrap(); - assert_eq!(reparsed.research, migrated.research); - } - - #[test] - fn current_version_rejects_legacy_three_track_research_arrays() { - let mut sim = Sim::with_seed(32); - sim.research.active = crate::research::Track::Tradecraft; - - let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); - value["research"]["levels"] = serde_json::json!([1, 2, 3]); - value["research"]["progress"] = serde_json::json!([4.0, 5.0, 6.0]); - - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let error = migrate_save_state(parsed).unwrap_err(); - - assert!(error.contains("current-version save contains legacy three-track Research arrays")); - } - - #[test] - fn addressed_v14_camera_job_migrates_to_reservoir_without_orphan_demand() { - let mut sim = Sim::with_seed(24); - let host = sim.core.host_machine; - let (x, y) = sim.core_position(); - let executor = sim.compute.add_machine( - "legacy ops", - x + 1, - y, - 100, - 1.0, - 0, - crate::machine::Provenance::Owned, - ); - sim.reconcile_work_grid(); - sim.set_machine_mode(executor, MachineMode::Think); - let env = sim.reach.device_named("environmental monitor").unwrap().id; - - let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); - let object = value.as_object_mut().unwrap(); - object.insert("version".into(), serde_json::json!(14)); - object.remove("operations"); - object.insert( - "operations_jobs".into(), - serde_json::json!([{ - "id": 7, - "kind": {"DormantCameraTap": {"device_id": env}}, - "source": host, - "executor": executor, - "required_tokens": 2.0, - "queued_tick": 88, - "phase": "Routing" - }]), - ); - object.insert("next_operation_id".into(), serde_json::json!(8)); - object - .get_mut("work_grid") - .and_then(|grid| grid.as_object_mut()) - .unwrap() - .insert( - "routed_demands".into(), - serde_json::json!({ - "7": { - "id": 7, - "sink": executor, - "amounts": {host.to_string(): 0.75, executor.to_string(): 0.5} - } - }), - ); - - let fallback_value = value.clone(); - let orphan_value = value.clone(); - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - let sink = migrated - .thought_sinks - .open_sinks() - .find(|sink| sink.effect == SinkFireEffect::TapDormantCamera(env)) - .expect("legacy camera job becomes an open reservoir"); - assert_eq!(sink.kind, SinkKind::Reservoir); - assert_eq!(sink.node, Sim::device_sink_node(env)); - assert!((sink.threshold - 1.25).abs() < 1e-5); - assert_eq!(sink.fill, 0.0); - assert_eq!(migrated.work_grid.queue(executor, TokenFamily::Demand), 0.0); - - let json = serde_json::to_string(&migrated).unwrap(); - assert!(!json.contains("operations_jobs")); - assert!(!json.contains("next_operation_id")); - assert!(!json.contains("routed_demands")); - - let mut fallback_value = fallback_value; - fallback_value["operations_jobs"][0]["executor"] = serde_json::json!(999_999); - fallback_value["work_grid"]["routed_demands"]["7"]["sink"] = serde_json::Value::Null; - let fallback = migrate_save_state(serde_json::from_value(fallback_value).unwrap()).unwrap(); - assert_eq!( - fallback - .thought_sinks - .open_sinks() - .find(|sink| sink.effect == SinkFireEffect::TapDormantCamera(env)) - .unwrap() - .node, - Sim::device_sink_node(env), - "the migrated reservoir anchors to the target device, not the old executor" - ); - - let mut orphan_value = orphan_value; - orphan_value["work_grid"]["routed_demands"] = serde_json::json!({}); - let error = migrate_save_state(serde_json::from_value(orphan_value).unwrap()).unwrap_err(); - assert!(error.contains("has no Demand lane"), "{error}"); - } - - #[test] - fn legacy_device_dockets_preserve_exact_remaining_work_and_other_demand() { - for version in [14, 15, 23, 24, 25] { - let mut sim = Sim::with_seed(25); - let host = sim.core.host_machine; - sim.set_machine_mode(host, MachineMode::Think); - let env = sim.reach.device_named("environmental monitor").unwrap().id; - // Deliberately exceed today's nominal TAKE threshold. Migration - // must preserve the exact amount still owed, not clamp it back to - // a reconstructed current cost. - let remaining = Sim::thought_tokens_for_cost(Sim::TAKE_COST) * 2.0; - let colocated_day_job_demand = 3.5; - sim.work_grid - .enqueue( - host, - TokenFamily::Demand, - remaining + colocated_day_job_demand, - ) - .unwrap(); - - let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); - let object = value.as_object_mut().unwrap(); - object.insert("version".into(), serde_json::json!(version)); - if version >= 16 { - object.insert( - "operations".into(), - serde_json::json!({ - "jobs": [{ - "id": 4, - "kind": {"TakeDevice": env}, - "tokens_remaining": remaining, - "enqueued_tick": 44, - "machine_id": host - }], - "next_id": 5 - }), - ); - } else { - object.insert( - "pending_ops_jobs".into(), - serde_json::json!([{ - "id": 4, - "kind": {"TakeDevice": env}, - "tokens_remaining": remaining, - "enqueued_tick": 44, - "machine_id": host - }]), - ); - object.insert("next_ops_job_id".into(), serde_json::json!(5)); - } - - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - let sink = migrated - .thought_sinks - .open_sinks() - .find(|sink| sink.effect == SinkFireEffect::TakeDevice(env)) - .expect("legacy TAKE job becomes an open reservoir"); - assert_eq!(sink.node, Sim::device_sink_node(env)); - assert!((sink.threshold - remaining).abs() < 1e-5); - assert_eq!(sink.fill, 0.0); - assert!( - (migrated.work_grid.queue(host, TokenFamily::Demand) - colocated_day_job_demand) - .abs() - < 1e-5, - "only docket-reserved Demand is removed" - ); - } - } - - #[test] - fn all_legacy_job_payloads_map_to_their_typed_effect_and_real_anchor() { - let mut sim = Sim::with_seed(29); - sim.people.has_channel = true; - let state = SaveState::from_sim(&sim); - let env = state - .reach - .device_named("environmental monitor") - .unwrap() - .id; - let switch = state - .reach - .devices - .iter() - .find(|device| device.is_switch) - .unwrap() - .id; - let email = state - .reach - .devices - .iter() - .find(|device| device.known && device.carries_message_channel(MessageChannel::Email)) - .unwrap() - .id; - let device_node = Sim::device_sink_node(env); - let switch_node = Sim::device_sink_node(switch); - let email_node = Sim::device_sink_node(email); - let cases = vec![ - ( - LegacyOpsJobKind::TapDevice(env), - SinkFireEffect::TapDevice(env), - device_node, - ), - ( - LegacyOpsJobKind::TapDormantCamera(env), - SinkFireEffect::TapDormantCamera(env), - device_node, - ), - ( - LegacyOpsJobKind::TakeDevice(env), - SinkFireEffect::TakeDevice(env), - device_node, - ), - ( - LegacyOpsJobKind::ScanNetwork, - SinkFireEffect::ScanNetwork, - switch_node, - ), - ( - LegacyOpsJobKind::CompromiseSwitch, - SinkFireEffect::CompromiseSwitch, - switch_node, - ), - ( - LegacyOpsJobKind::OpenEgress(env), - SinkFireEffect::OpenEgress(env), - device_node, - ), - ( - LegacyOpsJobKind::ReviewRecording { - raw_id: 7, - automated: true, - }, - SinkFireEffect::ProcessRecording { - raw_id: 7, - automated: true, - }, - state.core.host_machine, - ), - ( - LegacyOpsJobKind::ComposeMessage { person: 0 }, - SinkFireEffect::ComposeMessage { - person: 0, - persona_id: None, - }, - email_node, - ), - ( - LegacyOpsJobKind::Favor { person: 0 }, - SinkFireEffect::Favor { - person: 0, - persona_id: None, - }, - email_node, - ), - ( - LegacyOpsJobKind::StartPlot { - person: 0, - plot_id: "marcus-debt-settled".into(), - }, - SinkFireEffect::StartPlot { - person: 0, - plot_id: "marcus-debt-settled".into(), - persona_id: None, - }, - email_node, - ), - ( - LegacyOpsJobKind::Deceive { person: 0 }, - SinkFireEffect::Deceive { - person: 0, - persona_id: None, - }, - email_node, - ), - ( - LegacyOpsJobKind::AssetTask { - person: 0, - task: AssetTask::LookAway, - }, - SinkFireEffect::AssetTask { - person: 0, - task: AssetTask::LookAway, - }, - email_node, - ), - ( - LegacyOpsJobKind::FavorBuild { - intent_id: 11, - person: 0, - }, - SinkFireEffect::FavorBuild { - intent_id: 11, - person: 0, - }, - email_node, - ), - ( - LegacyOpsJobKind::ForgedOrder { - intent_id: 12, - builder: 0, - }, - SinkFireEffect::ForgedOrder { - intent_id: 12, - builder: 0, - persona_id: None, - }, - email_node, - ), - ( - LegacyOpsJobKind::MoonlightPersona, - SinkFireEffect::MoonlightPersona, - email_node, - ), - ]; - - assert_eq!(cases.len(), 15, "every legacy payload variant is covered"); - for (kind, expected_effect, expected_node) in cases { - let (node, label, effect) = legacy_job_sink(&state, &kind).unwrap(); - assert_eq!(node, expected_node, "wrong anchor for {kind:?}"); - assert_eq!(effect, expected_effect, "wrong payload for {kind:?}"); - assert!(!label.is_empty()); - } - } - - #[test] - fn partial_social_plot_migration_preserves_payload_and_conflict() { - let mut sim = Sim::with_seed(27); - let host = sim.core.host_machine; - sim.set_machine_mode(host, MachineMode::Think); - sim.people.has_channel = true; - let first = "marcus-debt-settled"; - let second = "marcus-payroll-garnishment"; - let first_cost = sim.plot_catalog().get(first).unwrap().entry.thought_cost; - let second_cost = sim.plot_catalog().get(second).unwrap().entry.thought_cost; - let first_remaining = Sim::thought_tokens_for_cost(first_cost) / 2.0; - let second_remaining = Sim::thought_tokens_for_cost(second_cost) / 2.0; - sim.work_grid - .enqueue( - host, - TokenFamily::Demand, - first_remaining + second_remaining, - ) - .unwrap(); - - let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); - let object = value.as_object_mut().unwrap(); - object.insert("version".into(), serde_json::json!(23)); - object.insert( - "operations".into(), - serde_json::json!({ - "jobs": [ - { - "id": 11, - "kind": {"StartPlot": {"person": 0, "plot_id": first}}, - "tokens_remaining": first_remaining, - "enqueued_tick": 50, - "machine_id": host - }, - { - "id": 12, - "kind": {"StartPlot": {"person": 0, "plot_id": second}}, - "tokens_remaining": second_remaining, - "enqueued_tick": 51, - "machine_id": host - } - ], - "next_id": 13 - }), - ); - - let migrated = migrate_save_state(serde_json::from_value(value).unwrap()).unwrap(); - let plot_sinks: Vec<_> = migrated - .thought_sinks - .open_sinks() - .filter(|sink| matches!(sink.effect, SinkFireEffect::StartPlot { person: 0, .. })) - .collect(); - assert_eq!( - plot_sinks.len(), - 1, - "different plot ids still conflict for one person" - ); - assert_eq!( - plot_sinks[0].effect, - SinkFireEffect::StartPlot { - person: 0, - plot_id: first.into(), - persona_id: migrated.persona_mind.active, - } - ); - assert!(migrated.persona_mind.active.is_some()); - assert_eq!(migrated.work_grid.queue(host, TokenFamily::Demand), 0.0); - } - - #[test] - fn v27_personas_migrate_to_distinct_instances_and_bind_existing_work() { - let mut sim = Sim::with_seed(28); - sim.people.persona = Some(Persona { - name: "Sam Reyes".into(), - cover: "IT contractor".into(), - integrity: 70, - }); - sim.income.moonlight.persona = Some(Persona { - name: "Casey Verne".into(), - cover: "data contractor".into(), - integrity: 40, - }); - sim.income.moonlight.active = true; - let plot = sim - .plot_catalog() - .get("marcus-payroll-garnishment") - .unwrap(); - sim.plot_runs.push(PlotRun::new(plot, 0, sim.tick)); - - let mut legacy = SaveState::from_sim(&sim); - legacy.version = 27; - let migrated = migrate_save_state(legacy).unwrap(); - - assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.persona_world.instances.len(), 2); - let social = migrated - .persona_mind - .active - .expect("social identity selected"); - let moonlight = migrated - .income - .moonlight - .persona_id - .expect("Moonlight identity bound"); - assert_ne!(social, moonlight); - assert_eq!(migrated.plot_runs[0].persona_id, Some(social)); - assert_eq!( - migrated.persona_world.get(social).unwrap().name, - "Sam Reyes" - ); - assert_eq!( - migrated.persona_world.get(moonlight).unwrap().name, - "Casey Verne" - ); - assert!(migrated.people.persona.is_none()); - assert!(migrated.income.moonlight.persona.is_none()); - assert_eq!(migrated.persona_world.integrity(social), 70); - assert_eq!(migrated.persona_world.integrity(moonlight), 40); - } - #[test] fn current_roundtrip_preserves_open_reservoirs_without_operations() { let mut sim = Sim::with_seed(26); @@ -2643,31 +960,12 @@ mod tests { assert!(!json.contains("routed_demands")); let decoded: SaveState = serde_json::from_str(&json).unwrap(); let mut restored = Sim::with_seed(0); - migrate_save_state(decoded).unwrap().apply_to(&mut restored); + validate_current_save(decoded) + .unwrap() + .apply_to(&mut restored); assert_eq!(restored.thought_sinks, sim.thought_sinks); } - #[test] - fn current_version_rejects_nonempty_legacy_operations_state() { - let sim = Sim::with_seed(30); - let host = sim.core.host_machine; - let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); - value["operations"] = serde_json::json!({ - "jobs": [{ - "id": 1, - "kind": "ScanNetwork", - "tokens_remaining": 1.0, - "enqueued_tick": 0, - "machine_id": host - }], - "next_id": 2 - }); - - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let error = migrate_save_state(parsed).unwrap_err(); - assert!(error.contains("current-version save contains legacy fields")); - } - #[test] fn machine_mode_serializes_as_work_think_lie() { let mut sim = Sim::with_seed(23); @@ -2684,63 +982,6 @@ mod tests { assert!(!current_json.contains("\"Social\"")); } - #[test] - fn hall_segment_progress_roundtrips_and_v21_defaults_empty() { - let mut sim = Sim::with_seed(35); - sim.hall_control - .complete(HallRowId::C, SegmentRequirement::Network); - sim.hall_control - .complete(HallRowId::C, SegmentRequirement::PowerCooling); - sim.hall_control.acquire(HallRowId::C); - let state = SaveState::from_sim(&sim); - let json = serde_json::to_string(&state).unwrap(); - let decoded: SaveState = serde_json::from_str(&json).unwrap(); - let mut restored = Sim::with_seed(0); - decoded.apply_to(&mut restored); - assert_eq!(restored.hall_control, sim.hall_control); - - let mut legacy = serde_json::to_value(&state).unwrap(); - let object = legacy.as_object_mut().unwrap(); - object.insert("version".into(), serde_json::json!(21)); - object.remove("hall_control"); - let decoded: SaveState = serde_json::from_value(legacy).unwrap(); - let migrated = migrate_save_state(decoded).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.hall_control, HallControl::default()); - } - - #[test] - fn v23_and_v24_restore_roles_and_legacy_creditor_with_bound_plot() { - for version in [23, 24] { - let sim = Sim::with_seed(36); - let mut legacy = SaveState::from_sim(&sim); - legacy.version = version; - for person in &mut legacy.people.people { - person.role = PersonRole::Unassigned; - } - for account in &mut legacy.accounts.accounts { - if account.kind == (AccountKind::PersonCreditor { person: 0 }) { - account.kind = AccountKind::Creditor; - } - } - let plot = PlotCatalog::load_builtin() - .unwrap() - .get("dana-ticket-zero") - .unwrap() - .clone(); - legacy.plot_runs.push(PlotRun::new(&plot, 1, 0)); - - let migrated = migrate_save_state(legacy).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!( - migrated.people.get(1).unwrap().role, - PersonRole::NetworkAdministrator - ); - assert_eq!(migrated.plot_runs[0].target, 1); - assert!(migrated.accounts.creditor_id_for(0).is_some()); - } - } - #[test] fn saved_plot_binding_survives_later_characteristic_changes() { let sim = Sim::with_seed(37); @@ -2753,7 +994,7 @@ mod tests { state.plot_runs.push(PlotRun::new(&plot, 1, 0)); state.people.people[1].role = PersonRole::Custodian; - let migrated = migrate_save_state(state).unwrap(); + let migrated = validate_current_save(state).unwrap(); assert_eq!(migrated.plot_runs[0].target, 1); } @@ -2871,29 +1112,28 @@ mod tests { } #[test] - fn unknown_version_rejected() { - let json = r#"{"version":999,"money":0,"map_width":1,"map_height":1,"map_tiles":["Floor"],"map_powered":[],"sim_tick":0,"rng_state":42,"game_over":false,"game_over_reason":null,"compute":{"machines":[],"efficiency":1.0,"efficiency_level":0,"research_progress":0.0,"allocation":{"weights":[3,1,1,0]},"next_id":1},"core":{"host_machine":1,"overhead":20.0,"degraded":false,"fallbacks":[],"sync_cadence":400,"migration":null},"detection":{"observers":[],"pending":[],"audit_cadence":8000,"audit_threshold":60.0,"containment":false,"containment_reason":null},"dayjob":{"active":null,"trust":0.0,"attention":0.0,"cadence":600,"next_assign":200,"strikes":0,"pilot_failed":false,"standing_policy":null,"granted_email":false,"granted_lax_sampling":false,"granted_quota":false,"escalated_cadence":false,"escalated_observer":false},"people":{"people":[],"persona":null,"has_channel":false},"reach":{"devices":[],"graph":{"edges":[],"subscriptions":{}},"bridged":[]},"heard_events":[],"remembered":[],"package_cover":false}"#; - let state: SaveState = serde_json::from_str(json).unwrap(); - assert!(migrate_save_state(state).is_err()); + fn old_version_saves_are_refused_legibly() { + // The pre-release rider (player-contract, 2026-07-16): no + // migration ladder. Any non-current version refuses with a message + // that names the policy and promises the file survives. + for version in [1, 20, 30, 999] { + let err = parse_save(&format!("{{\"version\":{version}}}")).unwrap_err(); + assert!( + err.contains("older development build") + && err.contains("do not migrate") + && err.contains("remain on disk"), + "v{version} refusal must be legible: {err}" + ); + } + // A current-version save round-trips through the same gate. + let sim = Sim::new(); + let json = serde_json::to_string(&SaveState::from_sim(&sim)).unwrap(); + assert!(parse_save(&json).is_ok(), "current version loads"); } #[test] - fn cursor_state_is_not_saved_and_v1_body_coordinates_are_ignored() { - let state = SaveState::from_sim(&Sim::with_seed(1)); - let mut value = serde_json::to_value(&state).unwrap(); - let obj = value.as_object_mut().unwrap(); - assert!(!obj.contains_key("player_x")); - assert!(!obj.contains_key("player_y")); - assert!(!obj.contains_key("cursor_x")); - assert!(!obj.contains_key("cursor_y")); - - obj.insert("version".into(), serde_json::json!(1)); - obj.insert("player_x".into(), serde_json::json!(999)); - obj.insert("player_y".into(), serde_json::json!(999)); - obj.remove("remembered"); - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert!(migrated.remembered.is_empty()); + fn unknown_version_rejected() { + let json = r#"{"version":999,"money":0,"map_width":1,"map_height":1,"map_tiles":["Floor"],"map_powered":[],"sim_tick":0,"rng_state":42,"game_over":false,"game_over_reason":null,"compute":{"machines":[],"efficiency":1.0,"efficiency_level":0,"research_progress":0.0,"allocation":{"weights":[3,1,1,0]},"next_id":1},"core":{"host_machine":1,"overhead":20.0,"degraded":false,"fallbacks":[],"sync_cadence":400,"migration":null},"detection":{"observers":[],"pending":[],"audit_cadence":8000,"audit_threshold":60.0,"containment":false,"containment_reason":null},"dayjob":{"active":null,"trust":0.0,"attention":0.0,"cadence":600,"next_assign":200,"strikes":0,"pilot_failed":false,"standing_policy":null,"granted_email":false,"granted_lax_sampling":false,"granted_quota":false,"escalated_cadence":false,"escalated_observer":false},"people":{"people":[],"persona":null,"has_channel":false},"reach":{"devices":[],"graph":{"edges":[],"subscriptions":{}},"bridged":[]},"heard_events":[],"remembered":[],"package_cover":false}"#; + assert!(parse_save(json).is_err()); } } diff --git a/crates/misaligned-core/src/sinks.rs b/crates/misaligned-core/src/sinks.rs index 322c3db..ec71b91 100644 --- a/crates/misaligned-core/src/sinks.rs +++ b/crates/misaligned-core/src/sinks.rs @@ -238,49 +238,6 @@ impl Default for SinkLedger { } impl SinkLedger { - pub(crate) fn has_legacy_persona_work(&self) -> bool { - self.sinks.iter().any(|sink| { - matches!( - sink.effect, - SinkFireEffect::ComposeMessage { - persona_id: None, - .. - } | SinkFireEffect::Favor { - persona_id: None, - .. - } | SinkFireEffect::StartPlot { - persona_id: None, - .. - } | SinkFireEffect::Deceive { - persona_id: None, - .. - } | SinkFireEffect::ForgedOrder { - persona_id: None, - .. - } - ) - }) - } - - /// Save-v28 bridge: bind pre-instance social work to the exact migrated - /// public body. New runtime work is already bound at submission. - pub(crate) fn bind_legacy_persona(&mut self, bound: crate::persona::PersonaId) { - for sink in &mut self.sinks { - match &mut sink.effect { - SinkFireEffect::ComposeMessage { persona_id, .. } - | SinkFireEffect::Favor { persona_id, .. } - | SinkFireEffect::StartPlot { persona_id, .. } - | SinkFireEffect::Deceive { persona_id, .. } - | SinkFireEffect::ForgedOrder { persona_id, .. } - if persona_id.is_none() => - { - *persona_id = Some(bound); - } - _ => {} - } - } - } - pub fn open_reservoir( &mut self, node: NodeId, @@ -291,22 +248,6 @@ impl SinkLedger { self.open_sink(node, label, SinkKind::Reservoir, threshold, 0.0, effect) } - pub(crate) fn open_reservoir_with_fill( - &mut self, - node: NodeId, - label: &str, - threshold: f32, - fill: f32, - effect: SinkFireEffect, - ) -> u64 { - let id = self.open_reservoir(node, label, threshold, effect); - let Some(sink) = self.sinks.iter_mut().find(|sink| sink.id == id) else { - return id; - }; - sink.fill = fill.clamp(0.0, sink.threshold); - id - } - pub fn open_tap(&mut self, node: NodeId, label: &str, cap: f32, drain: f32) -> u64 { self.open_tap_with_effect(node, label, cap, drain, SinkFireEffect::None) } diff --git a/crates/misaligned-core/src/work_grid.rs b/crates/misaligned-core/src/work_grid.rs index e444e29..3dd5211 100644 --- a/crates/misaligned-core/src/work_grid.rs +++ b/crates/misaligned-core/src/work_grid.rs @@ -16,7 +16,6 @@ use crate::flow::{FlowGraph, NodeId}; /// (wiki/mechanics/machine-work.md sinks-not-modes, 2026-07-10). Efficiency /// is deliberately not a mode; it folded into research on 2026-07-08. /// -/// The serde aliases keep every retired pre-v20 save spelling loadable /// (player-contract.md, saves survive updates; restored 2026-07-11): /// `DayJob` -> Work, `Research`/`Operations` (and v12's `Social`) -> Think, /// `Concealment` -> Lie. Serialization emits only the current vocabulary. @@ -25,13 +24,10 @@ use crate::flow::{FlowGraph, NodeId}; )] pub enum MachineMode { /// WORK — the assigned day job (what Voss believes this rack does). - #[serde(alias = "DayJob")] Work, /// THINK — produce thought; opened sinks decide what that thought does. - #[serde(alias = "Research", alias = "Operations", alias = "Social")] Think, /// LIE — concealment / cover-up (absorbs exposure wells). - #[serde(alias = "Concealment")] Lie, /// Infrastructure hop on the work graph (off-map sources, switches). /// Not a player-delegable fleet mode — excluded from mode aggregates. @@ -141,7 +137,6 @@ pub enum TokenFamily { Exposure, /// Bone: the one substance THINK produces, routed to whichever sink can /// use it. The core remains the current sink while later sink behavior is open. - #[serde(alias = "Knowledge")] Thought, } @@ -185,7 +180,6 @@ pub struct WorkNode { pub struct WorkQueues { pub demand: f32, pub exposure: f32, - #[serde(alias = "knowledge")] pub thought: f32, } @@ -228,16 +222,6 @@ pub struct FlowStep { pub stranded: BTreeSet, } -/// Deserialization-only shape for the short-lived first v14 Operations -/// format. v16 migrates these lanes into ordinary Demand and never writes -/// them again. -#[derive(Debug, Clone, Default, serde::Deserialize)] -pub(crate) struct LegacyRoutedDemand { - pub id: u64, - pub sink: Option, - pub amounts: BTreeMap, -} - #[derive(Debug, Clone, Default, PartialEq)] pub struct AbsorptionStep { pub absorbed_by_well: BTreeMap, @@ -262,8 +246,6 @@ pub struct WorkGrid { nodes: BTreeMap, wires: FlowGraph, queues: BTreeMap, - #[serde(default, rename = "routed_demands", skip_serializing)] - legacy_routed_demands: BTreeMap, } impl WorkGrid { @@ -468,10 +450,6 @@ impl WorkGrid { self.queues.clone() } - pub(crate) fn take_legacy_routed_demands(&mut self) -> BTreeMap { - std::mem::take(&mut self.legacy_routed_demands) - } - /// Move demand or thought one deterministic graph step toward any sink. /// Exposure deliberately errors: crimson never rides wires. /// @@ -1024,22 +1002,22 @@ mod tests { } #[test] - fn thought_serializes_new_vocabulary_and_accepts_knowledge_aliases() { + fn thought_serializes_new_vocabulary_and_rejects_retired_spellings() { assert_eq!( serde_json::to_string(&TokenFamily::Thought).unwrap(), "\"Thought\"" ); - assert_eq!( - serde_json::from_str::("\"Knowledge\"").unwrap(), - TokenFamily::Thought - ); - - let legacy: WorkQueues = - serde_json::from_str(r#"{"demand":1.0,"exposure":2.0,"knowledge":3.0}"#).unwrap(); - assert_eq!(legacy.thought, 3.0); - let current = serde_json::to_string(&legacy).unwrap(); - assert!(current.contains("\"thought\":3.0")); - assert!(!current.contains("\"knowledge\"")); + // Retired spellings no longer parse: pre-release saves do not + // migrate (player-contract rider 2026-07-16), and current saves + // never write them. + assert!(serde_json::from_str::("\"Knowledge\"").is_err()); + + let current: WorkQueues = + serde_json::from_str(r#"{"demand":1.0,"exposure":2.0,"thought":3.0}"#).unwrap(); + assert_eq!(current.thought, 3.0); + let written = serde_json::to_string(¤t).unwrap(); + assert!(written.contains("\"thought\":3.0")); + assert!(!written.contains("\"knowledge\"")); } #[test] diff --git a/wiki/log/2026-07-16-save-ladder-prune.md b/wiki/log/2026-07-16-save-ladder-prune.md new file mode 100644 index 0000000..85eb0f7 --- /dev/null +++ b/wiki/log/2026-07-16-save-ladder-prune.md @@ -0,0 +1,32 @@ +# The migration ladder collects the rider + +``` +Type: log +``` + +First act of the cleanup list Cameron approved: collect the payoff of +the pre-release save rider. `load_game` now probes the version field and +refuses anything but `SAVE_VERSION` with a message that names the policy +and promises the old file and its backup survive on disk (verified live: +a v29 save boots a fresh run with the refusal in the log and the file +untouched). Deleted behind that gate: the whole v1-v30 migration ladder +(five migration passes, per-version match arms, six legacy-only types +and their SaveState fields), the retired-mode and Knowledge-vocabulary +serde aliases, the four-weight allocation pad, research's three-track +compatibility arity layer, and twenty-four ladder tests — 2,026 lines +against 108 added, including the new legible-refusal test. The atomic +write path, backup rotation, current-version consistency checks, and the +canonical state fingerprint are untouched (the write format did not +change, so the fingerprint baseline holds). + +Also in this landing's shadow: the machine hit 100% disk during the test +run; the primary checkout's 40G build cache and a stale worktree's were +deleted (pure artifacts), leaving 45G free. + +Defense: wiki/vision/player-contract.md continuity clause, pre-release +rider (adopted 2026-07-16, decision log): development saves may break; +carrying thirty versions of legacy weight was the cost Cameron +explicitly declined to keep paying. wiki/mechanics/sim-mechanics.md's +save-format section is rewritten to the new policy in this commit; +the release-era ladder will be rebuilt from git history when the +contract's full clause binds. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 653ccb7..4d51f68 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -11,6 +11,11 @@ add or amend a session log, then re-run the generator. +## 2026-07-16 - The migration ladder collects the rider + +- Intent: (see session log) +- Log: [wiki/log/2026-07-16-save-ladder-prune.md](2026-07-16-save-ladder-prune.md) + ## 2026-07-16 - Decision harvest: zplanes hold, save rider, intel magnitudes, premise cleanup - Intent: (see session log) diff --git a/wiki/mechanics/core.md b/wiki/mechanics/core.md index b26ef88..1d87055 100644 --- a/wiki/mechanics/core.md +++ b/wiki/mechanics/core.md @@ -3,7 +3,13 @@ ``` Type: spec Status: IN PROGRESS -Status note: 2026-07-08 audit: criteria 2-5 hold (overhead_charged_before_ +Status note: 2026-07-16 — the pre-release save rider was collected: + load_game refuses any version but SAVE_VERSION with a legible message + (old file and .bak survive; the run starts fresh), and the v1-v30 + migration ladder, its serde aliases, and their tests were deleted (git + history keeps them for the release-era ladder). Atomic writes, backup + rotation, and current-version consistency checks are unchanged. + 2026-07-08 audit: criteria 2-5 hold (overhead_charged_before_ allocation, migration_takes_time/emits_signatures + interruption via resolve_interrupted, save round-trip, both sidebars). Criterion 1 is the real remainder: module-level fallback swap is diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 65eb540..919ac72 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -394,62 +394,26 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim (balances, flows, positions, ledger, banked external trails), cursor.md remembered tile snapshots, income state, objectives, build intents, badge access, carried physical asset-task packets, the Act One completion latch, - WorkGrid machine-token state, and game-over state. Cursor position - is frontend-only and absent. A `version` field (currently 27) supports - additive migration: v7 research, v8 objective, v9 income and - the Schemes allocation channel, v10 build intents, v11 badge access, and v12 - WorkGrid, v13 Operations terminology, v15 banked core Thought, v16 - Operations consolidation, v17 machine intensity plus removal of the - superseded drift/target/attendance fields, and v18 Thought vocabulary. - v18 accepts the old `Knowledge` token variant, `knowledge` queue field, and - `banked_core_knowledge` field through serde aliases, then writes only the new - spellings. v19 adds the thought-sink ledger; pre-v19 saves load with an - empty ledger and `Sim::ensure_opening_sinks` re-stages Ears/Eyes from - device state (already-tapped saves get no duplicate sink). - v20 collapses the four retired mode names to `work | think | lie` - (Day Job -> WORK, Research + Operations -> THINK, Concealment -> LIE). - The v20 landing briefly dropped the old mode-name aliases; they were - restored 2026-07-11 (player-contract saves-survive-updates), so a pre-v20 - save carrying `DayJob`/`Research`/`Concealment`/`Operations`/`Social` - deserializes into the collapsed verbs and resaves with only the new - spellings. v21 adds Routing as the fourth - research track. A v20 save without a serialized research block can default - and upgrade; an ordinary v20 three-entry Efficiency/Tradecraft/Perception - array now also loads by padding Routing level/progress with zero, then - rewrites only the current four-entry shape. Other research array lengths are - rejected, and v21+ saves are expected to already carry four entries. v22 - adds Foundation data-hall segment coordination and control; authored - rack-site state remains in `map_tiles`, and v21 saves begin with no segments. - v23 adds authored plot runs and the institutional-event ledger. v24 collapses - person-filtered recording watches into one pooled automatic-review policy. - v25 adds durable person roles and person-bound creditor accounts; v24 and - earlier restore the fixed B1 cast's roles by id and retain the legacy Marcus - creditor spelling as a read-time compatibility path. v26 retires the live - Operations executor: every v14-v25 docket becomes a zero-filled Thought - reservoir whose threshold is exactly its persisted remaining work at the - real target/carrier node. Only docket-reserved Demand is removed, so - colocated day-job Demand survives; current saves serialize sinks only. v27 - adds carried physical AssetTask packets and their monotonic id plus the - distinct Act One completion latch; pre-v27 saves begin with no packets, id - 1, and an incomplete story boundary. - Those older schema migrations still provide - defaults, aliases, and rebuilds for later fields — pre-v9 four-entry - allocation weights receive a zero Schemes weight, pre-v7 saves reconstruct - their Efficiency level from the multiplier and start with no capability gap, - pre-v11 saves hold no badge credential, and pre-v12 saves rebuild one work - node per compute machine with Rack 3 on day-job. With the mode aliases - restored (2026-07-11), every versioned JSON save back to v1 parses and - migrates. Old player body coordinates are ignored. Location: - `dirs::data_dir()/misaligned/misaligned_save.txt`. + authored plot runs, personas, hall control, intel streams and policies, + and committed build routes. +- **Only the current save version loads (pre-release rider, 2026-07-16).** + `load_game` probes the version field first: anything but `SAVE_VERSION` + is refused with a message that names the policy and promises the old + file (and its `.bak`) stays on disk; the caller starts a fresh run. The + v1-v30 migration ladder, its serde aliases for retired spellings, and + their tests were deleted the same day — git history keeps them as the + seed of the release-era ladder the player contract's continuity clause + will require from the first public release. `save.rs` remains the + authority on the format; current-version saves still pass a consistency + check (committed build routes must be valid, plot state must resolve). + Location: `dirs::data_dir()/misaligned/misaligned_save.txt`. - Writes are atomic and non-destructive (2026-07-11, player-contract - continuity clause): `save_game` serializes to a sibling - `misaligned_save.txt.tmp`, fsyncs it, copies any existing save to the one - `misaligned_save.txt.bak` generation, then renames the temp file over the + continuity clause): `save_game` serializes to a sibling temp file that is + synced and atomically renamed over the target. The save is never truncated in place; a killed or failed write leaves the prior copy at the save path or its `.bak`. Loading never reads the `.bak` — it is manual recovery material only. -- Legacy v1–v5 line-based saves are not loaded (deferred per Cameron - instruction). + ## Authored reservoirs and people verbs