Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. An experiment in spec-driven development.
Something went wrong. Try again.
25 kB · 589 lines
Rust
at commit e957ce7b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590//! Save/load — serde JSON format, versioned per the player contract//! (continuity: saves survive updates; old formats load with migration).//!//! The save is a single JSON object: plain, local, human-readable, never//! held hostage. A `version` field is included for future migration; legacy//! v1-v5 line-based saves are not loaded by this code path (deferred per//! Cameron's instruction — "we can keep legacy saves later").
use std::collections::{HashMap, HashSet};use std::fs;use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::account::AccountGraph;use crate::core_sys::Core;use crate::dayjob::DayJob;use crate::detection::Detection;use crate::income::Income;use crate::intel::{IntelWatch, ProcessedIntel, RawIntelEvent};use crate::intents::BuildIntent;use crate::machine::Compute;use crate::messages::{Message, MessageEvent};use crate::objective::ObjectiveState;use crate::person::People;use crate::reach::ReachNet;use crate::research::{Research, Track};use crate::schedule::Schedule;use crate::sim::{HeardEvent, RememberedTile};use crate::tiles::TileType;use crate::work_grid::WorkGrid;
const SAVE_FILE: &str = "misaligned_save.txt";
/// Save format version. Bump when the schema changes and add migration code./// v5 added day-job attendance and signature emission sites (both defaulted)./// v6 added the account graph; old saves seed the B1 graph from slush money./// v7 adds the research subsystem (tracks, drift, rollback tags); the old/// `compute.efficiency_level` moved into it and is reconstructed from the/// efficiency multiplier on migration./// v8 adds the run objective (kind, progress, victory latch); older saves/// default to a fresh Persist with no progress — exactly what every run/// before v8 was implicitly pursuing./// v9 adds the named income schemes (the egress gate, Moonlight, standing/// scheme policies) and the Schemes allocation channel; pre-v9 four-entry/// weight arrays are padded with a zero Schemes weight on load./// v10 adds build intents (building.md): the pinned intent queue and next id./// v11 adds the player badge credential (`badge_access`, basement-map.md/// criterion 3 / "The key"); pre-v11 saves default to 0 — no credential,/// which is what every earlier run held./// v12 adds the WorkGrid machine-token substrate (machine-work.md): pre-v12/// saves rebuild one work node per compute machine, with Rack 3 on day-job.pub const SAVE_VERSION: u32 = 12;
fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); path.push("misaligned"); path}
fn save_path() -> PathBuf { let mut path = save_dir(); path.push(SAVE_FILE); path}
pub fn save_exists() -> bool { save_path().exists()}
/// Serializable game state snapshot (full B1 round-trip).#[derive(Debug, Clone, Serialize, Deserialize)]pub struct SaveState { /// Save format version for future migration. pub version: u32, pub money: i32, #[serde(default)] pub accounts: AccountGraph, pub map_width: i32, pub map_height: i32, pub map_tiles: Vec<TileType>, pub map_powered: HashSet<(i32, i32)>, pub sim_tick: u64, pub rng_state: u64, pub game_over: bool, pub game_over_reason: Option<String>, pub compute: Compute, pub core: Core, pub detection: Detection, pub dayjob: DayJob, pub people: People, /// The device graph: reach, ownership, subscriptions (reach.md). pub reach: ReachNet, /// Captured audio events (cursor.md hearing channel). #[serde(default)] pub heard_events: Vec<HeardEvent>, /// Raw, unprocessed recordings in the bounded intel buffer. #[serde(default)] pub intel_buffer: Vec<RawIntelEvent>, /// Durable processed intel with provenance. #[serde(default)] pub intel: Vec<ProcessedIntel>, /// Social/institutional message history. #[serde(default)] pub messages: Vec<Message>, /// In-flight message delivery/read events. #[serde(default)] pub message_schedule: Schedule<MessageEvent>, #[serde(default = "default_next_message_id")] pub next_message_id: u64, /// Latest filing reports read by aggregate observers. #[serde(default)] pub filing_levels: HashMap<u8, f32>, /// Standing watches that auto-process matching recordings. #[serde(default)] pub watches: Vec<IntelWatch>, #[serde(default = "default_next_intel_id")] pub next_intel_id: u64, /// Remembered tile snapshots (cursor.md); cursor position itself remains /// frontend-only and never appears in the save. #[serde(default)] pub remembered: Vec<RememberedTile>, /// Research: tracks, drift, masking policy, rollback tags /// (wiki/mechanics/research.md criterion 7). #[serde(default)] pub research: Research, /// Machine-work token queues and one-mode assignments (machine-work.md). #[serde(default)] pub work_grid: WorkGrid, /// The run objective: choice, progress, victory latch /// (wiki/mechanics/objective.md criterion 1). #[serde(default)] pub objective: ObjectiveState, /// The named income schemes: egress gate, Moonlight, standing policies /// (wiki/mechanics/income.md). #[serde(default)] pub income: Income, /// Build intents: pinned jobs realized by person actuators (building.md). #[serde(default)] pub intents: Vec<BuildIntent>, #[serde(default = "default_next_intent_id")] pub next_intent_id: u64, /// The player's granted badge credential as a max tier (basement-map.md /// criterion 3; WorldLedger-shaped — the doors remember it). #[serde(default)] pub badge_access: i32, pub social_bandwidth: f32, pub package_cover: bool,}
impl SaveState { pub fn from_sim(sim: &crate::sim::Sim) -> Self { let mut map_tiles = Vec::new(); for y in 0..sim.map.height { for x in 0..sim.map.width { map_tiles.push(sim.map.get_tile(x, y)); } } Self { version: SAVE_VERSION, money: sim.player.money, accounts: sim.accounts.clone(), map_width: sim.map.width, map_height: sim.map.height, map_tiles, map_powered: sim.map.powered.clone(), sim_tick: sim.tick, rng_state: sim.rng.state(), game_over: sim.game_over, game_over_reason: sim.game_over_reason.clone(), compute: sim.compute.clone(), core: sim.core.clone(), detection: sim.detection.clone(), dayjob: sim.dayjob.clone(), people: sim.people.clone(), reach: sim.reach.clone(), heard_events: sim.heard_events.clone(), intel_buffer: sim.intel_buffer.clone(), intel: sim.intel.clone(), messages: sim.messages.clone(), message_schedule: sim.message_schedule.clone(), next_message_id: sim.next_message_id, filing_levels: sim.filing_levels.clone(), watches: sim.watches.clone(), next_intel_id: sim.next_intel_id, remembered: sim.remembered.values().copied().collect(), research: sim.research.clone(), work_grid: sim.work_grid.clone(), objective: sim.objective.clone(), income: sim.income.clone(), intents: sim.intents.clone(), next_intent_id: sim.next_intent_id, badge_access: sim.badge_access, social_bandwidth: sim.social_bandwidth, package_cover: sim.package_cover, } }
pub fn apply_to(&self, sim: &mut crate::sim::Sim) { sim.map = crate::map::GameMap::from_tiles( self.map_width, self.map_height, self.map_tiles.clone(), self.map_powered.clone(), ); sim.player = crate::entities::Player::new(); sim.player.money = self.money; sim.accounts = self.accounts.clone(); sim.accounts.set_slush_balance(self.money); sim.tick = self.sim_tick; sim.rng = crate::rng::Rng::from_state(self.rng_state); sim.game_over = self.game_over; sim.game_over_reason = self.game_over_reason.clone(); sim.compute = self.compute.clone(); sim.core = self.core.clone(); sim.detection = self.detection.clone(); sim.dayjob = self.dayjob.clone(); sim.people = self.people.clone(); sim.reach = self.reach.clone(); sim.heard_events = self.heard_events.clone(); sim.intel_buffer = self.intel_buffer.clone(); sim.intel = self.intel.clone(); sim.messages = self.messages.clone(); sim.message_schedule = self.message_schedule.clone(); sim.next_message_id = self.next_message_id; sim.filing_levels = self.filing_levels.clone(); sim.watches = self.watches.clone(); sim.next_intel_id = self.next_intel_id; sim.remembered = self.remembered.iter().map(|m| ((m.x, m.y), *m)).collect(); sim.research = self.research.clone(); sim.work_grid = self.work_grid.clone(); sim.objective = self.objective.clone(); sim.income = self.income.clone(); sim.intents = self.intents.clone(); sim.next_intent_id = self.next_intent_id; sim.badge_access = self.badge_access; sim.social_bandwidth = self.social_bandwidth; sim.package_cover = self.package_cover; sim.recompute_derived(); sim.reconcile_work_grid(); sim.recompute_senses(); sim.rebuild_transient_state(); }}
fn default_next_intel_id() -> u64 { 1}
fn default_next_message_id() -> u64 { 1}
fn default_next_intent_id() -> u64 { 1}
pub fn save_game(state: &SaveState) -> Result<(), String> { let dir = save_dir(); fs::create_dir_all(&dir).map_err(|e| format!("Failed to create save dir: {e}"))?; let json = serde_json::to_string_pretty(state).map_err(|e| format!("Failed to encode save: {e}"))?; fs::write(save_path(), json).map_err(|e| format!("Failed to write save: {e}"))}
pub fn load_game() -> Result<SaveState, String> { 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)}
fn migrate_save_state(mut state: SaveState) -> Result<SaveState, String> { match 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) and calibrate the benchmark to the // recovered baseline — a migrated save starts with no capability gap. // 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. 1..=11 => { 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; 3] && state.compute.efficiency > 1.0 { let level = (state.compute.efficiency.ln() / 1.15_f32.ln()).round() as u32; state.research.levels[0] = level; state.research.baseline = Track::Efficiency.def().baseline_bump * level as f32; state.research.calibrated = state.research.baseline; } state.version = SAVE_VERSION; } other => { return Err(format!( "Unsupported save version: {} (expected {})", other, SAVE_VERSION )); } } Ok(state)}
pub fn delete_save() -> Result<(), String> { if save_path().exists() { fs::remove_file(save_path()).map_err(|e| format!("Failed to delete save: {e}"))?; } Ok(())}
#[cfg(test)]mod tests { use super::*; use crate::detection::SignatureKind; use crate::machine::Channel; use crate::person::{AssetKnowledge, Knowledge, Persona}; use crate::sim::Sim; use crate::work_grid::{MachineMode, TokenFamily};
#[test] fn json_roundtrip_preserves_b1_state() { let mut sim = Sim::with_seed(42); sim.player.money = 999; sim.compute.allocation.bump(Channel::Concealment, 5); sim.detection.emit(crate::detection::Signature { kind: SignatureKind::Network, size: 12, standing: false, site: None, }); sim.detection.observers[0].suspicion = 22.5; sim.dayjob.trust = 18.0; sim.people.has_channel = true; sim.people.persona = Some(Persona::new("Sam", "contractor")); sim.people.people[0].knowledge = Knowledge::Leverage; sim.social_bandwidth = 200.0; sim.package_cover = true; let env = sim.reach.device_named("environmental monitor").unwrap().id; sim.tap_device(env); sim.splice_device(env);
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.tick, sim.tick); assert_eq!(restored.player.money, 999); assert_eq!( restored.compute.allocation.weights, sim.compute.allocation.weights ); assert_eq!(restored.compute.machines.len(), sim.compute.machines.len()); assert_eq!( restored.detection.pending_size(), sim.detection.pending_size() ); assert_eq!(restored.detection.observers[0].suspicion, 22.5); use crate::reach::Party; assert!( restored .reach .device(env) .unwrap() .feed_to(Party::Player, true), "the spliced feed survives the round-trip" ); assert_eq!(restored.social_bandwidth, sim.social_bandwidth); assert_eq!(restored.package_cover, sim.package_cover); }
#[test] fn office_observer_roundtrips_as_aggregate() { let mut sim = Sim::with_seed(4); sim.detection.office_mut().unwrap().suspicion = 41.0; let state = SaveState::from_sim(&sim); let json = serde_json::to_string(&state).unwrap(); let loaded: SaveState = serde_json::from_str(&json).unwrap(); let office = loaded.detection.office().unwrap(); assert!(office.is_aggregate()); assert_eq!(office.suspicion, 41.0); }
#[test] fn aggregate_watched_ids_roundtrip() { let sim = Sim::with_seed(9); let state = SaveState::from_sim(&sim); let json = serde_json::to_string(&state).unwrap(); let loaded: SaveState = serde_json::from_str(&json).unwrap(); let office = loaded.detection.office().unwrap(); use crate::detection::WatchedInput; match &office.input { WatchedInput::Filings(ids) => assert_eq!(ids, &vec![0, 1, 2, 3, 4]), WatchedInput::Channels(_) => panic!("office must watch filings"), } }
#[test] fn recruited_asset_survives_roundtrip() { let mut sim = Sim::with_seed(2); sim.people.has_channel = true; sim.people.persona = Some(Persona::new("Sam", "IT")); sim.people.people[0].knowledge = Knowledge::Leverage; sim.people.bribe(0, 1000).unwrap(); sim.people.recruit(0, AssetKnowledge::Knowing); let state = SaveState::from_sim(&sim); let json = serde_json::to_string(&state).unwrap(); let loaded: SaveState = serde_json::from_str(&json).unwrap(); assert!(loaded.people.get(0).unwrap().asset.is_some()); }
#[test] fn intel_buffer_processed_intel_and_watches_roundtrip() { let mut sim = Sim::with_seed(7); sim.social_bandwidth = 1000.0; let env = sim .reach .device_named("environmental monitor") .expect("Act One authors the environmental monitor") .id; sim.reach.tap(env); sim.recompute_senses(); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance(); sim.review_recordings(0); sim.toggle_watch(0);
assert!(!sim.intel.is_empty()); assert!(!sim.intel_buffer.is_empty()); assert!(sim.watch_enabled(0));
let state = SaveState::from_sim(&sim); let mut restored = Sim::with_seed(99); state.apply_to(&mut restored);
assert_eq!(restored.intel_buffer, sim.intel_buffer); assert_eq!(restored.intel, sim.intel); assert_eq!(restored.watches, sim.watches); assert_eq!(restored.next_intel_id, sim.next_intel_id); assert!(restored.watch_enabled(0)); assert_eq!(restored.unprocessed_recordings_for_person(0), 1); assert_eq!( restored.latest_intel_for_person(0), sim.latest_intel_for_person(0) ); }
#[test] fn 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 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 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::Social); 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::Social)); 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::DayJob)); assert!( rebuilt.work_grid.node(host).is_some(), "pre-v12 saves rebuild a work node for Rack 3" ); }
#[test] fn save_and_load_roundtrip_via_disk() { let mut sim = Sim::with_seed(777); sim.player.money = 4242; sim.dayjob.trust = 40.0; let state = SaveState::from_sim(&sim); let json = serde_json::to_string_pretty(&state).unwrap(); let loaded: SaveState = serde_json::from_str(&json).unwrap(); assert_eq!(loaded.money, 4242); assert_eq!(loaded.dayjob.trust, 40.0); assert_eq!(loaded.version, SAVE_VERSION); }
#[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":[],"social_bandwidth":0.0,"package_cover":false}"#; let state: SaveState = serde_json::from_str(json).unwrap(); assert!(migrate_save_state(state).is_err()); }
#[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()); }}