Files
settled-reach/server/src/simulation/save_io.rs
T
jpmschweitzerandClaude Opus 4.6 1ea3d9c724 fix(simulation): persist TriangleState in SaveStateV1
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>
2026-02-25 22:48:18 +01:00

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