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::knowledge::types::StableId;
|
||||||
use crate::simulation::interaction::DoorState;
|
use crate::simulation::interaction::DoorState;
|
||||||
use crate::simulation::tier::BackgroundSim;
|
use crate::simulation::tier::{ActiveSim, BackgroundSim};
|
||||||
use crate::simulation::time::SimulationTime;
|
use crate::simulation::time::SimulationTime;
|
||||||
|
use crate::content::template::TriangleCrisisEventQueue;
|
||||||
|
use crate::storyteller::{ContaminationActive, ContaminationEventQueue};
|
||||||
|
|
||||||
/// Errors from save/load operations (#553).
|
/// Errors from save/load operations (#553).
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -144,6 +146,9 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
|
|||||||
ids
|
ids
|
||||||
},
|
},
|
||||||
modifications: vec![],
|
modifications: vec![],
|
||||||
|
contamination_active: world
|
||||||
|
.get_resource::<ContaminationActive>()
|
||||||
|
.map_or(false, |c| c.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
let bytes = state
|
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
|
/// 3. Re-spawn each NPC via `deserialize_npc_from_frozen`; register with
|
||||||
/// `register_existing`; insert `BackgroundSim` tier marker.
|
/// `register_existing`; insert `BackgroundSim` tier marker.
|
||||||
/// 4. Advance `EntityRegistry` counter past all restored IDs.
|
/// 4. Advance `EntityRegistry` counter past all restored IDs.
|
||||||
/// 5. Restore `RelationshipGraph`, `SimulationTime`, and `SimRng` resources.
|
/// 5. Despawn existing triangle entities (separate from NPCs — no `Npc` marker).
|
||||||
/// 6. Update the player entity's `KnowledgeGraph` if a player entity exists.
|
/// 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
|
/// **Gotcha (D-010):** Bevy `Entity` handles are generational. `NpcSaveState` uses
|
||||||
/// `StableId(u64)` throughout — `EntityRegistry` maps restored `StableId`s to the
|
/// `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.relationship_graph);
|
||||||
world.insert_resource(state.template_references);
|
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 {
|
for ts in &state.triangle_states {
|
||||||
world.spawn(ts.clone());
|
world.spawn((ts.clone(), ActiveSim));
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let mut t = world.resource_mut::<SimulationTime>();
|
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));
|
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.
|
// Restore door open states (#246) — find door entities by StableId and toggle.
|
||||||
if !state.open_doors.is_empty() {
|
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 door_entities: Vec<(Entity, StableId)> = {
|
||||||
let mut q = world.query::<(Entity, &crate::knowledge::registry::StableEntityId, &DoorState)>();
|
let mut q = world.query::<(Entity, &crate::knowledge::registry::StableEntityId, &DoorState)>();
|
||||||
q.iter(world)
|
q.iter(world)
|
||||||
@@ -560,6 +589,7 @@ mod tests {
|
|||||||
triangle_states: vec![],
|
triangle_states: vec![],
|
||||||
open_doors: vec![],
|
open_doors: vec![],
|
||||||
modifications: vec![],
|
modifications: vec![],
|
||||||
|
contamination_active: false,
|
||||||
};
|
};
|
||||||
let bytes = bad_state.to_bytes().expect("serialize");
|
let bytes = bad_state.to_bytes().expect("serialize");
|
||||||
let path = temp_path();
|
let path = temp_path();
|
||||||
@@ -717,4 +747,118 @@ mod tests {
|
|||||||
assert!(e2.to_string().contains("expected 1"));
|
assert!(e2.to_string().contains("expected 1"));
|
||||||
assert!(e2.to_string().contains("found 2"));
|
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.
|
/// DLC can populate it without a save format migration.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub modifications: Vec<Modification>,
|
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`.
|
/// Per-NPC state snapshot for `SaveStateV1`.
|
||||||
@@ -418,6 +424,7 @@ mod tests {
|
|||||||
triangle_states: vec![],
|
triangle_states: vec![],
|
||||||
open_doors: vec![],
|
open_doors: vec![],
|
||||||
modifications: vec![],
|
modifications: vec![],
|
||||||
|
contamination_active: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,6 +823,7 @@ mod tests {
|
|||||||
triangle_states: vec![],
|
triangle_states: vec![],
|
||||||
open_doors: vec![],
|
open_doors: vec![],
|
||||||
modifications: vec![],
|
modifications: vec![],
|
||||||
|
contamination_active: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let bytes = save.to_bytes().expect("serialize");
|
let bytes = save.to_bytes().expect("serialize");
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ fn minimal_save() -> SaveStateV1 {
|
|||||||
triangle_states: vec![],
|
triangle_states: vec![],
|
||||||
open_doors: vec![],
|
open_doors: vec![],
|
||||||
modifications: vec![],
|
modifications: vec![],
|
||||||
|
contamination_active: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ fn save_state_npc_kg_isolation() {
|
|||||||
triangle_states: vec![],
|
triangle_states: vec![],
|
||||||
open_doors: vec![],
|
open_doors: vec![],
|
||||||
modifications: vec![],
|
modifications: vec![],
|
||||||
|
contamination_active: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Roundtrip: serialize → deserialize.
|
// Roundtrip: serialize → deserialize.
|
||||||
|
|||||||
Reference in New Issue
Block a user