Files
settled-reach/server/src/simulation/save_io.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

959 lines
34 KiB
Rust

// Save/load ECS extraction (#553)
// Implements D-020 MessagePack format for save files, D-010 determinism.
//
// Two entry points:
// save_to_file: queries ECS, builds SaveStateV1, writes MessagePack to path.
// load_from_file: reads path, deserialises SaveStateV1, re-injects ECS state.
//
// IPC: SaveGame / LoadGame PlayerAction variants queue commands here.
// execute_save_load: exclusive system that drains the queue and writes the result
// to SnapshotBuffer.pending_save_result for client feedback.
use std::path::{Path, PathBuf};
use bevy_ecs::prelude::*;
use thiserror::Error;
use crate::bridge::types::SaveLoadResultWire;
use crate::bridge::types::SnapshotBuffer;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::knowledge::types::StableId;
use crate::npc::relationships::RelationshipGraph;
use crate::npc::Npc;
use crate::simulation::interaction::DoorState;
use crate::simulation::movement::PlayerCharacter;
use crate::simulation::rng::SimRng;
use crate::simulation::save_state::{
deserialize_npc_from_frozen, serialize_npc_to_frozen, SaveStateV1, SAVE_FORMAT_VERSION,
};
use crate::simulation::tier::{ActiveSim, BackgroundSim};
use crate::simulation::time::SimulationTime;
use crate::simulation::triangle::TriangleCrisisEventQueue;
use crate::simulation::triangle::{TemplateReferenceMap, TriangleState};
use crate::storyteller::{
ActivationState, ContaminationActive, ContaminationEventQueue, MovementHistoryBuffer,
TriangleActivatedQueue,
};
/// Errors from save/load operations (#553).
#[derive(Debug, Error)]
pub enum SaveLoadError {
#[error("I/O error: {0}")]
Io(String),
#[error("serialization error: {0}")]
Serialize(String),
#[error("deserialization error: {0}")]
Deserialize(String),
#[error("format version mismatch: expected {expected}, found {found}")]
VersionMismatch { expected: u8, found: u8 },
}
/// A queued save or load command (#553).
#[derive(Debug, Clone)]
pub enum SaveLoadCommand {
Save { path: PathBuf },
Load { path: PathBuf },
}
/// Pending save/load command resource (#553).
///
/// `process_player_input` queues commands here when it encounters
/// `PlayerAction::SaveGame` or `PlayerAction::LoadGame`. The
/// `execute_save_load` exclusive system drains this queue each tick.
#[derive(Resource, Debug, Default)]
pub struct SaveLoadPending {
/// Pending command (at most one; new commands overwrite pending ones).
pub pending: Option<SaveLoadCommand>,
}
/// Extract world state into `SaveStateV1` and write MessagePack bytes to `path` (#553).
///
/// Queries all NPC entities, the player knowledge graph, global relationship graph,
/// simulation time, and RNG seed. Builds `SaveStateV1` and writes to disk.
///
/// NPC states are sorted by `stable_id` ascending for determinism (D-010).
/// NPCs without `StableEntityId` trigger a panic (caller invariant — all live
/// NPCs must be registered before save).
///
/// # Errors
/// `SaveLoadError::Io` on filesystem failure.
/// `SaveLoadError::Serialize` on MessagePack encoding failure.
pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> {
// Simulation clock
let (tick, tick_rate) = {
let t = world.resource::<SimulationTime>();
(t.tick, t.tick_rate)
};
// RNG seed for deterministic replay (D-010)
let seed = world.resource::<SimRng>().seed();
// Player knowledge graph — the observer's epistemics at save time
let player_knowledge = {
let mut q = world.query_filtered::<&KnowledgeGraph, With<PlayerCharacter>>();
q.single(world).cloned().unwrap_or_else(|_| {
tracing::warn!(
"save_to_file: no PlayerCharacter with KnowledgeGraph found — saving empty graph"
);
KnowledgeGraph::new()
})
};
// Global NPC social web
let relationship_graph = world.resource::<RelationshipGraph>().clone();
// Per-NPC states: collect then sort by stable_id (D-010 determinism)
let npc_entities: Vec<Entity> = {
let mut q = world.query_filtered::<Entity, With<Npc>>();
q.iter(world).collect()
};
let mut npc_states: Vec<_> = npc_entities
.iter()
.map(|&entity| serialize_npc_to_frozen(entity, world))
.collect();
npc_states.sort_by_key(|s| s.stable_id.0);
let npc_count = npc_states.len();
// Capture TemplateReferenceMap if present — default to empty if not yet initialised.
let template_references = world
.get_resource::<TemplateReferenceMap>()
.cloned()
.unwrap_or_default();
// Capture TriangleState components — sorted by triangle_id for determinism (D-010).
let mut triangle_states: Vec<TriangleState> = {
let mut q = world.query::<&TriangleState>();
q.iter(world).cloned().collect()
};
triangle_states.sort_by_key(|t| t.triangle_id.0);
let state = SaveStateV1 {
format_version: SAVE_FORMAT_VERSION,
tick,
tick_rate,
seed,
player_knowledge,
relationship_graph,
npc_states,
template_references,
triangle_states,
open_doors: {
use crate::knowledge::registry::StableEntityId;
let mut q = world.query::<(&DoorState, &StableEntityId)>();
let mut ids: Vec<_> = q
.iter(world)
.filter(|(ds, _)| ds.is_open)
.map(|(_, sid)| sid.0)
.collect();
ids.sort_by_key(|id| id.0);
ids
},
modifications: vec![], // TODO: persist when modification system is implemented
contamination_active: world
.get_resource::<ContaminationActive>()
.is_some_and(|c| c.0),
activated_count: world
.get_resource::<ActivationState>()
.map_or(0, |a| a.activated_count),
last_activation_tick: world
.get_resource::<ActivationState>()
.and_then(|a| a.last_activation_tick),
};
let bytes = state
.to_bytes()
.map_err(|e| SaveLoadError::Serialize(e.to_string()))?;
std::fs::write(path, &bytes).map_err(|e| SaveLoadError::Io(e.to_string()))?;
tracing::info!(
"save_to_file: {:?} (tick={}, npcs={}, {} bytes)",
path,
tick,
npc_count,
bytes.len()
);
Ok(())
}
/// Read `path`, deserialise `SaveStateV1`, and re-inject state into the ECS (#553).
///
/// Steps:
/// 1. Read and deserialise bytes; reject if `format_version != SAVE_FORMAT_VERSION`.
/// 2. Despawn all existing NPC entities and unregister them from `EntityRegistry`.
/// 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. 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
/// new `Entity` handles after re-spawn.
///
/// # Errors
/// `SaveLoadError::Io` on filesystem failure.
/// `SaveLoadError::Deserialize` on MessagePack decoding failure.
/// `SaveLoadError::VersionMismatch` when the save file predates the current schema.
pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> {
let bytes = std::fs::read(path).map_err(|e| SaveLoadError::Io(e.to_string()))?;
let state =
SaveStateV1::from_bytes(&bytes).map_err(|e| SaveLoadError::Deserialize(e.to_string()))?;
if state.format_version != SAVE_FORMAT_VERSION {
return Err(SaveLoadError::VersionMismatch {
expected: SAVE_FORMAT_VERSION,
found: state.format_version,
});
}
let npc_count = state.npc_states.len();
// Despawn all existing NPC entities and clear their registry entries.
let npc_entities: Vec<Entity> = {
let mut q = world.query_filtered::<Entity, With<Npc>>();
q.iter(world).collect()
};
for entity in npc_entities {
world.resource_mut::<EntityRegistry>().unregister(entity);
world.despawn(entity);
}
// Track the highest restored StableId so we can advance the counter.
let mut max_id: u64 = 0;
// Re-spawn NPCs, assign tier marker, register pre-existing StableIds.
for npc_state in &state.npc_states {
let entity = deserialize_npc_from_frozen(npc_state, world);
// Loaded NPCs start in BackgroundSim; the distance system promotes as needed.
world.entity_mut(entity).insert(BackgroundSim);
let stable_id = npc_state.stable_id;
world
.resource_mut::<EntityRegistry>()
.register_existing(entity, stable_id);
max_id = max_id.max(stable_id.0);
}
// Advance the registry counter past all restored IDs so future register()
// calls produce non-conflicting IDs.
if npc_count > 0 {
world.resource_mut::<EntityRegistry>().advance_past(max_id);
}
// Restore simulation resources.
world.insert_resource(state.relationship_graph);
world.insert_resource(state.template_references);
// 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(), ActiveSim));
}
{
let mut t = world.resource_mut::<SimulationTime>();
t.tick = state.tick;
t.tick_rate = state.tick_rate;
}
world.insert_resource(SimRng::new(state.seed));
// Restore contamination state (#254) — prevents double-firing on reload.
world.insert_resource(ContaminationActive(state.contamination_active));
// Restore activation state (#572) — prevents double-activation on reload.
world.insert_resource(ActivationState {
activated_count: state.activated_count,
last_activation_tick: state.last_activation_tick,
});
// Reset event queues and transient buffers — prevent stale events/history
// from the pre-load world leaking into the post-load simulation.
world.insert_resource(ContaminationEventQueue::default());
world.insert_resource(TriangleCrisisEventQueue::default());
world.insert_resource(TriangleActivatedQueue::default());
world.insert_resource(MovementHistoryBuffer::default());
// Restore door open states (#246) — find door entities by StableId and toggle.
if !state.open_doors.is_empty() {
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)
.filter(|(_, sid, _)| open_set.contains(&sid.0))
.map(|(e, sid, _)| (e, sid.0))
.collect()
};
for (entity, sid) in door_entities {
if let Some(mut door) = world.get_mut::<DoorState>(entity) {
door.is_open = true;
let tile = door.blocking_tile;
if let Some(mut wmap) =
world.get_resource_mut::<crate::simulation::movement::WalkabilityMap>()
{
wmap.set_walkable(&tile, true);
}
tracing::debug!(stable_id = sid.0, "load: restored open door state");
}
}
}
// Update the player entity's KnowledgeGraph if a player exists.
let player_entity = {
let mut q = world.query_filtered::<Entity, With<PlayerCharacter>>();
q.single(world).ok()
};
if let Some(player_entity) = player_entity {
world
.entity_mut(player_entity)
.insert(state.player_knowledge);
}
tracing::info!(
"load_from_file: {:?} (tick={}, npcs={})",
path,
state.tick,
npc_count,
);
Ok(())
}
/// Exclusive system: drain `SaveLoadPending` and execute queued save/load (#553).
///
/// Runs each tick, after `process_player_input`. If a command is pending,
/// executes it and writes `SaveLoadResultWire` to `SnapshotBuffer.pending_save_result`
/// for consumption by `compute_observer_snapshot` the same tick.
pub fn execute_save_load(world: &mut World) {
// Take the pending command (releases the borrow before we use world again).
let command = {
let mut pending = world.resource_mut::<SaveLoadPending>();
pending.pending.take()
};
let Some(command) = command else {
return;
};
let (kind_str, result) = match &command {
SaveLoadCommand::Save { path } => {
let r = save_to_file(path, world);
("save", r)
}
SaveLoadCommand::Load { path } => {
let r = load_from_file(path, world);
("load", r)
}
};
let wire_result = match result {
Ok(()) => {
tracing::info!("execute_save_load: {} completed", kind_str);
SaveLoadResultWire {
success: true,
kind: kind_str.to_string(),
error: None,
}
}
Err(ref e) => {
tracing::error!("execute_save_load: {} failed: {}", kind_str, e);
SaveLoadResultWire {
success: false,
kind: kind_str.to_string(),
error: Some(e.to_string()),
}
}
};
// Write result to SnapshotBuffer for client feedback (one tick only — consumed by
// compute_observer_snapshot via pending_save_result.take()).
if let Some(mut buf) = world.get_resource_mut::<SnapshotBuffer>() {
buf.pending_save_result = Some(wire_result);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::knowledge::types::StableId;
use crate::npc::relationships::RelationshipGraph;
use crate::npc::Npc;
use crate::simulation::movement::TilePosition;
use crate::simulation::rng::SimRng;
use crate::simulation::save_state::{SaveStateV1, SAVE_FORMAT_VERSION};
use crate::simulation::time::{SimulationTime, TickRate};
use bevy_ecs::world::World;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_path() -> PathBuf {
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("settled_reach_save_io_test_{}.msgpack", id))
}
fn minimal_world() -> World {
let mut w = World::new();
w.insert_resource(SimulationTime::default());
w.insert_resource(SimRng::new(42));
w.insert_resource(RelationshipGraph::new());
w.init_resource::<EntityRegistry>();
w.init_resource::<ContaminationActive>();
w.init_resource::<ActivationState>();
w
}
fn spawn_test_npc(world: &mut World, stable_id: u64) -> Entity {
world
.spawn((
Npc,
StableEntityId(StableId(stable_id)),
TilePosition::new(stable_id as i32, 0, 0),
))
.id()
}
// -----------------------------------------------------------------------
// save_to_file
// -----------------------------------------------------------------------
#[test]
fn save_to_file_creates_valid_msgpack() {
let mut world = minimal_world();
spawn_test_npc(&mut world, 1);
spawn_test_npc(&mut world, 2);
let path = temp_path();
save_to_file(&path, &mut world).expect("save should succeed");
let bytes = std::fs::read(&path).expect("file should exist");
let state = SaveStateV1::from_bytes(&bytes).expect("bytes must be valid msgpack");
assert_eq!(state.format_version, SAVE_FORMAT_VERSION);
assert_eq!(state.npc_states.len(), 2);
let _ = std::fs::remove_file(&path);
}
#[test]
fn save_to_file_sorts_npc_states_by_stable_id() {
let mut world = minimal_world();
// Spawn in reverse order — save should still sort ascending
spawn_test_npc(&mut world, 50);
spawn_test_npc(&mut world, 10);
spawn_test_npc(&mut world, 30);
let path = temp_path();
save_to_file(&path, &mut world).expect("save should succeed");
let bytes = std::fs::read(&path).expect("read saved file");
let state = SaveStateV1::from_bytes(&bytes).unwrap();
let ids: Vec<u64> = state.npc_states.iter().map(|n| n.stable_id.0).collect();
assert_eq!(
ids,
vec![10, 30, 50],
"npc_states must be sorted by stable_id"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn save_to_file_preserves_tick_and_seed() {
let mut world = minimal_world();
{
let mut t = world.resource_mut::<SimulationTime>();
t.tick = 9999;
t.tick_rate = TickRate::Half;
}
world.insert_resource(SimRng::new(0xDEADBEEF));
let path = temp_path();
save_to_file(&path, &mut world).expect("save");
let bytes = std::fs::read(&path).unwrap();
let state = SaveStateV1::from_bytes(&bytes).unwrap();
assert_eq!(state.tick, 9999);
assert_eq!(state.tick_rate, TickRate::Half);
assert_eq!(state.seed, 0xDEADBEEF);
let _ = std::fs::remove_file(&path);
}
#[test]
fn save_to_file_returns_io_error_on_bad_path() {
let mut world = minimal_world();
let bad_path = std::path::Path::new("/nonexistent/directory/save.msgpack");
let result = save_to_file(bad_path, &mut world);
assert!(
matches!(result, Err(SaveLoadError::Io(_))),
"expected Io error for bad path"
);
}
// -----------------------------------------------------------------------
// load_from_file
// -----------------------------------------------------------------------
#[test]
fn load_from_file_restores_npc_count() {
let mut world = minimal_world();
spawn_test_npc(&mut world, 1);
spawn_test_npc(&mut world, 2);
let path = temp_path();
save_to_file(&path, &mut world).expect("save");
// Spawn an extra NPC — loading should despawn the old NPCs and restore exactly 2
spawn_test_npc(&mut world, 99);
let pre_load_count = {
let mut q = world.query_filtered::<Entity, With<Npc>>();
q.iter(&world).count()
};
assert_eq!(pre_load_count, 3, "three NPCs before load");
load_from_file(&path, &mut world).expect("load");
let post_load_count = {
let mut q = world.query_filtered::<Entity, With<Npc>>();
q.iter(&world).count()
};
assert_eq!(post_load_count, 2, "exactly the two saved NPCs after load");
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_from_file_restores_stable_ids_in_registry() {
let mut world = minimal_world();
spawn_test_npc(&mut world, 10);
spawn_test_npc(&mut world, 20);
let path = temp_path();
save_to_file(&path, &mut world).expect("save");
load_from_file(&path, &mut world).expect("load");
let registry = world.resource::<EntityRegistry>();
assert!(
registry.to_entity(&StableId(10)).is_some(),
"StableId(10) must be in registry after load"
);
assert!(
registry.to_entity(&StableId(20)).is_some(),
"StableId(20) must be in registry after load"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_from_file_restores_tick_and_seed() {
let mut world = minimal_world();
{
let mut t = world.resource_mut::<SimulationTime>();
t.tick = 5000;
}
world.insert_resource(SimRng::new(12345));
let path = temp_path();
save_to_file(&path, &mut world).expect("save");
// Change time and seed, then load
{
let mut t = world.resource_mut::<SimulationTime>();
t.tick = 1;
}
world.insert_resource(SimRng::new(0));
load_from_file(&path, &mut world).expect("load");
let t = world.resource::<SimulationTime>();
assert_eq!(t.tick, 5000, "tick restored from save");
assert_eq!(
world.resource::<SimRng>().seed(),
12345,
"seed restored from save"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_from_file_rejects_wrong_format_version() {
use crate::simulation::triangle::TemplateReferenceMap;
// Craft a save with a wrong format_version
let bad_state = SaveStateV1 {
format_version: 0xFF, // deliberately wrong
tick: 0,
tick_rate: TickRate::Full,
seed: 0,
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
open_doors: vec![],
modifications: vec![],
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
};
let bytes = bad_state.to_bytes().expect("serialize");
let path = temp_path();
std::fs::write(&path, &bytes).expect("write");
let mut world = minimal_world();
let result = load_from_file(&path, &mut world);
assert!(
matches!(result, Err(SaveLoadError::VersionMismatch { .. })),
"expected VersionMismatch error, got {:?}",
result
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_from_file_returns_io_error_for_missing_file() {
let mut world = minimal_world();
let missing = std::path::Path::new("/tmp/settled_reach_nonexistent_42.msgpack");
let result = load_from_file(missing, &mut world);
assert!(
matches!(result, Err(SaveLoadError::Io(_))),
"expected Io error for missing file"
);
}
#[test]
fn load_from_file_assigns_background_sim_tier() {
let mut world = minimal_world();
spawn_test_npc(&mut world, 1);
let path = temp_path();
save_to_file(&path, &mut world).expect("save");
load_from_file(&path, &mut world).expect("load");
let has_background: bool = {
let mut q = world.query_filtered::<Entity, (With<Npc>, With<BackgroundSim>)>();
q.iter(&world).count() > 0
};
assert!(has_background, "loaded NPC should be in BackgroundSim tier");
let _ = std::fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// execute_save_load
// -----------------------------------------------------------------------
#[test]
fn execute_save_load_noop_when_no_pending() {
let mut world = minimal_world();
world.init_resource::<SaveLoadPending>();
world.init_resource::<SnapshotBuffer>();
execute_save_load(&mut world);
// No result written when no pending command
let buf = world.resource::<SnapshotBuffer>();
assert!(
buf.pending_save_result.is_none(),
"no pending_save_result when no command was queued"
);
}
#[test]
fn execute_save_load_writes_success_result() {
let mut world = minimal_world();
world.init_resource::<SnapshotBuffer>();
let path = temp_path();
world.insert_resource(SaveLoadPending {
pending: Some(SaveLoadCommand::Save { path: path.clone() }),
});
execute_save_load(&mut world);
let buf = world.resource::<SnapshotBuffer>();
let result = buf
.pending_save_result
.as_ref()
.expect("result must be written after execute");
assert!(result.success, "save should succeed");
assert_eq!(result.kind, "save");
assert!(result.error.is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn execute_save_load_writes_error_result_on_bad_path() {
let mut world = minimal_world();
world.init_resource::<SnapshotBuffer>();
world.insert_resource(SaveLoadPending {
pending: Some(SaveLoadCommand::Save {
path: PathBuf::from("/nonexistent/dir/save.msgpack"),
}),
});
execute_save_load(&mut world);
let buf = world.resource::<SnapshotBuffer>();
let result = buf
.pending_save_result
.as_ref()
.expect("result must be written even on failure");
assert!(!result.success, "save should fail with bad path");
assert_eq!(result.kind, "save");
assert!(result.error.is_some(), "error message should be present");
}
// -----------------------------------------------------------------------
// Overwrite behaviour
// -----------------------------------------------------------------------
/// When two commands arrive in the same tick, the second overwrites the first.
/// The warn! in process_player_input fires; here we just confirm last-write-wins.
#[test]
fn pending_command_overwrite_last_write_wins() {
let mut pending = SaveLoadPending::default();
pending.pending = Some(SaveLoadCommand::Save {
path: PathBuf::from("/tmp/first.msgpack"),
});
// Overwrite with a Load command
pending.pending = Some(SaveLoadCommand::Load {
path: PathBuf::from("/tmp/second.msgpack"),
});
match pending.pending.unwrap() {
SaveLoadCommand::Load { ref path } => {
assert_eq!(path.to_str().unwrap(), "/tmp/second.msgpack");
}
other => panic!("expected Load, got {:?}", other),
}
}
// -----------------------------------------------------------------------
// SaveLoadError display
// -----------------------------------------------------------------------
#[test]
fn save_load_error_display() {
let e = SaveLoadError::Io("disk full".into());
assert!(e.to_string().contains("disk full"));
let e2 = SaveLoadError::VersionMismatch {
expected: 1,
found: 2,
};
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::simulation::triangle::{
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);
}
// -----------------------------------------------------------------------
// ActivationState roundtrip (#572, Task #12)
// -----------------------------------------------------------------------
#[test]
fn load_from_file_restores_activation_state() {
let mut world = minimal_world();
// Set activation state: 1 activation at tick 500
world.insert_resource(ActivationState {
activated_count: 1,
last_activation_tick: Some(500),
});
let path = temp_path();
save_to_file(&path, &mut world).expect("save");
// Reset activation state to defaults before load
world.insert_resource(ActivationState::default());
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
assert!(world
.resource::<ActivationState>()
.last_activation_tick
.is_none());
load_from_file(&path, &mut world).expect("load");
let state = world.resource::<ActivationState>();
assert_eq!(
state.activated_count, 1,
"activated_count must survive save/load"
);
assert_eq!(
state.last_activation_tick,
Some(500),
"last_activation_tick must survive save/load"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_from_file_restores_zero_activation_state() {
// Verify that saves with no activations restore correctly (serde(default))
let mut world = minimal_world();
let path = temp_path();
save_to_file(&path, &mut world).expect("save");
// Pollute state before load
world.insert_resource(ActivationState {
activated_count: 5,
last_activation_tick: Some(9999),
});
load_from_file(&path, &mut world).expect("load");
let state = world.resource::<ActivationState>();
assert_eq!(state.activated_count, 0);
assert!(state.last_activation_tick.is_none());
let _ = std::fs::remove_file(&path);
}
}