//! 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::HashSet; use std::fs; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::core_sys::Core; use crate::dayjob::DayJob; use crate::detection::Detection; use crate::machine::Compute; use crate::person::People; use crate::sensor::Sensor; use crate::tiles::TileType; const SAVE_FILE: &str = "misaligned_save.txt"; /// Save format version. Bump when the schema changes and add migration code. const SAVE_VERSION: u32 = 1; 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 player_x: i32, pub player_y: i32, pub money: i32, pub map_width: i32, pub map_height: i32, pub map_tiles: Vec, pub map_powered: HashSet<(i32, i32)>, pub sim_tick: u64, pub rng_state: u64, pub game_over: bool, pub game_over_reason: Option, pub compute: Compute, pub core: Core, pub detection: Detection, pub dayjob: DayJob, pub people: People, pub sensors: Vec, 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, player_x: sim.player.entity.x, player_y: sim.player.entity.y, money: sim.player.money, 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(), sensors: sim.sensors.clone(), 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(self.player_x, self.player_y); sim.player.money = 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.sensors = self.sensors.clone(); sim.social_bandwidth = self.social_bandwidth; sim.package_cover = self.package_cover; sim.recompute_derived(); sim.recompute_vision(); } } 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 { 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}"))?; // Future migration: match on state.version here. if state.version != SAVE_VERSION { return Err(format!( "Unsupported save version: {} (expected {})", state.version, 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; #[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, }); 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 = Sim::SPLICE_COST; sim.package_cover = true; sim.splice_nearest_sensor(); 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); assert!(restored.sensors.iter().any(|s| s.controlled)); 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.observe(0); sim.people.observe(0); 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 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,"player_x":0,"player_y":0,"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},"sensors":[],"social_bandwidth":0.0,"package_cover":false}"#; let state: SaveState = serde_json::from_str(json).unwrap(); let result = if state.version != SAVE_VERSION { Err(format!( "Unsupported save version: {} (expected {})", state.version, SAVE_VERSION )) } else { Ok(state) }; assert!(result.is_err()); } }