input.rs 2,739 → 858 lines: per-domain action handlers moved to their owning modules (inventory, movement, stance, examine, follow, interaction, save_io, settings, bridge::debug, economy, vision_cone, bookmark, test_world reset + new teleport.rs); input.rs keeps the queue, the thin dispatch table, and pause/cooldown glue. All 9 type_complexity allows dissolved via one PlayerInputQuery alias. dialogue.rs → dialogue/ directory module: selection (631), response (1,473), confrontation (714), mod.rs (226, shared session components + re-exports — public paths preserved). Documented seam deviation: process_walk_away lives with confrontation (D-064/D-063 share the same world-response shape). Mechanical, zero behavior change: determinism + golden_suite byte-identical (independently re-verified); 1,504 lib tests unchanged — 23 input tests moved with their subjects, 53 dialogue tests redistributed, zero deleted. System scheduling registrations untouched (input_plugin.rs 0-line diff). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1062 lines
38 KiB
Rust
1062 lines
38 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::bookmark::SelectedBookmark;
|
|
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>,
|
|
}
|
|
|
|
/// Queue a save/load command from player input (#553).
|
|
/// At most one command can be pending; a newer command overwrites it.
|
|
/// `None` means the resource was never registered (warn and drop).
|
|
pub fn queue_save_load(pending: Option<&mut SaveLoadPending>, cmd: SaveLoadCommand) {
|
|
let (label, path) = match &cmd {
|
|
SaveLoadCommand::Save { path } => ("SaveGame", path),
|
|
SaveLoadCommand::Load { path } => ("LoadGame", path),
|
|
};
|
|
let Some(sl) = pending else {
|
|
tracing::warn!(
|
|
"{} received but SaveLoadPending resource not registered",
|
|
label
|
|
);
|
|
return;
|
|
};
|
|
if sl.pending.is_some() {
|
|
tracing::warn!(
|
|
"{} overwrites already-pending save/load command (dropped)",
|
|
label
|
|
);
|
|
}
|
|
tracing::info!("{} queued: {:?}", label, path);
|
|
sl.pending = Some(cmd);
|
|
}
|
|
|
|
/// 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),
|
|
selected_bookmark: world
|
|
.get_resource::<SelectedBookmark>()
|
|
.cloned()
|
|
.unwrap_or_default(),
|
|
};
|
|
|
|
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,
|
|
});
|
|
|
|
// Restore bookmark selection (#863) — the player's confirmed bookmark and
|
|
// starting location survive save/load so downstream systems stay consistent.
|
|
world.insert_resource(state.selected_bookmark);
|
|
|
|
// 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 {
|
|
// Include the process ID so nextest processes (each starts COUNTER at 0)
|
|
// do not collide on the same filename when running concurrently.
|
|
let pid = std::process::id();
|
|
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
std::env::temp_dir().join(format!("settled_reach_save_io_test_{}_{}.msgpack", pid, 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,
|
|
selected_bookmark: SelectedBookmark::default(),
|
|
};
|
|
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 {
|
|
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);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SelectedBookmark round-trip (#863)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn save_load_preserves_selected_bookmark() {
|
|
let mut world = minimal_world();
|
|
|
|
// Set a bookmark selection before saving.
|
|
world.insert_resource(SelectedBookmark {
|
|
bookmark_id: Some("tycoon".to_string()),
|
|
starting_location_id: Some("new-stockholm".to_string()),
|
|
});
|
|
|
|
let path = temp_path();
|
|
save_to_file(&path, &mut world).expect("save");
|
|
|
|
// Clear the resource to prove load restores it, not the pre-existing value.
|
|
world.insert_resource(SelectedBookmark::default());
|
|
assert!(
|
|
world.resource::<SelectedBookmark>().bookmark_id.is_none(),
|
|
"bookmark must be cleared before load"
|
|
);
|
|
|
|
load_from_file(&path, &mut world).expect("load");
|
|
|
|
let restored = world.resource::<SelectedBookmark>();
|
|
assert_eq!(
|
|
restored.bookmark_id.as_deref(),
|
|
Some("tycoon"),
|
|
"bookmark_id must survive round-trip"
|
|
);
|
|
assert_eq!(
|
|
restored.starting_location_id.as_deref(),
|
|
Some("new-stockholm"),
|
|
"starting_location_id must survive round-trip"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn save_load_selected_bookmark_defaults_when_unset() {
|
|
// Save without a bookmark selection (default = both None).
|
|
// Load must produce SelectedBookmark::default(), not error.
|
|
let mut world = minimal_world();
|
|
// SelectedBookmark not explicitly inserted — should default to no selection.
|
|
|
|
let path = temp_path();
|
|
save_to_file(&path, &mut world).expect("save");
|
|
load_from_file(&path, &mut world).expect("load");
|
|
|
|
let restored = world.resource::<SelectedBookmark>();
|
|
assert!(
|
|
restored.bookmark_id.is_none(),
|
|
"unset bookmark_id must survive as None"
|
|
);
|
|
assert!(
|
|
restored.starting_location_id.is_none(),
|
|
"unset starting_location_id must survive as None"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
}
|