feat(simulation): Sprint 19 — 7 server systems
Protocol handshake (#555): HandshakeMessage as first IPC frame, HandshakeState resource, forward-compatible input handling. State serialization (#96): serialize_npc_to_frozen/deserialize with full D-024 axis coverage (10 new optional fields on NpcSaveState). Scope tags (#98): ScopeTagKind enum, ScopePinned marker, automatic assignment from KnowledgeGraph and RelationshipGraph. Timestamp eviction (#97): LastInteractionTick, SimSpacePressure, BinaryHeap LRU eviction respecting ScopePinned entities. Save/load (#553): save_to_file/load_from_file via MessagePack, SaveGame/LoadGame IPC commands, SaveLoadResultWire on snapshot. Test infrastructure (#200): Layer 3 integration test entry point, three-layer architecture documented per D-030. Information boundary tests (#272): 4 negative tests proving no passive KG leakage, LOS fog holds, tier boundary holds, save isolation per NPC. 1063 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,629 @@
|
||||
// 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::npc::Npc;
|
||||
use crate::npc::relationships::RelationshipGraph;
|
||||
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::BackgroundSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// 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(|_| 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();
|
||||
let state = SaveStateV1 {
|
||||
format_version: SAVE_FORMAT_VERSION,
|
||||
tick,
|
||||
tick_rate,
|
||||
seed,
|
||||
player_knowledge,
|
||||
relationship_graph,
|
||||
npc_states,
|
||||
};
|
||||
|
||||
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. Restore `RelationshipGraph`, `SimulationTime`, and `SimRng` resources.
|
||||
/// 6. Update the player entity's `KnowledgeGraph` if a player entity exists.
|
||||
///
|
||||
/// **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);
|
||||
{
|
||||
let mut t = world.resource_mut::<SimulationTime>();
|
||||
t.tick = state.tick;
|
||||
t.tick_rate = state.tick_rate;
|
||||
}
|
||||
world.insert_resource(SimRng::new(state.seed));
|
||||
|
||||
// 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::Npc;
|
||||
use crate::npc::relationships::RelationshipGraph;
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
// 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![],
|
||||
};
|
||||
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");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user