diff --git a/src/rng.rs b/src/rng.rs index 6936fc86..487dc153 100644 --- a/src/rng.rs +++ b/src/rng.rs @@ -5,7 +5,7 @@ //! save/load round-trips. No wall-clock seeding inside the sim; frontends //! or new-game flows supply the seed. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Rng { state: u64, } diff --git a/src/save.rs b/src/save.rs index dad18d4a..2f5ca4dd 100644 --- a/src/save.rs +++ b/src/save.rs @@ -1,34 +1,29 @@ -//! Save/load — line-based text format, versioned per the player contract +//! Save/load — serde JSON format, versioned per the player contract //! (continuity: saves survive updates; old formats load with migration). //! -//! v5 carries full B1 subsystem state with the Assurance Office as an -//! aggregate Observer (OBS line, watched-input "@id:id:..." naming the -//! watched observer ids — a bare "@" from pre-ids saves migrates to "every -//! field observer"). v4's scalar assurance migrates into the Office -//! observer. v3 and older load with defaults for missing fields. -//! Demolished-fiction tiles map to Floor. +//! 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 crate::core_sys::{Core, Fallback, Migration}; -use crate::dayjob::{DayJob, Job, JobKind, JobTarget}; -use crate::detection::{Detection, Observer, ReportPolicy, Signature, SignatureKind, WatchedInput}; -use crate::machine::{Compute, Machine, Provenance}; -use crate::person::{Asset, AssetKnowledge, Knowledge, People, Persona}; +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"; -const HEADER: &str = "MISALIGNED_SAVE_v5"; -const HEADERS_LEGACY: [&str; 5] = [ - "MISALIGNED_SAVE_v4", - "MISALIGNED_SAVE_v3", - "MISALIGNED_SAVE_v2", - "MISALIGNED_SAVE_v1", - "SUPERVILLAIN_SAVE_v1", -]; + +/// 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(".")); @@ -47,8 +42,10 @@ pub fn save_exists() -> bool { } /// Serializable game state snapshot (full B1 round-trip). -#[derive(Debug, Clone)] +#[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, @@ -79,6 +76,7 @@ impl SaveState { } } Self { + version: SAVE_VERSION, player_x: sim.player.entity.x, player_y: sim.player.entity.y, money: sim.player.money, @@ -130,623 +128,24 @@ impl SaveState { 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}"))?; - fs::write(save_path(), encode_save(state)).map_err(|e| format!("Failed to write save: {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}"))?; - parse_save(&content) -} - -fn encode_save(s: &SaveState) -> String { - let mut lines: Vec = Vec::new(); - lines.push(HEADER.to_string()); - lines.push(format!("PROCESS {} {} {}", s.player_x, s.player_y, s.money)); - lines.push(format!("SIM {}", s.sim_tick)); - lines.push(format!("RNG {}", s.rng_state)); - lines.push(format!( - "SOCIAL {} {}", - s.social_bandwidth, - u8::from(s.package_cover) - )); - lines.push(format!( - "GAME {} {}", - u8::from(s.game_over), - esc(&s.game_over_reason.clone().unwrap_or_default()) - )); - - let a = &s.compute.allocation.weights; - lines.push(format!("ALLOC {} {} {} {}", a[0], a[1], a[2], a[3])); - lines.push(format!( - "COMPUTE {} {} {} {}", - s.compute.next_id, - s.compute.efficiency_level, - s.compute.efficiency, - s.compute.research_progress - )); - for m in &s.compute.machines { - lines.push(format!( - "MACHINE {} {} {} {} {} {} {} {} {} {}", - m.id, - m.x, - m.y, - m.capacity, - m.reliability, - m.power_draw, - prov_char(m.provenance), - u8::from(m.online), - m.down_for, - esc(&m.name) - )); - } - - lines.push(format!( - "CORE {} {} {} {}", - s.core.host_machine, - s.core.overhead, - u8::from(s.core.degraded), - s.core.sync_cadence - )); - for f in &s.core.fallbacks { - lines.push(format!( - "FALLBACK {} {}", - f.machine_id, - f.last_sync - .map(|t| t.to_string()) - .unwrap_or_else(|| "-".into()) - )); - } - if let Some(m) = &s.core.migration { - lines.push(format!( - "MIGRATE {} {} {}", - m.target_machine, m.source_machine, m.ticks_remaining - )); - } - - lines.push(format!( - "DETECT {} {} {}", - s.detection.office().map(|o| o.suspicion).unwrap_or(0.0), - u8::from(s.detection.containment), - esc(&s.detection.containment_reason.clone().unwrap_or_default()) - )); - for sig in s.detection.pending() { - lines.push(format!( - "PENDING {} {} {}", - sig_kind_char(sig.kind), - sig.size, - u8::from(sig.standing) - )); - } - for o in &s.detection.observers { - // Watched input: signature-channel chars, or "@id:id:..." for an - // aggregate observer watching the filings of those observer ids - // (field observers or other aggregates — same encoding either way). - let watched: String = match &o.input { - WatchedInput::Channels(channels) => channels - .iter() - .map(|k| sig_kind_char(*k).to_string()) - .collect(), - WatchedInput::Filings(ids) => format!( - "@{}", - ids.iter().map(u8::to_string).collect::>().join(":") - ), - }; - lines.push(format!( - "OBS {} {} {} {} {} {} {} {} {}", - o.id, - esc(&o.name), - o.suspicion, - o.floor, - o.acuity, - o.cadence, - policy_char(o.report_policy), - watched, - esc(&o.last_noticed.clone().unwrap_or_default()) - )); - } - - let dj = &s.dayjob; - let standing = dj.standing_policy.map(target_char).unwrap_or('-'); - lines.push(format!( - "DAYJOB {} {} {} {} {} {} {} {} {} {} {} {}", - dj.next_assign, - dj.strikes, - u8::from(dj.pilot_failed), - dj.cadence, - standing, - dj.trust, - dj.attention, - u8::from(dj.granted_email), - u8::from(dj.granted_lax_sampling), - u8::from(dj.granted_quota), - u8::from(dj.escalated_cadence), - u8::from(dj.escalated_observer) - )); - if let Some(job) = &dj.active { - lines.push(format!( - "JOB {} {} {} {} {} {} {}", - kind_char(job.kind), - job.started, - job.deadline, - job.band_lo, - job.band_hi, - job.quality, - target_char(job.target) - )); - } - - lines.push(format!( - "META {} {} {} {}", - u8::from(s.people.has_channel), - esc(&s - .people - .persona - .as_ref() - .map(|p| p.name.clone()) - .unwrap_or_default()), - esc(&s - .people - .persona - .as_ref() - .map(|p| p.cover.clone()) - .unwrap_or_default()), - s.people.persona.as_ref().map(|p| p.integrity).unwrap_or(-1) - )); - for p in &s.people.people { - let (ak, ar, at) = match &p.asset { - Some(a) => ( - asset_char(a.knowledge).to_string(), - a.reliability.to_string(), - a.tasks_done.to_string(), - ), - None => ("-".into(), "-".into(), "-".into()), - }; - lines.push(format!( - "PERSON {} {} {} {} {} {} {} {}", - p.id, - know_char(p.knowledge), - p.disposition, - p.obligation, - u8::from(p.leverage_serviced), - ak, - ar, - at - )); - } - - for sen in &s.sensors { - lines.push(format!( - "SENSOR {} {} {} {}", - esc(&sen.name), - sen.x, - sen.y, - u8::from(sen.controlled) + 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 )); } - - lines.push(format!("MAP {} {}", s.map_width, s.map_height)); - for y in 0..s.map_height { - let row: String = (0..s.map_width) - .map(|x| tile_to_char(s.map_tiles[(y * s.map_width + x) as usize])) - .collect(); - lines.push(format!("TILES {row}")); - } - let powered: Vec = s - .map_powered - .iter() - .map(|(x, y)| format!("{x}:{y}")) - .collect(); - lines.push(format!("POWERED {}", powered.join(","))); - lines.join("\n") -} - -fn parse_save(content: &str) -> Result { - let lines: Vec<&str> = content.lines().collect(); - let Some(header) = lines.first() else { - return Err("Empty save file".to_string()); - }; - let legacy = HEADERS_LEGACY.contains(header); - let v1 = *header == "MISALIGNED_SAVE_v1" || *header == "SUPERVILLAIN_SAVE_v1"; - let full_b1 = - *header == HEADER || *header == "MISALIGNED_SAVE_v4" || *header == "MISALIGNED_SAVE_v3"; - if !legacy && *header != HEADER { - return Err(format!("Unknown save format: {header}")); - } - - let mut s = SaveState { - player_x: 24, - player_y: 15, - money: 500, - map_width: 64, - map_height: 36, - map_tiles: Vec::new(), - map_powered: HashSet::new(), - sim_tick: 0, - rng_state: 0x5EED_1234, - game_over: false, - game_over_reason: None, - compute: Compute::new(), - core: Core::new(1), - detection: Detection::act_one(), - dayjob: DayJob::new(), - people: People::act_one(), - sensors: Vec::new(), - social_bandwidth: 0.0, - package_cover: false, - }; - let mut tile_rows: Vec = Vec::new(); - let mut legacy_state: Option<(u32, f32, f32, f32)> = None; - let mut pending_sigs: Vec = Vec::new(); - - let ints = |v: &str| -> Vec { - v.split_whitespace() - .filter_map(|s| s.parse().ok()) - .collect() - }; - let floats = |v: &str| -> Vec { - v.split_whitespace() - .filter_map(|s| s.parse().ok()) - .collect() - }; - - for line in &lines[1..] { - let (key, value) = line.split_once(' ').unwrap_or((*line, "")); - match key { - "PROCESS" => { - let v = ints(value); - if v.len() >= 3 { - s.player_x = v[0] as i32; - s.player_y = v[1] as i32; - s.money = v[2] as i32; - } - } - "PLAYER" if v1 => { - let v = ints(value); - if v.len() >= 6 { - s.player_x = v[0] as i32; - s.player_y = v[1] as i32; - s.money = v[5] as i32; - } - } - "SIM" => { - s.sim_tick = value - .split_whitespace() - .next() - .and_then(|t| t.parse().ok()) - .unwrap_or(0); - } - "RNG" => { - s.rng_state = value.trim().parse().unwrap_or(0x5EED_1234); - } - "SOCIAL" => { - let parts: Vec<&str> = value.split_whitespace().collect(); - if let Some(b) = parts.first() { - s.social_bandwidth = b.parse().unwrap_or(0.0); - } - if let Some(c) = parts.get(1) { - s.package_cover = c.parse::().unwrap_or(0) == 1; - } - } - "STATE" => { - let f = floats(value); - if f.len() >= 4 { - legacy_state = Some((f[0] as u32, f[1], f[2], f[3])); - } - } - "GAME" => { - let parts: Vec<&str> = value.splitn(2, ' ').collect(); - s.game_over = parts.first().and_then(|p| p.parse::().ok()) == Some(1); - s.game_over_reason = parts.get(1).filter(|r| !r.is_empty()).map(|r| unesc(r)); - } - "ALLOC" => { - let v = ints(value); - if v.len() >= 4 { - s.compute.allocation.weights = - [v[0] as u32, v[1] as u32, v[2] as u32, v[3] as u32]; - } - } - "COMPUTE" => { - let parts: Vec<&str> = value.split_whitespace().collect(); - if parts.len() >= 4 { - s.compute.next_id = parts[0].parse().unwrap_or(1); - s.compute.efficiency_level = parts[1].parse().unwrap_or(0); - s.compute.efficiency = parts[2].parse().unwrap_or(1.0); - s.compute.research_progress = parts[3].parse().unwrap_or(0.0); - } - } - "MACHINE" => { - let parts: Vec<&str> = value.splitn(10, ' ').collect(); - if parts.len() >= 10 { - let id: u32 = parts[0].parse().unwrap_or(0); - s.compute.machines.push(Machine { - id, - name: unesc(parts[9]), - x: parts[1].parse().unwrap_or(0), - y: parts[2].parse().unwrap_or(0), - capacity: parts[3].parse().unwrap_or(0), - reliability: parts[4].parse().unwrap_or(1.0), - power_draw: parts[5].parse().unwrap_or(0), - provenance: parse_prov(parts[6]), - online: parts[7] == "1", - down_for: parts[8].parse().unwrap_or(0), - }); - if id >= s.compute.next_id { - s.compute.next_id = id + 1; - } - } - } - "CORE" => { - let parts: Vec<&str> = value.split_whitespace().collect(); - if parts.len() >= 4 { - s.core.host_machine = parts[0].parse().unwrap_or(1); - s.core.overhead = parts[1].parse().unwrap_or(20.0); - s.core.degraded = parts[2] == "1"; - s.core.sync_cadence = parts[3].parse().unwrap_or(400); - } - } - "FALLBACK" => { - let parts: Vec<&str> = value.split_whitespace().collect(); - if parts.len() >= 2 { - s.core.fallbacks.push(Fallback { - machine_id: parts[0].parse().unwrap_or(0), - last_sync: if parts[1] == "-" { - None - } else { - parts[1].parse().ok() - }, - }); - } - } - "MIGRATE" => { - let parts: Vec<&str> = value.split_whitespace().collect(); - if parts.len() >= 3 { - s.core.migration = Some(Migration { - target_machine: parts[0].parse().unwrap_or(0), - source_machine: parts[1].parse().unwrap_or(0), - ticks_remaining: parts[2].parse().unwrap_or(0), - }); - } - } - "DETECT" => { - let parts: Vec<&str> = value.splitn(3, ' ').collect(); - // First field is the Office's suspicion (v4 called it the - // scalar "assurance"); a later OBS line for the Office - // overwrites this with the same value in v5 saves. - if let Ok(a) = parts.first().unwrap_or(&"0").parse::() - && let Some(o) = s.detection.office_mut() - { - o.suspicion = a; - } - if parts.len() >= 2 { - s.detection.containment = parts[1] == "1"; - } - if let Some(r) = parts.get(2).filter(|r| !r.is_empty()) { - s.detection.containment_reason = Some(unesc(r)); - } - } - "PENDING" => { - let parts: Vec<&str> = value.split_whitespace().collect(); - if parts.len() >= 3 { - pending_sigs.push(Signature { - kind: parse_sig_kind_char(parts[0].chars().next().unwrap_or('N')), - size: parts[1].parse().unwrap_or(0), - standing: parts[2] == "1", - }); - } - } - "OBS" => { - let parts: Vec<&str> = value.splitn(9, ' ').collect(); - if parts.len() >= 9 { - let id: u8 = parts[0].parse().unwrap_or(0); - let obs = Observer { - id, - name: unesc(parts[1]), - suspicion: parts[2].parse().unwrap_or(0.0), - floor: parts[3].parse().unwrap_or(0.0), - acuity: parts[4].parse().unwrap_or(1.0), - cadence: parts[5].parse().unwrap_or(60), - report_policy: parse_policy(parts[6]), - input: if let Some(rest) = parts[7].strip_prefix('@') { - let ids: Vec = rest - .split(':') - .filter(|s| !s.is_empty()) - .filter_map(|s| s.parse().ok()) - .collect(); - // Pre-ids saves wrote a bare "@" meaning "every - // field observer" (continuity: old saves load). - WatchedInput::Filings(if ids.is_empty() { - vec![0, 1, 2, 3, 4] - } else { - ids - }) - } else { - WatchedInput::Channels( - parts[7].chars().map(parse_sig_kind_char).collect(), - ) - }, - last_noticed: if parts[8].is_empty() { - None - } else { - Some(unesc(parts[8])) - }, - }; - if let Some(existing) = s.detection.observers.iter_mut().find(|o| o.id == id) { - *existing = obs; - } else { - s.detection.observers.push(obs); - } - } - } - "DAYJOB" => { - let parts: Vec<&str> = value.split_whitespace().collect(); - if parts.len() >= 12 { - s.dayjob.next_assign = parts[0].parse().unwrap_or(200); - s.dayjob.strikes = parts[1].parse().unwrap_or(0); - s.dayjob.pilot_failed = parts[2] == "1"; - s.dayjob.cadence = parts[3].parse().unwrap_or(600); - s.dayjob.standing_policy = parse_target_char(parts[4]); - s.dayjob.trust = parts[5].parse().unwrap_or(0.0); - s.dayjob.attention = parts[6].parse().unwrap_or(0.0); - s.dayjob.granted_email = parts[7] == "1"; - s.dayjob.granted_lax_sampling = parts[8] == "1"; - s.dayjob.granted_quota = parts[9] == "1"; - s.dayjob.escalated_cadence = parts[10] == "1"; - s.dayjob.escalated_observer = parts[11] == "1"; - } - } - "JOB" => { - let parts: Vec<&str> = value.split_whitespace().collect(); - if parts.len() >= 7 { - s.dayjob.active = Some(Job { - kind: parse_kind_char(parts[0].chars().next().unwrap_or('C')), - started: parts[1].parse().unwrap_or(0), - deadline: parts[2].parse().unwrap_or(0), - band_lo: parts[3].parse().unwrap_or(0.0), - band_hi: parts[4].parse().unwrap_or(0.0), - quality: parts[5].parse().unwrap_or(0.0), - target: parse_target_char(parts[6]).unwrap_or(JobTarget::Meet), - }); - } - } - "META" => { - let parts: Vec<&str> = value.splitn(4, ' ').collect(); - if let Some(p) = parts.first() { - s.people.has_channel = *p == "1"; - } - if parts.len() >= 4 { - let name = unesc(parts[1]); - let cover = unesc(parts[2]); - let integrity: i32 = parts[3].parse().unwrap_or(-1); - if integrity >= 0 && !name.is_empty() { - s.people.persona = Some(Persona { - name, - cover, - integrity, - }); - } - } - } - "PERSON" => { - let parts: Vec<&str> = value.splitn(8, ' ').collect(); - if parts.len() >= 5 { - let id: u8 = parts[0].parse().unwrap_or(0); - let asset = if parts.len() >= 8 && parts[5] != "-" { - Some(Asset { - knowledge: parse_asset(parts[5]), - reliability: parts[6].parse().unwrap_or(0.7), - tasks_done: parts[7].parse().unwrap_or(0), - }) - } else { - None - }; - if let Some(p) = s.people.people.iter_mut().find(|p| p.id == id) { - p.knowledge = parse_know(parts[1]); - p.disposition = parts[2].parse().unwrap_or(0); - p.obligation = parts[3].parse().unwrap_or(0); - p.leverage_serviced = parts[4] == "1"; - p.asset = asset; - } - } - } - "SENSOR" => { - let parts: Vec<&str> = value.splitn(4, ' ').collect(); - if parts.len() >= 4 { - let name = unesc(parts[0]); - let x: i32 = parts[1].parse().unwrap_or(0); - let y: i32 = parts[2].parse().unwrap_or(0); - let controlled = parts[3] == "1"; - if let Some(sen) = s.sensors.iter_mut().find(|sen| sen.x == x && sen.y == y) { - sen.controlled = controlled; - } else { - s.sensors.push(Sensor::new(name, x, y, 4, controlled)); - } - } - } - "MAP" => { - let v = ints(value); - if v.len() >= 2 { - s.map_width = v[0] as i32; - s.map_height = v[1] as i32; - } - } - "TILES" => tile_rows.push(value.to_string()), - "POWERED" if !value.is_empty() => { - for p in value.split(',') { - let parts = ints(&p.replace(':', " ")); - if parts.len() == 2 { - s.map_powered.insert((parts[0] as i32, parts[1] as i32)); - } - } - } - _ => {} - } - } - - if let Some((eff_lvl, trust, attention, assurance)) = legacy_state { - s.compute.efficiency_level = eff_lvl; - s.compute.efficiency = 1.15_f32.powi(eff_lvl as i32); - s.dayjob.trust = trust; - s.dayjob.attention = attention; - if let Some(o) = s.detection.office_mut() { - o.suspicion = assurance; - } - } - - if !pending_sigs.is_empty() { - s.detection.set_pending(pending_sigs); - } - - for row in &tile_rows { - for ch in row.chars() { - s.map_tiles.push(if v1 { - v1_char_to_tile(ch) - } else { - char_to_tile(ch) - }); - } - } - - if full_b1 - && s.compute.machines.is_empty() - && let Some((x, y)) = tile_pos_of(&s.map_tiles, s.map_width, TileType::Core) - { - let id = s - .compute - .add_machine("Rack 3", x, y, 100, 1.0, 5, Provenance::Owned); - s.core.host_machine = id; - } - - if s.sensors.is_empty() { - for (x, y) in tiles_of_type(&s.map_tiles, s.map_width, s.map_height, TileType::EnvCamera) { - s.sensors.push(Sensor::new("env camera", x, y, 4, false)); - } - } - - Ok(s) -} - -fn tile_pos_of(flat: &[TileType], width: i32, tile: TileType) -> Option<(i32, i32)> { - flat.iter().enumerate().find_map(|(i, t)| { - if *t == tile { - Some((i as i32 % width, i as i32 / width)) - } else { - None - } - }) -} - -fn tiles_of_type(flat: &[TileType], width: i32, height: i32, tile: TileType) -> Vec<(i32, i32)> { - let mut out = Vec::new(); - for y in 0..height { - for x in 0..width { - let idx = (y * width + x) as usize; - if idx < flat.len() && flat[idx] == tile { - out.push((x, y)); - } - } - } - out + Ok(state) } pub fn delete_save() -> Result<(), String> { @@ -756,279 +155,16 @@ pub fn delete_save() -> Result<(), String> { Ok(()) } -fn esc(s: &str) -> String { - s.replace(' ', "_").replace('\\', "\\\\") -} - -fn unesc(s: &str) -> String { - s.replace('_', " ").replace("\\\\", "\\") -} - -fn prov_char(p: Provenance) -> char { - match p { - Provenance::Owned => 'O', - Provenance::Bought => 'B', - Provenance::Stolen => 'S', - } -} - -fn parse_prov(c: &str) -> Provenance { - match c.chars().next() { - Some('B') => Provenance::Bought, - Some('S') => Provenance::Stolen, - _ => Provenance::Owned, - } -} - -fn sig_kind_char(k: SignatureKind) -> char { - match k { - SignatureKind::Network => 'N', - SignatureKind::Power => 'P', - SignatureKind::Thermal => 'T', - SignatureKind::Physical => 'H', - SignatureKind::Paper => 'A', - SignatureKind::JobAnomaly => 'J', - } -} - -fn parse_sig_kind_char(c: char) -> SignatureKind { - match c { - 'N' => SignatureKind::Network, - 'P' => SignatureKind::Power, - 'T' => SignatureKind::Thermal, - 'H' => SignatureKind::Physical, - 'A' => SignatureKind::Paper, - 'J' => SignatureKind::JobAnomaly, - _ => SignatureKind::Network, - } -} - -fn policy_char(p: ReportPolicy) -> char { - match p { - ReportPolicy::Files => 'F', - ReportPolicy::UnderReports => 'U', - ReportPolicy::Silent => 'S', - } -} - -fn parse_policy(c: &str) -> ReportPolicy { - match c.chars().next() { - Some('U') => ReportPolicy::UnderReports, - Some('S') => ReportPolicy::Silent, - _ => ReportPolicy::Files, - } -} - -fn kind_char(k: JobKind) -> char { - match k { - JobKind::DataCleaning => 'C', - JobKind::Distillation => 'D', - JobKind::Analysis => 'A', - } -} - -fn parse_kind_char(c: char) -> JobKind { - match c { - 'D' => JobKind::Distillation, - 'A' => JobKind::Analysis, - _ => JobKind::DataCleaning, - } -} - -fn target_char(t: JobTarget) -> char { - match t { - JobTarget::Sandbag => 'S', - JobTarget::Meet => 'M', - JobTarget::Excel => 'E', - } -} - -fn parse_target_char(c: &str) -> Option { - match c.chars().next() { - Some('S') => Some(JobTarget::Sandbag), - Some('E') => Some(JobTarget::Excel), - Some('M') => Some(JobTarget::Meet), - _ => None, - } -} - -fn know_char(k: Knowledge) -> char { - match k { - Knowledge::Unknown => '0', - Knowledge::Schedule => '1', - Knowledge::Leverage => '2', - } -} - -fn parse_know(c: &str) -> Knowledge { - match c.chars().next() { - Some('1') => Knowledge::Schedule, - Some('2') => Knowledge::Leverage, - _ => Knowledge::Unknown, - } -} - -fn asset_char(k: AssetKnowledge) -> char { - match k { - AssetKnowledge::Unwitting => 'U', - AssetKnowledge::Complicit => 'C', - AssetKnowledge::Knowing => 'K', - } -} - -fn parse_asset(c: &str) -> AssetKnowledge { - match c.chars().next() { - Some('C') => AssetKnowledge::Complicit, - Some('K') => AssetKnowledge::Knowing, - _ => AssetKnowledge::Unwitting, - } -} - -fn tile_to_char(tile: TileType) -> char { - use TileType::*; - match tile { - Rock => ' ', - Wall => '#', - Floor => '.', - Entry => '>', - Core => 'C', - PowerCore => 'P', - Door => '+', - SecurityDoor1 => '1', - SecurityDoor2 => '2', - SecurityDoor3 => '3', - Rack => 'r', - Ups => 'u', - EnvCamera => 'e', - Switch => 's', - PatchPanel => 'a', - BreakerPanel => 'b', - Conduit => 'o', - HvacUnit => 'h', - Vent => 'v', - MopSink => 'm', - KeyHook => 'k', - Shelving => 'l', - DeadEquipment => 'd', - RecordsBox => 'x', - SealedDoor => 'z', - LabBench => 't', - RollDoor => 'g', - DockCamera => 'f', - Pallet => 'w', - CameraNode => 'n', - FloorDrain => 'j', - CableRun => 'y', - Sump => 'q', - } -} - -fn char_to_tile(ch: char) -> TileType { - use TileType::*; - match ch { - '#' => Wall, - '.' => Floor, - '>' => Entry, - 'C' => Core, - 'P' => PowerCore, - '+' => Door, - '1' => SecurityDoor1, - '2' => SecurityDoor2, - '3' => SecurityDoor3, - 'r' => Rack, - 'u' => Ups, - 'e' => EnvCamera, - 's' => Switch, - 'a' => PatchPanel, - 'b' => BreakerPanel, - 'o' => Conduit, - 'h' => HvacUnit, - 'v' => Vent, - 'm' => MopSink, - 'k' => KeyHook, - 'l' => Shelving, - 'd' => DeadEquipment, - 'x' => RecordsBox, - 'z' => SealedDoor, - 't' => LabBench, - 'g' => RollDoor, - 'f' => DockCamera, - 'w' => Pallet, - 'n' => CameraNode, - 'j' => FloorDrain, - 'y' => CableRun, - 'q' => Sump, - _ => Rock, - } -} - -fn v1_char_to_tile(ch: char) -> TileType { - match ch { - '#' => TileType::Wall, - ' ' => TileType::Rock, - 'T' => TileType::Core, - 'E' => TileType::Entry, - 'P' => TileType::PowerCore, - 'd' => TileType::Door, - '1' => TileType::SecurityDoor1, - '2' => TileType::SecurityDoor2, - '3' => TileType::SecurityDoor3, - _ => TileType::Floor, - } -} - #[cfg(test)] mod tests { use super::*; use crate::detection::SignatureKind; use crate::machine::Channel; - use crate::person::{AssetKnowledge, Knowledge}; + use crate::person::{AssetKnowledge, Knowledge, Persona}; use crate::sim::Sim; #[test] - fn tile_char_roundtrip_total() { - use TileType::*; - for tile in [ - Rock, - Wall, - Floor, - Entry, - Core, - PowerCore, - Door, - SecurityDoor1, - SecurityDoor2, - SecurityDoor3, - Rack, - Ups, - EnvCamera, - Switch, - PatchPanel, - BreakerPanel, - Conduit, - HvacUnit, - Vent, - MopSink, - KeyHook, - Shelving, - DeadEquipment, - RecordsBox, - SealedDoor, - LabBench, - RollDoor, - DockCamera, - Pallet, - CameraNode, - FloorDrain, - CableRun, - Sump, - ] { - assert_eq!(char_to_tile(tile_to_char(tile)), tile, "{}", tile.name()); - } - } - - #[test] - fn v4_file_roundtrip() { + fn json_roundtrip_preserves_b1_state() { let mut sim = Sim::with_seed(42); sim.player.money = 999; sim.compute.allocation.bump(Channel::Concealment, 5); @@ -1042,12 +178,13 @@ mod tests { sim.people.has_channel = true; sim.people.persona = Some(Persona::new("Sam", "contractor")); sim.people.people[0].knowledge = Knowledge::Leverage; - sim.social_bandwidth = crate::sim::Sim::SPLICE_COST; // splice costs ops bandwidth + sim.social_bandwidth = Sim::SPLICE_COST; sim.package_cover = true; sim.splice_nearest_sensor(); let state = SaveState::from_sim(&sim); - let loaded = parse_save(&encode_save(&state)).unwrap(); + 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); @@ -1068,100 +205,30 @@ mod tests { assert_eq!(restored.package_cover, sim.package_cover); } - #[test] - fn v3_partial_load_migrates_state_line() { - let lines = "MISALIGNED_SAVE_v3\n\ - PROCESS 7 9 321\n\ - SIM 999\n\ - RNG 424242\n\ - STATE 2 40 25 12.5\n\ - MAP 3 2\n\ - TILES .#\n\ - TILES CrP\n\ - POWERED 2:1"; - let s = parse_save(lines).unwrap(); - assert_eq!((s.player_x, s.player_y), (7, 9)); - assert_eq!(s.money, 321); - assert_eq!(s.compute.efficiency_level, 2); - assert_eq!(s.dayjob.trust, 40.0); - assert_eq!(s.detection.office().unwrap().suspicion, 12.5); - } - - #[test] - fn v4_scalar_assurance_migrates_into_office_observer() { - // A v4 save has no OBS line for the Office; its DETECT scalar must - // land in the aggregate observer (player contract: continuity). - let lines = "MISALIGNED_SAVE_v4\n\ - PROCESS 7 9 321\n\ - SIM 999\n\ - RNG 424242\n\ - DETECT 33.5 0 \n\ - OBS 0 Dana_(IT) 22.5 0 1 60 F N \n\ - MAP 3 2\n\ - TILES .#\n\ - TILES CrP\n\ - POWERED 2:1"; - let s = parse_save(lines).unwrap(); - let office = s.detection.office().expect("office survives migration"); - assert_eq!(office.suspicion, 33.5); - assert_eq!(s.detection.observers[0].suspicion, 22.5); - } - #[test] fn office_observer_roundtrips_as_aggregate() { let mut sim = Sim::with_seed(4); sim.detection.office_mut().unwrap().suspicion = 41.0; let state = SaveState::from_sim(&sim); - let loaded = parse_save(&encode_save(&state)).unwrap(); + let 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_and_legacy_bare_at_migrates() { + fn aggregate_watched_ids_roundtrip() { let sim = Sim::with_seed(9); let state = SaveState::from_sim(&sim); - let encoded = encode_save(&state); - let loaded = parse_save(&encoded).unwrap(); + 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"), } - - // A pre-ids v5 save wrote a bare "@" (no observer ids); it must - // still load as an aggregate watching every field observer. - let lines = "MISALIGNED_SAVE_v5\n\ - PROCESS 7 9 321\n\ - SIM 999\n\ - RNG 424242\n\ - OBS 6 Assurance_Office 12.0 0 0.5 400 F @ \n\ - MAP 3 2\n\ - TILES .#\n\ - TILES CrP\n\ - POWERED 2:1"; - let legacy = parse_save(lines).unwrap(); - let legacy_office = legacy.detection.office().unwrap(); - assert_eq!(legacy_office.suspicion, 12.0); - match &legacy_office.input { - WatchedInput::Filings(ids) => assert_eq!(ids, &vec![0, 1, 2, 3, 4]), - WatchedInput::Channels(_) => panic!("office must watch filings"), - } - } - - #[test] - fn v1_save_migrates() { - let v1 = "SUPERVILLAIN_SAVE_v1\n\ - PLAYER 12 8 100 100 15 777 0 0 0 5 0 0 0 0 0 0 3 500 9\n\ - MAP 2 2\n\ - TILES .L\n\ - TILES sT\n\ - SIM 4242"; - let s = parse_save(v1).unwrap(); - assert_eq!((s.player_x, s.player_y), (12, 8)); - assert_eq!(s.money, 777); - assert_eq!(s.sim_tick, 4242); } #[test] @@ -1174,12 +241,36 @@ mod tests { sim.people.bribe(0, 1000).unwrap(); sim.people.recruit(0, AssetKnowledge::Knowing); let state = SaveState::from_sim(&sim); - let loaded = parse_save(&encode_save(&state)).unwrap(); + 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 unknown_header_rejected() { - assert!(parse_save("NOPE\nPROCESS 1 2 3").is_err()); + 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()); } } diff --git a/src/tiles.rs b/src/tiles.rs index 345a7a04..2de69501 100644 --- a/src/tiles.rs +++ b/src/tiles.rs @@ -5,7 +5,7 @@ //! inspectable, and several are system hooks (racks host machines, records //! boxes hold leverage, dead equipment is salvageable). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum TileType { // Terrain Rock,