fix(simulation): fix triangle state save/load and persist contamination

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 23:24:56 +01:00
co-authored by Claude Opus 4.6
parent 70de959ac3
commit 339f112c8f
4 changed files with 160 additions and 6 deletions
+150 -6
View File
@@ -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::<ContaminationActive>()
.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<Entity> = {
let mut q = world.query_filtered::<Entity, With<TriangleState>>();
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<ActiveSim>) can see them.
for ts in &state.triangle_states {
world.spawn(ts.clone());
world.spawn((ts.clone(), ActiveSim));
}
{
let mut t = world.resource_mut::<SimulationTime>();
@@ -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<ActiveSim>)
/// can see them.
#[test]
fn load_from_file_restores_triangles_with_active_sim() {
let mut world = minimal_world();
world.init_resource::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
world.init_resource::<TriangleCrisisEventQueue>();
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::<Entity, (With<TriangleState>, With<ActiveSim>)>();
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::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
world.init_resource::<TriangleCrisisEventQueue>();
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::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
world.init_resource::<TriangleCrisisEventQueue>();
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<u8> = {
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);
}
}
+8
View File
@@ -105,6 +105,12 @@ pub struct SaveStateV1 {
/// DLC can populate it without a save format migration.
#[serde(default)]
pub modifications: Vec<Modification>,
/// 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");
@@ -419,6 +419,7 @@ fn minimal_save() -> SaveStateV1 {
triangle_states: vec![],
open_doors: vec![],
modifications: vec![],
contamination_active: false,
}
}
+1
View File
@@ -207,6 +207,7 @@ fn save_state_npc_kg_isolation() {
triangle_states: vec![],
open_doors: vec![],
modifications: vec![],
contamination_active: false,
};
// Roundtrip: serialize → deserialize.