Triangle phase and tension were silently lost on save/load. Now serialized as triangle_states vec in SaveStateV1, sorted by triangle_id for determinism (D-010). Dedicated triangle entities are respawned on load. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
683 lines
23 KiB
Rust
683 lines
23 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::content::template::{TemplateReferenceMap, TriangleState};
|
|
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(|_| {
|
|
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,
|
|
};
|
|
|
|
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);
|
|
world.insert_resource(state.template_references);
|
|
|
|
// Restore triangle states (#250) — spawn dedicated entities for each.
|
|
for ts in &state.triangle_states {
|
|
world.spawn(ts.clone());
|
|
}
|
|
{
|
|
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() {
|
|
use crate::content::template::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![],
|
|
};
|
|
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"));
|
|
}
|
|
}
|