From 339f112c8fa57e9d38d181d1d75fe13bc6e217b5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 28 Feb 2026 23:24:56 +0100 Subject: [PATCH] fix(simulation): fix triangle state save/load and persist contamination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three save/load bugs fixed: - ContaminationActive not persisted in SaveStateV1 — caused double-fire of contamination pressure on reload after tick 300. - Loaded triangle entities missing ActiveSim marker — made them invisible to escalation and contamination systems after any load. - Existing triangle entities not despawned before load — created duplicates, doubling tension escalation per tick. Also: HashSet → BTreeSet for D-010 compliance, defensive event queue reset on load, and three regression tests for triangle roundtrip. Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/save_io.rs | 156 +++++++++++++++++++++- server/src/simulation/save_state.rs | 8 ++ server/tests/environmental_interaction.rs | 1 + server/tests/information_boundaries.rs | 1 + 4 files changed, 160 insertions(+), 6 deletions(-) diff --git a/server/src/simulation/save_io.rs b/server/src/simulation/save_io.rs index a25cf28af..f0296fae2 100644 --- a/server/src/simulation/save_io.rs +++ b/server/src/simulation/save_io.rs @@ -28,8 +28,10 @@ use crate::simulation::save_state::{ }; use crate::knowledge::types::StableId; use crate::simulation::interaction::DoorState; -use crate::simulation::tier::BackgroundSim; +use crate::simulation::tier::{ActiveSim, BackgroundSim}; use crate::simulation::time::SimulationTime; +use crate::content::template::TriangleCrisisEventQueue; +use crate::storyteller::{ContaminationActive, ContaminationEventQueue}; /// Errors from save/load operations (#553). #[derive(Debug, Error)] @@ -144,6 +146,9 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> ids }, modifications: vec![], + contamination_active: world + .get_resource::() + .map_or(false, |c| c.0), }; let bytes = state @@ -170,8 +175,11 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> /// 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. +/// 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 @@ -233,9 +241,22 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro world.insert_resource(state.relationship_graph); world.insert_resource(state.template_references); - // Restore triangle states (#250) — spawn dedicated entities for each. + // 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()); + world.spawn((ts.clone(), ActiveSim)); } { let mut t = world.resource_mut::(); @@ -244,9 +265,17 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro } world.insert_resource(SimRng::new(state.seed)); + // Restore contamination state (#254) — prevents double-firing on reload. + world.insert_resource(ContaminationActive(state.contamination_active)); + + // Reset event queues — prevent stale events from the pre-load world + // leaking into the post-load simulation. + world.insert_resource(ContaminationEventQueue::default()); + world.insert_resource(TriangleCrisisEventQueue::default()); + // Restore door open states (#246) — find door entities by StableId and toggle. if !state.open_doors.is_empty() { - let open_set: std::collections::HashSet<_> = state.open_doors.iter().copied().collect(); + 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) @@ -560,6 +589,7 @@ mod tests { triangle_states: vec![], open_doors: vec![], modifications: vec![], + contamination_active: false, }; let bytes = bad_state.to_bytes().expect("serialize"); let path = temp_path(); @@ -717,4 +747,118 @@ mod tests { 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::content::template::{ + 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); + } } diff --git a/server/src/simulation/save_state.rs b/server/src/simulation/save_state.rs index 7780fbd25..4e52b2084 100644 --- a/server/src/simulation/save_state.rs +++ b/server/src/simulation/save_state.rs @@ -105,6 +105,12 @@ pub struct SaveStateV1 { /// DLC can populate it without a save format migration. #[serde(default)] pub modifications: Vec, + /// Whether contamination has already activated (#254). + /// Persisted to prevent double-firing on save/load — without this, + /// reloading a save after tick 300 would re-trigger contamination + /// and apply a duplicate tension delta to all ActiveFork triangles. + #[serde(default)] + pub contamination_active: bool, } /// Per-NPC state snapshot for `SaveStateV1`. @@ -418,6 +424,7 @@ mod tests { triangle_states: vec![], open_doors: vec![], modifications: vec![], + contamination_active: false, } } @@ -816,6 +823,7 @@ mod tests { triangle_states: vec![], open_doors: vec![], modifications: vec![], + contamination_active: false, }; let bytes = save.to_bytes().expect("serialize"); diff --git a/server/tests/environmental_interaction.rs b/server/tests/environmental_interaction.rs index f9cf7d59c..fe9d01635 100644 --- a/server/tests/environmental_interaction.rs +++ b/server/tests/environmental_interaction.rs @@ -419,6 +419,7 @@ fn minimal_save() -> SaveStateV1 { triangle_states: vec![], open_doors: vec![], modifications: vec![], + contamination_active: false, } } diff --git a/server/tests/information_boundaries.rs b/server/tests/information_boundaries.rs index 59e56152e..8bee8cc75 100644 --- a/server/tests/information_boundaries.rs +++ b/server/tests/information_boundaries.rs @@ -207,6 +207,7 @@ fn save_state_npc_kg_isolation() { triangle_states: vec![], open_doors: vec![], modifications: vec![], + contamination_active: false, }; // Roundtrip: serialize → deserialize.