// 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::knowledge::graph::KnowledgeGraph; use crate::knowledge::registry::EntityRegistry; use crate::knowledge::types::StableId; use crate::npc::relationships::RelationshipGraph; use crate::npc::Npc; use crate::simulation::interaction::DoorState; 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::{ActiveSim, BackgroundSim}; use crate::simulation::time::SimulationTime; use crate::simulation::triangle::TriangleCrisisEventQueue; use crate::simulation::triangle::{TemplateReferenceMap, TriangleState}; use crate::storyteller::{ ActivationState, ContaminationActive, ContaminationEventQueue, MovementHistoryBuffer, TriangleActivatedQueue, }; /// 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, open_doors: { use crate::knowledge::registry::StableEntityId; let mut q = world.query::<(&DoorState, &StableEntityId)>(); let mut ids: Vec<_> = q .iter(world) .filter(|(ds, _)| ds.is_open) .map(|(_, sid)| sid.0) .collect(); ids.sort_by_key(|id| id.0); ids }, modifications: vec![], // TODO: persist when modification system is implemented contamination_active: world .get_resource::() .is_some_and(|c| c.0), activated_count: world .get_resource::() .map_or(0, |a| a.activated_count), last_activation_tick: world .get_resource::() .and_then(|a| a.last_activation_tick), }; 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. Despawn existing triangle entities (separate from NPCs — no `Npc` marker). /// 6. Restore triangle states with `ActiveSim` so escalation/contamination systems see them. /// 7. Restore resources: `RelationshipGraph`, `SimulationTime`, `SimRng`, /// `ContaminationActive`, event queues (reset to prevent stale cross-load leakage). /// 8. Restore door open states and player `KnowledgeGraph`. /// /// **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); // Despawn existing triangle entities before restoring from save. // Triangle entities are separate from NPC entities (no Npc component), // so the NPC despawn loop above does not catch them. Without this, // loading a save would create duplicates — doubling tension escalation. let triangle_entities: Vec = { let mut q = world.query_filtered::>(); q.iter(world).collect() }; for entity in triangle_entities { world.despawn(entity); } // Restore triangle states (#250) — spawn with ActiveSim so escalation // and contamination systems (which filter With) can see them. for ts in &state.triangle_states { world.spawn((ts.clone(), ActiveSim)); } { let mut t = world.resource_mut::(); t.tick = state.tick; t.tick_rate = state.tick_rate; } world.insert_resource(SimRng::new(state.seed)); // Restore contamination state (#254) — prevents double-firing on reload. world.insert_resource(ContaminationActive(state.contamination_active)); // Restore activation state (#572) — prevents double-activation on reload. world.insert_resource(ActivationState { activated_count: state.activated_count, last_activation_tick: state.last_activation_tick, }); // Reset event queues and transient buffers — prevent stale events/history // from the pre-load world leaking into the post-load simulation. world.insert_resource(ContaminationEventQueue::default()); world.insert_resource(TriangleCrisisEventQueue::default()); world.insert_resource(TriangleActivatedQueue::default()); world.insert_resource(MovementHistoryBuffer::default()); // Restore door open states (#246) — find door entities by StableId and toggle. if !state.open_doors.is_empty() { let open_set: std::collections::BTreeSet<_> = state.open_doors.iter().copied().collect(); let door_entities: Vec<(Entity, StableId)> = { let mut q = world.query::<( Entity, &crate::knowledge::registry::StableEntityId, &DoorState, )>(); q.iter(world) .filter(|(_, sid, _)| open_set.contains(&sid.0)) .map(|(e, sid, _)| (e, sid.0)) .collect() }; for (entity, sid) in door_entities { if let Some(mut door) = world.get_mut::(entity) { door.is_open = true; let tile = door.blocking_tile; if let Some(mut wmap) = world.get_resource_mut::() { wmap.set_walkable(&tile, true); } tracing::debug!(stable_id = sid.0, "load: restored open door state"); } } } // 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::relationships::RelationshipGraph; use crate::npc::Npc; 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.init_resource::(); 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::simulation::triangle::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![], open_doors: vec![], modifications: vec![], contamination_active: false, activated_count: 0, last_activation_tick: None, }; 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")); } // ----------------------------------------------------------------------- // Triangle state roundtrip (regression tests for missing ActiveSim // and duplicate triangle entities on load) // ----------------------------------------------------------------------- fn make_test_triangle(slug: &str, tension: u8) -> TriangleState { use crate::simulation::triangle::{ RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, }; let mut role_assignments = std::collections::BTreeMap::new(); role_assignments.insert(RoleId::new("a"), StableId(1)); role_assignments.insert(RoleId::new("b"), StableId(2)); role_assignments.insert(RoleId::new("c"), StableId(3)); TriangleState { triangle_id: TriangleId::from_seed_and_slug(0, slug), role_assignments, tension, phase: TrianglePhase::Simmering, tension_rate: 1, template_id: TemplateId::from_seed_and_slug(0, "test"), classification: TriangleClassification::ActiveFork, } } /// Regression: loaded triangle entities must have ActiveSim so that /// escalation and contamination systems (which filter With) /// can see them. #[test] fn load_from_file_restores_triangles_with_active_sim() { let mut world = minimal_world(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.spawn((make_test_triangle("hub", 15), ActiveSim)); world.spawn((make_test_triangle("bar", 30), ActiveSim)); let path = temp_path(); save_to_file(&path, &mut world).expect("save"); load_from_file(&path, &mut world).expect("load"); // All restored triangles must have both TriangleState and ActiveSim. let with_active_sim = { let mut q = world.query_filtered::, With)>(); q.iter(&world).count() }; assert_eq!( with_active_sim, 2, "loaded triangles must have ActiveSim — escalation/contamination systems require it" ); let _ = std::fs::remove_file(&path); } /// Regression: loading must not duplicate triangle entities — existing /// triangles must be despawned before restoring from save. #[test] fn load_from_file_does_not_duplicate_triangles() { let mut world = minimal_world(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.spawn((make_test_triangle("hub", 10), ActiveSim)); let path = temp_path(); save_to_file(&path, &mut world).expect("save"); // Load twice — should not accumulate triangles. load_from_file(&path, &mut world).expect("load 1"); load_from_file(&path, &mut world).expect("load 2"); let count = { let mut q = world.query::<&TriangleState>(); q.iter(&world).count() }; assert_eq!( count, 1, "loading twice must not create duplicate triangle entities" ); let _ = std::fs::remove_file(&path); } /// Triangle tension values must survive save/load roundtrip. #[test] fn load_from_file_preserves_triangle_tension() { let mut world = minimal_world(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.spawn((make_test_triangle("hub", 42), ActiveSim)); world.spawn((make_test_triangle("bar", 99), ActiveSim)); let path = temp_path(); save_to_file(&path, &mut world).expect("save"); load_from_file(&path, &mut world).expect("load"); let mut tensions: Vec = { let mut q = world.query::<&TriangleState>(); q.iter(&world).map(|ts| ts.tension).collect() }; tensions.sort(); assert_eq!( tensions, vec![42, 99], "triangle tension values must survive save/load roundtrip" ); let _ = std::fs::remove_file(&path); } // ----------------------------------------------------------------------- // ActivationState roundtrip (#572, Task #12) // ----------------------------------------------------------------------- #[test] fn load_from_file_restores_activation_state() { let mut world = minimal_world(); // Set activation state: 1 activation at tick 500 world.insert_resource(ActivationState { activated_count: 1, last_activation_tick: Some(500), }); let path = temp_path(); save_to_file(&path, &mut world).expect("save"); // Reset activation state to defaults before load world.insert_resource(ActivationState::default()); assert_eq!(world.resource::().activated_count, 0); assert!(world .resource::() .last_activation_tick .is_none()); load_from_file(&path, &mut world).expect("load"); let state = world.resource::(); assert_eq!( state.activated_count, 1, "activated_count must survive save/load" ); assert_eq!( state.last_activation_tick, Some(500), "last_activation_tick must survive save/load" ); let _ = std::fs::remove_file(&path); } #[test] fn load_from_file_restores_zero_activation_state() { // Verify that saves with no activations restore correctly (serde(default)) let mut world = minimal_world(); let path = temp_path(); save_to_file(&path, &mut world).expect("save"); // Pollute state before load world.insert_resource(ActivationState { activated_count: 5, last_activation_tick: Some(9999), }); load_from_file(&path, &mut world).expect("load"); let state = world.resource::(); assert_eq!(state.activated_count, 0); assert!(state.last_activation_tick.is_none()); let _ = std::fs::remove_file(&path); } }