// Save/load ECS extraction (#553) // Implements D-020 MessagePack format for save files, D-010 determinism. // // Two entry points: // save_to_file: queries ECS, builds SaveStateV1, writes MessagePack to path. // load_from_file: reads path, deserialises SaveStateV1, re-injects ECS state. // // IPC: SaveGame / LoadGame PlayerAction variants queue commands here. // execute_save_load: exclusive system that drains the queue and writes the result // to SnapshotBuffer.pending_save_result for client feedback. use std::path::{Path, PathBuf}; use bevy_ecs::prelude::*; use thiserror::Error; use crate::bridge::types::SaveLoadResultWire; use crate::bridge::types::SnapshotBuffer; use crate::content::template::{TemplateReferenceMap, TriangleState}; use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::registry::EntityRegistry; use crate::npc::Npc; use crate::npc::relationships::RelationshipGraph; use crate::simulation::movement::PlayerCharacter; use crate::simulation::rng::SimRng; use crate::simulation::save_state::{ deserialize_npc_from_frozen, serialize_npc_to_frozen, SaveStateV1, SAVE_FORMAT_VERSION, }; use crate::simulation::tier::BackgroundSim; use crate::simulation::time::SimulationTime; /// Errors from save/load operations (#553). #[derive(Debug, Error)] pub enum SaveLoadError { #[error("I/O error: {0}")] Io(String), #[error("serialization error: {0}")] Serialize(String), #[error("deserialization error: {0}")] Deserialize(String), #[error("format version mismatch: expected {expected}, found {found}")] VersionMismatch { expected: u8, found: u8 }, } /// A queued save or load command (#553). #[derive(Debug, Clone)] pub enum SaveLoadCommand { Save { path: PathBuf }, Load { path: PathBuf }, } /// Pending save/load command resource (#553). /// /// `process_player_input` queues commands here when it encounters /// `PlayerAction::SaveGame` or `PlayerAction::LoadGame`. The /// `execute_save_load` exclusive system drains this queue each tick. #[derive(Resource, Debug, Default)] pub struct SaveLoadPending { /// Pending command (at most one; new commands overwrite pending ones). pub pending: Option, } /// Extract world state into `SaveStateV1` and write MessagePack bytes to `path` (#553). /// /// Queries all NPC entities, the player knowledge graph, global relationship graph, /// simulation time, and RNG seed. Builds `SaveStateV1` and writes to disk. /// /// NPC states are sorted by `stable_id` ascending for determinism (D-010). /// NPCs without `StableEntityId` trigger a panic (caller invariant — all live /// NPCs must be registered before save). /// /// # Errors /// `SaveLoadError::Io` on filesystem failure. /// `SaveLoadError::Serialize` on MessagePack encoding failure. pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> { // Simulation clock let (tick, tick_rate) = { let t = world.resource::(); (t.tick, t.tick_rate) }; // RNG seed for deterministic replay (D-010) let seed = world.resource::().seed(); // Player knowledge graph — the observer's epistemics at save time let player_knowledge = { let mut q = world.query_filtered::<&KnowledgeGraph, With>(); q.single(world).cloned().unwrap_or_else(|_| { tracing::warn!("save_to_file: no PlayerCharacter with KnowledgeGraph found — saving empty graph"); KnowledgeGraph::new() }) }; // Global NPC social web let relationship_graph = world.resource::().clone(); // Per-NPC states: collect then sort by stable_id (D-010 determinism) let npc_entities: Vec = { let mut q = world.query_filtered::>(); q.iter(world).collect() }; let mut npc_states: Vec<_> = npc_entities .iter() .map(|&entity| serialize_npc_to_frozen(entity, world)) .collect(); npc_states.sort_by_key(|s| s.stable_id.0); let npc_count = npc_states.len(); // Capture TemplateReferenceMap if present — default to empty if not yet initialised. let template_references = world .get_resource::() .cloned() .unwrap_or_default(); // Capture TriangleState components — sorted by triangle_id for determinism (D-010). let mut triangle_states: Vec = { let mut q = world.query::<&TriangleState>(); q.iter(world).cloned().collect() }; triangle_states.sort_by_key(|t| t.triangle_id.0); let state = SaveStateV1 { format_version: SAVE_FORMAT_VERSION, tick, tick_rate, seed, player_knowledge, relationship_graph, npc_states, template_references, triangle_states, }; let bytes = state .to_bytes() .map_err(|e| SaveLoadError::Serialize(e.to_string()))?; std::fs::write(path, &bytes).map_err(|e| SaveLoadError::Io(e.to_string()))?; tracing::info!( "save_to_file: {:?} (tick={}, npcs={}, {} bytes)", path, tick, npc_count, bytes.len() ); Ok(()) } /// Read `path`, deserialise `SaveStateV1`, and re-inject state into the ECS (#553). /// /// Steps: /// 1. Read and deserialise bytes; reject if `format_version != SAVE_FORMAT_VERSION`. /// 2. Despawn all existing NPC entities and unregister them from `EntityRegistry`. /// 3. Re-spawn each NPC via `deserialize_npc_from_frozen`; register with /// `register_existing`; insert `BackgroundSim` tier marker. /// 4. Advance `EntityRegistry` counter past all restored IDs. /// 5. Restore `RelationshipGraph`, `SimulationTime`, and `SimRng` resources. /// 6. Update the player entity's `KnowledgeGraph` if a player entity exists. /// /// **Gotcha (D-010):** Bevy `Entity` handles are generational. `NpcSaveState` uses /// `StableId(u64)` throughout — `EntityRegistry` maps restored `StableId`s to the /// new `Entity` handles after re-spawn. /// /// # Errors /// `SaveLoadError::Io` on filesystem failure. /// `SaveLoadError::Deserialize` on MessagePack decoding failure. /// `SaveLoadError::VersionMismatch` when the save file predates the current schema. pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> { let bytes = std::fs::read(path).map_err(|e| SaveLoadError::Io(e.to_string()))?; let state = SaveStateV1::from_bytes(&bytes).map_err(|e| SaveLoadError::Deserialize(e.to_string()))?; if state.format_version != SAVE_FORMAT_VERSION { return Err(SaveLoadError::VersionMismatch { expected: SAVE_FORMAT_VERSION, found: state.format_version, }); } let npc_count = state.npc_states.len(); // Despawn all existing NPC entities and clear their registry entries. let npc_entities: Vec = { let mut q = world.query_filtered::>(); q.iter(world).collect() }; for entity in npc_entities { world.resource_mut::().unregister(entity); world.despawn(entity); } // Track the highest restored StableId so we can advance the counter. let mut max_id: u64 = 0; // Re-spawn NPCs, assign tier marker, register pre-existing StableIds. for npc_state in &state.npc_states { let entity = deserialize_npc_from_frozen(npc_state, world); // Loaded NPCs start in BackgroundSim; the distance system promotes as needed. world.entity_mut(entity).insert(BackgroundSim); let stable_id = npc_state.stable_id; world .resource_mut::() .register_existing(entity, stable_id); max_id = max_id.max(stable_id.0); } // Advance the registry counter past all restored IDs so future register() // calls produce non-conflicting IDs. if npc_count > 0 { world.resource_mut::().advance_past(max_id); } // Restore simulation resources. world.insert_resource(state.relationship_graph); world.insert_resource(state.template_references); // Restore triangle states (#250) — spawn dedicated entities for each. for ts in &state.triangle_states { world.spawn(ts.clone()); } { let mut t = world.resource_mut::(); t.tick = state.tick; t.tick_rate = state.tick_rate; } world.insert_resource(SimRng::new(state.seed)); // Update the player entity's KnowledgeGraph if a player exists. let player_entity = { let mut q = world.query_filtered::>(); q.single(world).ok() }; if let Some(player_entity) = player_entity { world .entity_mut(player_entity) .insert(state.player_knowledge); } tracing::info!( "load_from_file: {:?} (tick={}, npcs={})", path, state.tick, npc_count, ); Ok(()) } /// Exclusive system: drain `SaveLoadPending` and execute queued save/load (#553). /// /// Runs each tick, after `process_player_input`. If a command is pending, /// executes it and writes `SaveLoadResultWire` to `SnapshotBuffer.pending_save_result` /// for consumption by `compute_observer_snapshot` the same tick. pub fn execute_save_load(world: &mut World) { // Take the pending command (releases the borrow before we use world again). let command = { let mut pending = world.resource_mut::(); pending.pending.take() }; let Some(command) = command else { return; }; let (kind_str, result) = match &command { SaveLoadCommand::Save { path } => { let r = save_to_file(path, world); ("save", r) } SaveLoadCommand::Load { path } => { let r = load_from_file(path, world); ("load", r) } }; let wire_result = match result { Ok(()) => { tracing::info!("execute_save_load: {} completed", kind_str); SaveLoadResultWire { success: true, kind: kind_str.to_string(), error: None, } } Err(ref e) => { tracing::error!("execute_save_load: {} failed: {}", kind_str, e); SaveLoadResultWire { success: false, kind: kind_str.to_string(), error: Some(e.to_string()), } } }; // Write result to SnapshotBuffer for client feedback (one tick only — consumed by // compute_observer_snapshot via pending_save_result.take()). if let Some(mut buf) = world.get_resource_mut::() { buf.pending_save_result = Some(wire_result); } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::registry::{EntityRegistry, StableEntityId}; use crate::knowledge::types::StableId; use crate::npc::Npc; use crate::npc::relationships::RelationshipGraph; use crate::simulation::movement::TilePosition; use crate::simulation::rng::SimRng; use crate::simulation::save_state::{SaveStateV1, SAVE_FORMAT_VERSION}; use crate::simulation::time::{SimulationTime, TickRate}; use bevy_ecs::world::World; use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); fn temp_path() -> PathBuf { let id = COUNTER.fetch_add(1, Ordering::Relaxed); std::env::temp_dir().join(format!("settled_reach_save_io_test_{}.msgpack", id)) } fn minimal_world() -> World { let mut w = World::new(); w.insert_resource(SimulationTime::default()); w.insert_resource(SimRng::new(42)); w.insert_resource(RelationshipGraph::new()); w.init_resource::(); w } fn spawn_test_npc(world: &mut World, stable_id: u64) -> Entity { world .spawn(( Npc, StableEntityId(StableId(stable_id)), TilePosition::new(stable_id as i32, 0, 0), )) .id() } // ----------------------------------------------------------------------- // save_to_file // ----------------------------------------------------------------------- #[test] fn save_to_file_creates_valid_msgpack() { let mut world = minimal_world(); spawn_test_npc(&mut world, 1); spawn_test_npc(&mut world, 2); let path = temp_path(); save_to_file(&path, &mut world).expect("save should succeed"); let bytes = std::fs::read(&path).expect("file should exist"); let state = SaveStateV1::from_bytes(&bytes).expect("bytes must be valid msgpack"); assert_eq!(state.format_version, SAVE_FORMAT_VERSION); assert_eq!(state.npc_states.len(), 2); let _ = std::fs::remove_file(&path); } #[test] fn save_to_file_sorts_npc_states_by_stable_id() { let mut world = minimal_world(); // Spawn in reverse order — save should still sort ascending spawn_test_npc(&mut world, 50); spawn_test_npc(&mut world, 10); spawn_test_npc(&mut world, 30); let path = temp_path(); save_to_file(&path, &mut world).expect("save should succeed"); let bytes = std::fs::read(&path).expect("read saved file"); let state = SaveStateV1::from_bytes(&bytes).unwrap(); let ids: Vec = state.npc_states.iter().map(|n| n.stable_id.0).collect(); assert_eq!(ids, vec![10, 30, 50], "npc_states must be sorted by stable_id"); let _ = std::fs::remove_file(&path); } #[test] fn save_to_file_preserves_tick_and_seed() { let mut world = minimal_world(); { let mut t = world.resource_mut::(); t.tick = 9999; t.tick_rate = TickRate::Half; } world.insert_resource(SimRng::new(0xDEADBEEF)); let path = temp_path(); save_to_file(&path, &mut world).expect("save"); let bytes = std::fs::read(&path).unwrap(); let state = SaveStateV1::from_bytes(&bytes).unwrap(); assert_eq!(state.tick, 9999); assert_eq!(state.tick_rate, TickRate::Half); assert_eq!(state.seed, 0xDEADBEEF); let _ = std::fs::remove_file(&path); } #[test] fn save_to_file_returns_io_error_on_bad_path() { let mut world = minimal_world(); let bad_path = std::path::Path::new("/nonexistent/directory/save.msgpack"); let result = save_to_file(bad_path, &mut world); assert!( matches!(result, Err(SaveLoadError::Io(_))), "expected Io error for bad path" ); } // ----------------------------------------------------------------------- // load_from_file // ----------------------------------------------------------------------- #[test] fn load_from_file_restores_npc_count() { let mut world = minimal_world(); spawn_test_npc(&mut world, 1); spawn_test_npc(&mut world, 2); let path = temp_path(); save_to_file(&path, &mut world).expect("save"); // Spawn an extra NPC — loading should despawn the old NPCs and restore exactly 2 spawn_test_npc(&mut world, 99); let pre_load_count = { let mut q = world.query_filtered::>(); q.iter(&world).count() }; assert_eq!(pre_load_count, 3, "three NPCs before load"); load_from_file(&path, &mut world).expect("load"); let post_load_count = { let mut q = world.query_filtered::>(); q.iter(&world).count() }; assert_eq!(post_load_count, 2, "exactly the two saved NPCs after load"); let _ = std::fs::remove_file(&path); } #[test] fn load_from_file_restores_stable_ids_in_registry() { let mut world = minimal_world(); spawn_test_npc(&mut world, 10); spawn_test_npc(&mut world, 20); let path = temp_path(); save_to_file(&path, &mut world).expect("save"); load_from_file(&path, &mut world).expect("load"); let registry = world.resource::(); assert!( registry.to_entity(&StableId(10)).is_some(), "StableId(10) must be in registry after load" ); assert!( registry.to_entity(&StableId(20)).is_some(), "StableId(20) must be in registry after load" ); let _ = std::fs::remove_file(&path); } #[test] fn load_from_file_restores_tick_and_seed() { let mut world = minimal_world(); { let mut t = world.resource_mut::(); t.tick = 5000; } world.insert_resource(SimRng::new(12345)); let path = temp_path(); save_to_file(&path, &mut world).expect("save"); // Change time and seed, then load { let mut t = world.resource_mut::(); t.tick = 1; } world.insert_resource(SimRng::new(0)); load_from_file(&path, &mut world).expect("load"); let t = world.resource::(); assert_eq!(t.tick, 5000, "tick restored from save"); assert_eq!( world.resource::().seed(), 12345, "seed restored from save" ); let _ = std::fs::remove_file(&path); } #[test] fn load_from_file_rejects_wrong_format_version() { use crate::content::template::TemplateReferenceMap; // Craft a save with a wrong format_version let bad_state = SaveStateV1 { format_version: 0xFF, // deliberately wrong tick: 0, tick_rate: TickRate::Full, seed: 0, player_knowledge: KnowledgeGraph::new(), relationship_graph: RelationshipGraph::new(), npc_states: vec![], template_references: TemplateReferenceMap::default(), triangle_states: vec![], }; let bytes = bad_state.to_bytes().expect("serialize"); let path = temp_path(); std::fs::write(&path, &bytes).expect("write"); let mut world = minimal_world(); let result = load_from_file(&path, &mut world); assert!( matches!(result, Err(SaveLoadError::VersionMismatch { .. })), "expected VersionMismatch error, got {:?}", result ); let _ = std::fs::remove_file(&path); } #[test] fn load_from_file_returns_io_error_for_missing_file() { let mut world = minimal_world(); let missing = std::path::Path::new("/tmp/settled_reach_nonexistent_42.msgpack"); let result = load_from_file(missing, &mut world); assert!( matches!(result, Err(SaveLoadError::Io(_))), "expected Io error for missing file" ); } #[test] fn load_from_file_assigns_background_sim_tier() { let mut world = minimal_world(); spawn_test_npc(&mut world, 1); let path = temp_path(); save_to_file(&path, &mut world).expect("save"); load_from_file(&path, &mut world).expect("load"); let has_background: bool = { let mut q = world.query_filtered::, With)>(); q.iter(&world).count() > 0 }; assert!( has_background, "loaded NPC should be in BackgroundSim tier" ); let _ = std::fs::remove_file(&path); } // ----------------------------------------------------------------------- // execute_save_load // ----------------------------------------------------------------------- #[test] fn execute_save_load_noop_when_no_pending() { let mut world = minimal_world(); world.init_resource::(); world.init_resource::(); execute_save_load(&mut world); // No result written when no pending command let buf = world.resource::(); assert!( buf.pending_save_result.is_none(), "no pending_save_result when no command was queued" ); } #[test] fn execute_save_load_writes_success_result() { let mut world = minimal_world(); world.init_resource::(); let path = temp_path(); world.insert_resource(SaveLoadPending { pending: Some(SaveLoadCommand::Save { path: path.clone() }), }); execute_save_load(&mut world); let buf = world.resource::(); let result = buf .pending_save_result .as_ref() .expect("result must be written after execute"); assert!(result.success, "save should succeed"); assert_eq!(result.kind, "save"); assert!(result.error.is_none()); let _ = std::fs::remove_file(&path); } #[test] fn execute_save_load_writes_error_result_on_bad_path() { let mut world = minimal_world(); world.init_resource::(); world.insert_resource(SaveLoadPending { pending: Some(SaveLoadCommand::Save { path: PathBuf::from("/nonexistent/dir/save.msgpack"), }), }); execute_save_load(&mut world); let buf = world.resource::(); let result = buf .pending_save_result .as_ref() .expect("result must be written even on failure"); assert!(!result.success, "save should fail with bad path"); assert_eq!(result.kind, "save"); assert!(result.error.is_some(), "error message should be present"); } // ----------------------------------------------------------------------- // Overwrite behaviour // ----------------------------------------------------------------------- /// When two commands arrive in the same tick, the second overwrites the first. /// The warn! in process_player_input fires; here we just confirm last-write-wins. #[test] fn pending_command_overwrite_last_write_wins() { let mut pending = SaveLoadPending::default(); pending.pending = Some(SaveLoadCommand::Save { path: PathBuf::from("/tmp/first.msgpack"), }); // Overwrite with a Load command pending.pending = Some(SaveLoadCommand::Load { path: PathBuf::from("/tmp/second.msgpack"), }); match pending.pending.unwrap() { SaveLoadCommand::Load { ref path } => { assert_eq!(path.to_str().unwrap(), "/tmp/second.msgpack"); } other => panic!("expected Load, got {:?}", other), } } // ----------------------------------------------------------------------- // SaveLoadError display // ----------------------------------------------------------------------- #[test] fn save_load_error_display() { let e = SaveLoadError::Io("disk full".into()); assert!(e.to_string().contains("disk full")); let e2 = SaveLoadError::VersionMismatch { expected: 1, found: 2, }; assert!(e2.to_string().contains("expected 1")); assert!(e2.to_string().contains("found 2")); } }