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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user