|
|
|
@@ -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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|