Files
settled-reach/server/src/simulation/save_state.rs
T
jpmschweitzerandClaude Opus 4.6 61eb40fcab feat(simulation): add modifications data model stub (#567, D-112)
DLC entry point for future player construction system. Adds
Modification struct, ModificationType enum, and Modifications
component. Wired into SaveStateV1 with #[serde(default)] for
forward-compatible save format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:34:08 +01:00

896 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Save state data model (#256, D-010).
//!
//! `SaveStateV1` is the versioned serialization envelope for full game state.
//! Shares architecture with #96 (state serialization system) — this module
//! defines the data model AND the per-NPC serialization primitives for tier
//! eviction freeze/thaw (#96).
//!
//! ## Write format: MessagePack
//!
//! Decision: MessagePack via `rmp_serde` (consistent with IPC protocol, D-020).
//! Both the IPC protocol and save files use the same codec for simplicity.
//! RON/YAML alternatives were considered — MessagePack chosen for consistency.
//! Human-readable debug output can be derived via the Debug impl or a separate
//! conversion step; a full RON bridge is deferred beyond v0.1.
//!
//! ## Versioning strategy
//!
//! `format_version: u8` bumps on breaking schema changes. Loader checks version
//! and rejects incompatible saves. `serde(default)` on optional new fields allows
//! forward-compatible extensions within the same major version.
//!
//! ## What is captured (v0.1 scope)
//!
//! - Simulation clock: `tick` + `tick_rate` for correct time reconstruction
//! - RNG seed: reproduce the same random sequence on load (D-010)
//! - Player knowledge graph: the observer's epistemics at save time
//! - Global relationship graph: the NPC social web (resource, not per-entity)
//! - Per-NPC summary state: the axis values that drive tell/mood/dialogue
//!
//! ## Not yet captured (deferred to #257 and beyond)
//!
//! - Full ECS world extraction/injection (system not yet written)
//! - Pathfinding state (reconstructed from position + routine)
//! - Tier transitions in-flight (dropped to background state on load)
//! - `NpcMemory` (intentionally excluded — stale inferences would be wrong after
//! reload; memory degrades naturally over time so reset-on-load is acceptable)
use bevy_ecs::entity::Entity;
use bevy_ecs::world::World;
use serde::{Deserialize, Serialize};
use crate::content::template::{TemplateOwnership, TemplateReferenceMap, TriangleState};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::StableId;
use crate::simulation::modification::Modification;
use crate::npc::{
CombatCapability, Contentment, DailyRoutine, InformationInventory, JobPerformance, Npc,
PersonalityTraits, Relationships, Secret, SecretSeverity, SkillSet, TellSystem,
ToleranceThreshold, Want, WantKind,
};
use crate::npc::awareness::PlayerAwareness;
use crate::npc::mood::MoodState;
use crate::npc::relationships::RelationshipGraph;
use crate::npc::vision::{NpcMemory, NpcVisionState};
use crate::simulation::movement::TilePosition;
use crate::simulation::time::TickRate;
/// Current format version. Bump on any breaking schema change.
pub const SAVE_FORMAT_VERSION: u8 = 1;
/// Top-level save state envelope (#256, D-010).
///
/// Serialized with MessagePack (rmp_serde) for storage. Load with
/// `rmp_serde::from_slice::<SaveStateV1>(&bytes)`.
///
/// Versioned from day one: check `format_version == SAVE_FORMAT_VERSION`
/// before trusting content.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveStateV1 {
/// Format version. Must equal `SAVE_FORMAT_VERSION` on load.
pub format_version: u8,
/// Simulation tick at the moment of save.
pub tick: u64,
/// Active tick rate at save time (Full/Half/Paused).
pub tick_rate: TickRate,
/// RNG seed active at save time for deterministic replay (D-010).
/// On load, seed the RNG from this value before advancing any ticks.
pub seed: u64,
/// Player character knowledge graph — the observer's epistemics at save time.
pub player_knowledge: KnowledgeGraph,
/// Global NPC relationship graph resource.
/// Serialized as a unit: all directed edges between NPCs and player.
pub relationship_graph: RelationshipGraph,
/// Per-NPC summary state for each simulated NPC.
/// Order is deterministic (sorted by stable_id in ascending order).
pub npc_states: Vec<NpcSaveState>,
/// Cross-template reference links (#165).
/// Preserved across save/load so that tier-evicted templates retain their
/// relationship metadata even when their NPCs are not in Active tier.
#[serde(default)]
pub template_references: TemplateReferenceMap,
/// Triangle escalation states (#250).
/// Persisted so tension/phase survive save/load. Sorted by triangle_id
/// for deterministic serialization (D-010).
#[serde(default)]
pub triangle_states: Vec<TriangleState>,
/// Stable IDs of doors that are currently open (#246).
/// Doors not in this list are assumed closed on load. Sorted ascending
/// for deterministic serialization (D-010).
#[serde(default)]
pub open_doors: Vec<StableId>,
/// Player-placed modifications to map chunks (D-111/D-112, #567).
/// DLC stub — empty in v0.1. The save slot exists so future construction
/// DLC can populate it without a save format migration.
#[serde(default)]
pub modifications: Vec<Modification>,
}
/// Per-NPC state snapshot for `SaveStateV1`.
///
/// Two usage contexts:
/// 1. **Whole-game save** (`SaveStateV1.npc_states`): populated by #553 ECS extraction.
/// Only the core axis fields need to be populated for this use case.
/// 2. **Tier eviction freeze** (produced by `serialize_npc_to_frozen`): captures ALL
/// components needed for full NPC reconstruction from `StateSaved` tier.
/// The extended optional fields (#96) carry all 10 D-024 axes.
///
/// All fields added post-#256 use `#[serde(default)]` for forward compatibility
/// with older save files that predate these fields.
///
/// ## D-024 axis coverage
/// | Axis | Field | Status |
/// |------|-------|--------|
/// | 1: Want | `want` | Full (optional for backward compat) |
/// | 2: Secret | `secret_severity` (legacy) + `secret` | Full |
/// | 3: Relationships | `relationships` | Full |
/// | 4: Tolerance | `current_stress` + `tolerance_threshold` | Full |
/// | 5: Daily routine | `routine` | Full (optional) |
/// | 6: Information inventory | `information_inventory` | Full (optional) |
/// | 7: Contentment | `contentment` | Full |
/// | Supporting 1: Personality | `personality_traits` | Full (optional) |
/// | Supporting 2: Tells | `tell_system` | Full (optional) |
/// | Supporting 3: Skills | `skill_set` + `combat_capability` | Full (optional) |
///
/// ## Components intentionally NOT serialized
/// - `NpcVisionState`: runtime LOS state, reset to default on reactivation
/// - `NpcMemory`: stale inferences would be wrong after reload (intentional drop)
/// - `PlayerAwareness`: runtime derived state, reset to default on reactivation
/// - `AnimationTier`: resets to `Tier1` on reactivation (no persistent state)
/// - `RoutineDeviation`: transient event marker, acceptable to drop on reload
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpcSaveState {
/// Stable entity identifier (survives serialization — D-020).
pub stable_id: StableId,
/// Last known tile position.
pub position: TilePosition,
// Axis 2: Secret severity (legacy field — description regenerated from content on load).
// Kept for backward compatibility. Prefer `secret` field when doing full reconstruction.
pub secret_severity: SecretSeverity,
// Axis 3: Per-NPC relationship slots
pub relationships: Option<Relationships>,
// Axis 4: Tolerance — current stress level at save time
pub current_stress: i16,
/// Tolerance threshold value (does not change at runtime).
pub tolerance_threshold: i16,
// Axis 7: Contentment
pub contentment: i16,
/// Per-NPC knowledge graph (if present — Active-tier NPCs carry KG).
pub knowledge_graph: Option<KnowledgeGraph>,
// --- Full reconstruction fields (added #96, for tier eviction freeze) ---
// All fields below use serde(default) for backward compatibility with saves
// created before #96 shipped.
/// Axis 1: Want (primary drive, intensity, and description).
#[serde(default)]
pub want: Option<Want>,
/// Axis 2: Full secret (description + known_by list).
/// Supersedes `secret_severity` for full reconstruction.
#[serde(default)]
pub secret: Option<Secret>,
/// Axis 5: Daily routine (phase → location schedule).
#[serde(default)]
pub routine: Option<DailyRoutine>,
/// Axis 6: Information inventory (facts this NPC carries).
#[serde(default)]
pub information_inventory: Option<InformationInventory>,
/// Supporting axis 1: Personality traits (23 traits, no contradictory pairs).
#[serde(default)]
pub personality_traits: Option<PersonalityTraits>,
/// Supporting axis 2: Tell system (behavioral tells tied to stress/personality).
#[serde(default)]
pub tell_system: Option<TellSystem>,
/// Supporting axis 3: Skill set (proficiency BTreeMap).
#[serde(default)]
pub skill_set: Option<SkillSet>,
/// Optional combat capability (only present for combat-trained NPCs).
#[serde(default)]
pub combat_capability: Option<CombatCapability>,
/// Mood state at save time. Derived from stress but worth preserving across
/// tier transitions to avoid jarring state resets on reactivation.
#[serde(default)]
pub mood_state: Option<MoodState>,
/// Job performance score — drifts over time, persist across tier transitions.
#[serde(default)]
pub job_performance: Option<JobPerformance>,
/// Template ownership (#165): which template owns this NPC and which role it fills.
/// `None` for NPCs that predate the template system or were hand-authored without
/// template assignment. Preserved across tier transitions (D-025 single-ownership).
#[serde(default)]
pub template_ownership: Option<TemplateOwnership>,
}
impl SaveStateV1 {
/// Serialize to MessagePack bytes.
pub fn to_bytes(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
rmp_serde::to_vec_named(self)
}
/// Deserialize from MessagePack bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
rmp_serde::from_slice(bytes)
}
}
// ---------------------------------------------------------------------------
// Per-NPC tier eviction serialization primitives (#96)
// ---------------------------------------------------------------------------
/// Serialize a live NPC entity to a `NpcSaveState` frozen struct.
///
/// Used by the tier eviction system when demoting an entity to `StateSaved`:
/// instead of keeping all ECS components live, the entity is frozen and despawned.
/// The caller should despawn the entity after calling this function.
///
/// **Caller invariant:** The entity must have a `StableEntityId` component.
/// All other components are optional — missing components produce sensible defaults
/// in the output (and will be reconstructed as defaults by `deserialize_npc_from_frozen`).
///
/// # Panics
/// Panics if the entity has no `StableEntityId` component.
pub fn serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState {
let position = world
.get::<TilePosition>(entity)
.copied()
.unwrap_or_else(|| TilePosition::new(0, 0, 0));
let stable_id = world
.get::<StableEntityId>(entity)
.map(|s| s.0)
.expect("NPC entity must have StableEntityId before serialization (#96)");
let secret = world.get::<Secret>(entity).cloned();
let secret_severity = secret
.as_ref()
.map(|s| s.severity)
.unwrap_or(SecretSeverity::Minor);
let (current_stress, tolerance_threshold) = world
.get::<ToleranceThreshold>(entity)
.map(|t| (t.current_stress, t.threshold))
.unwrap_or((0, 50));
NpcSaveState {
stable_id,
position,
secret_severity,
relationships: world.get::<Relationships>(entity).cloned(),
current_stress,
tolerance_threshold,
contentment: world
.get::<Contentment>(entity)
.map(|c| c.level)
.unwrap_or(0),
knowledge_graph: world.get::<KnowledgeGraph>(entity).cloned(),
want: world.get::<Want>(entity).cloned(),
secret,
routine: world.get::<DailyRoutine>(entity).cloned(),
information_inventory: world.get::<InformationInventory>(entity).cloned(),
personality_traits: world.get::<PersonalityTraits>(entity).cloned(),
tell_system: world.get::<TellSystem>(entity).cloned(),
skill_set: world.get::<SkillSet>(entity).cloned(),
combat_capability: world.get::<CombatCapability>(entity).cloned(),
mood_state: world.get::<MoodState>(entity).cloned(),
job_performance: world.get::<JobPerformance>(entity).cloned(),
template_ownership: world.get::<TemplateOwnership>(entity).cloned(),
}
}
/// Deserialize a frozen `NpcSaveState` and re-spawn a full NPC entity.
///
/// Used by the tier eviction system when reactivating an entity from `StateSaved`.
/// Reconstructs all D-024 axis components from the frozen state.
///
/// **Caller responsibilities after calling this function:**
/// 1. Register the returned `Entity` with `EntityRegistry` (StableId→Entity mapping).
/// 2. Assign the appropriate tier marker (`ActiveSim` or `BackgroundSim`).
///
/// Optional fields that are absent in `state` are reconstructed with sensible defaults:
/// - `Want`: defaults to `Safety` at intensity 5 (conservative non-disruptive default)
/// - `Secret`: reconstructed from `secret_severity` with empty description
/// - `MoodState`, `JobPerformance`: their `Default` implementations
///
/// Components excluded from reconstruction (see `NpcSaveState` doc for rationale):
/// `NpcVisionState`, `NpcMemory`, `PlayerAwareness` are reset to their `Default` states.
pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> Entity {
let want = state.want.clone().unwrap_or(Want {
primary: WantKind::Safety, // conservative fallback — see flag comment above
intensity: 5,
description: String::new(),
});
let secret = state.secret.clone().unwrap_or(crate::npc::Secret {
description: String::new(),
severity: state.secret_severity,
known_by: vec![],
});
let relationships = state
.relationships
.clone()
.unwrap_or(Relationships { entries: vec![] });
let tolerance = ToleranceThreshold {
current_stress: state.current_stress,
threshold: state.tolerance_threshold,
};
let contentment = Contentment {
level: state.contentment,
};
let kg = state
.knowledge_graph
.clone()
.unwrap_or_else(KnowledgeGraph::new);
// Spawn the entity with all required components. Tier marker (ActiveSim /
// BackgroundSim) is NOT added here — the caller assigns it after registration.
let entity = world
.spawn((
Npc,
state.position,
StableEntityId(state.stable_id),
want,
secret,
relationships,
tolerance,
contentment,
kg,
state.mood_state.clone().unwrap_or_default(),
state.job_performance.clone().unwrap_or_default(),
// Runtime-computed components: reset to default on reactivation.
NpcVisionState::default(),
NpcMemory::default(),
PlayerAwareness::default(),
))
.id();
// Optional axis components — insert only if present in frozen state.
{
let mut em = world.entity_mut(entity);
if let Some(routine) = state.routine.clone() {
em.insert(routine);
}
if let Some(inventory) = state.information_inventory.clone() {
em.insert(inventory);
}
if let Some(traits) = state.personality_traits.clone() {
em.insert(traits);
}
if let Some(tells) = state.tell_system.clone() {
em.insert(tells);
}
if let Some(skills) = state.skill_set.clone() {
em.insert(skills);
}
if let Some(combat) = state.combat_capability.clone() {
em.insert(combat);
}
// Restore template ownership if present — never reassigned after initial spawn (D-025).
if let Some(ownership) = state.template_ownership.clone() {
em.insert(ownership);
}
}
entity
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::StableEntityId;
use crate::knowledge::types::{FactId, FactKnowledge, KnowledgeConfidence, StableId};
use crate::npc::relationships::RelationshipGraph;
use crate::simulation::movement::TilePosition;
use crate::simulation::time::TickRate;
fn minimal_save_state() -> SaveStateV1 {
SaveStateV1 {
format_version: SAVE_FORMAT_VERSION,
tick: 0,
tick_rate: TickRate::Full,
seed: 42,
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
open_doors: vec![],
modifications: vec![],
}
}
// -----------------------------------------------------------------------
// Roundtrip tests: serialize → deserialize → re-serialize → bytes match
// -----------------------------------------------------------------------
#[test]
fn empty_save_state_roundtrips() {
// Spec (#256): roundtrip must produce identical state.
// Strategy: bytes(original) == bytes(roundtrip(original))
let state = minimal_save_state();
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(bytes, bytes2, "empty save state must roundtrip losslessly");
}
#[test]
fn format_version_preserved_in_roundtrip() {
let state = minimal_save_state();
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(
recovered.format_version, SAVE_FORMAT_VERSION,
"format version must survive roundtrip"
);
}
#[test]
fn tick_and_seed_preserved_in_roundtrip() {
let mut state = minimal_save_state();
state.tick = 12345;
state.seed = 0xDEADBEEF;
state.tick_rate = TickRate::Half;
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(recovered.tick, 12345);
assert_eq!(recovered.seed, 0xDEADBEEF);
assert_eq!(recovered.tick_rate, TickRate::Half);
}
#[test]
fn npc_states_roundtrip_with_position_and_axes() {
// Spec (#256): per-NPC D-024 axis values must survive serialization.
let mut state = minimal_save_state();
state.npc_states = vec![
NpcSaveState {
stable_id: StableId(101),
position: TilePosition::new(10, 20, 0),
secret_severity: crate::npc::SecretSeverity::Major,
relationships: None,
current_stress: 45,
tolerance_threshold: 80,
contentment: -15,
knowledge_graph: None,
want: None,
secret: None,
routine: None,
information_inventory: None,
personality_traits: None,
tell_system: None,
skill_set: None,
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
},
NpcSaveState {
stable_id: StableId(202),
position: TilePosition::new(5, 5, 1),
secret_severity: crate::npc::SecretSeverity::Minor,
relationships: None,
current_stress: 0,
tolerance_threshold: 60,
contentment: 30,
knowledge_graph: None,
want: None,
secret: None,
routine: None,
information_inventory: None,
personality_traits: None,
tell_system: None,
skill_set: None,
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
},
];
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(recovered.npc_states.len(), 2);
let npc1 = &recovered.npc_states[0];
assert_eq!(npc1.stable_id, StableId(101));
assert_eq!(npc1.position, TilePosition::new(10, 20, 0));
assert_eq!(npc1.secret_severity, crate::npc::SecretSeverity::Major);
assert_eq!(npc1.current_stress, 45);
assert_eq!(npc1.tolerance_threshold, 80);
assert_eq!(npc1.contentment, -15);
let npc2 = &recovered.npc_states[1];
assert_eq!(npc2.stable_id, StableId(202));
assert_eq!(npc2.contentment, 30);
}
#[test]
fn player_knowledge_graph_roundtrips() {
// Spec (#256): KnowledgeGraph is "already serializable" — verify it
// survives a save state roundtrip intact.
use crate::knowledge::types::{KnowledgeSource, KnowledgeState};
use crate::simulation::movement::TilePosition;
let mut state = minimal_save_state();
let mut kg = KnowledgeGraph::new();
kg.observe_entity(StableId(50), TilePosition::new(3, 3, 0), 5);
// Add a fact
kg.facts.insert(
FactId("contraband.ring_exists".into()),
FactKnowledge {
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 100,
disclosure_blocked: false,
},
);
state.player_knowledge = kg;
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
// Roundtrip the recovered state again — bytes must still match
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(
bytes, bytes2,
"KnowledgeGraph roundtrip must be idempotent"
);
}
#[test]
fn relationship_graph_roundtrips() {
use crate::npc::relationships::RelationshipEdge;
use crate::npc::RelationshipKind;
use crate::knowledge::types::StableId;
let mut state = minimal_save_state();
let mut rg = RelationshipGraph::new();
rg.set_relationship(
StableId(1),
StableId(2),
RelationshipEdge {
kind: RelationshipKind::Friend,
trust: 5,
history: vec![],
last_interaction_tick: 0,
},
);
state.relationship_graph = rg;
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(bytes, bytes2, "RelationshipGraph roundtrip must be lossless");
}
#[test]
fn npc_with_knowledge_graph_roundtrips() {
// Spec (#256): Active-tier NPCs carry KnowledgeGraph — must survive roundtrip.
let mut state = minimal_save_state();
let mut npc_kg = KnowledgeGraph::new();
npc_kg.observe_entity(StableId(99), TilePosition::new(7, 7, 0), 10);
state.npc_states = vec![NpcSaveState {
stable_id: StableId(1),
position: TilePosition::new(1, 1, 0),
secret_severity: crate::npc::SecretSeverity::Minor,
relationships: None,
current_stress: 0,
tolerance_threshold: 50,
contentment: 0,
knowledge_graph: Some(npc_kg),
want: None,
secret: None,
routine: None,
information_inventory: None,
personality_traits: None,
tell_system: None,
skill_set: None,
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
}];
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(
bytes, bytes2,
"NPC KnowledgeGraph roundtrip must be lossless"
);
}
#[test]
fn save_format_version_constant_is_one() {
// Document the version explicitly so CI catches unintentional bumps.
assert_eq!(SAVE_FORMAT_VERSION, 1);
}
// -----------------------------------------------------------------------
// Tier eviction serialization primitives (#96)
// -----------------------------------------------------------------------
fn spawn_minimal_npc(world: &mut World, stable_id: StableId) -> Entity {
use crate::npc::{
Contentment, Relationships, Secret, SecretSeverity, ToleranceThreshold, Want, WantKind,
};
use crate::npc::mood::MoodState;
use crate::npc::vision::{NpcMemory, NpcVisionState};
use crate::npc::awareness::PlayerAwareness;
world
.spawn((
Npc,
StableEntityId(stable_id),
TilePosition::new(5, 10, 0),
Want {
primary: WantKind::Safety,
intensity: 7,
description: "wants safety".into(),
},
Secret {
description: "has a minor secret".into(),
severity: SecretSeverity::Minor,
known_by: vec![],
},
Relationships { entries: vec![] },
ToleranceThreshold {
current_stress: 30,
threshold: 70,
},
Contentment { level: 15 },
MoodState::default(),
crate::npc::JobPerformance::default(),
KnowledgeGraph::new(),
NpcVisionState::default(),
NpcMemory::default(),
PlayerAwareness::default(),
))
.id()
}
#[test]
fn serialize_npc_to_frozen_captures_stable_id_and_position() {
let mut world = World::new();
let entity = spawn_minimal_npc(&mut world, StableId(42));
let frozen = serialize_npc_to_frozen(entity, &world);
assert_eq!(frozen.stable_id, StableId(42));
assert_eq!(frozen.position, TilePosition::new(5, 10, 0));
}
#[test]
fn serialize_npc_to_frozen_captures_axes() {
use crate::npc::SecretSeverity;
let mut world = World::new();
let entity = spawn_minimal_npc(&mut world, StableId(1));
let frozen = serialize_npc_to_frozen(entity, &world);
assert_eq!(frozen.secret_severity, SecretSeverity::Minor);
assert_eq!(frozen.current_stress, 30);
assert_eq!(frozen.tolerance_threshold, 70);
assert_eq!(frozen.contentment, 15);
// Full optional axes should be populated when components exist
assert!(frozen.want.is_some(), "want should be captured");
assert!(frozen.secret.is_some(), "secret should be captured");
}
#[test]
fn serialize_deserialize_roundtrip_produces_identical_component_values() {
// Spec (#96): serialize + deserialize produces an entity with identical values.
let mut world = World::new();
let original = spawn_minimal_npc(&mut world, StableId(77));
// Serialize
let frozen = serialize_npc_to_frozen(original, &world);
// Deserialize into a new entity
let restored = deserialize_npc_from_frozen(&frozen, &mut world);
// Verify StableEntityId matches
let orig_stable = world.get::<StableEntityId>(original).unwrap().0;
let rest_stable = world.get::<StableEntityId>(restored).unwrap().0;
assert_eq!(orig_stable, rest_stable, "StableId must match");
// Position
let orig_pos = world.get::<TilePosition>(original).copied().unwrap();
let rest_pos = world.get::<TilePosition>(restored).copied().unwrap();
assert_eq!(orig_pos, rest_pos, "position must match");
// Tolerance
let orig_tol = world.get::<ToleranceThreshold>(original).cloned().unwrap();
let rest_tol = world.get::<ToleranceThreshold>(restored).cloned().unwrap();
assert_eq!(orig_tol.current_stress, rest_tol.current_stress);
assert_eq!(orig_tol.threshold, rest_tol.threshold);
// Contentment
let orig_con = world.get::<Contentment>(original).cloned().unwrap();
let rest_con = world.get::<Contentment>(restored).cloned().unwrap();
assert_eq!(orig_con.level, rest_con.level, "contentment must match");
// Want
let orig_want = world.get::<Want>(original).cloned().unwrap();
let rest_want = world.get::<Want>(restored).cloned().unwrap();
assert_eq!(orig_want.primary, rest_want.primary, "want.primary must match");
assert_eq!(orig_want.intensity, rest_want.intensity, "want.intensity must match");
// Secret severity
let orig_secret = world.get::<Secret>(original).cloned().unwrap();
let rest_secret = world.get::<Secret>(restored).cloned().unwrap();
assert_eq!(orig_secret.severity, rest_secret.severity, "secret severity must match");
}
#[test]
fn deserialize_npc_without_optional_axes_uses_safe_defaults() {
// Spec (#96): optional fields absent in frozen state produce sensible defaults.
use crate::npc::SecretSeverity;
let frozen = NpcSaveState {
stable_id: StableId(999),
position: TilePosition::new(0, 0, 0),
secret_severity: SecretSeverity::Moderate,
relationships: None,
current_stress: 10,
tolerance_threshold: 50,
contentment: 0,
knowledge_graph: None,
want: None,
secret: None,
routine: None,
information_inventory: None,
personality_traits: None,
tell_system: None,
skill_set: None,
combat_capability: None,
mood_state: None,
job_performance: None,
template_ownership: None,
};
let mut world = World::new();
let entity = deserialize_npc_from_frozen(&frozen, &mut world);
// Entity must exist with required components
assert!(world.get::<Npc>(entity).is_some());
assert!(world.get::<StableEntityId>(entity).is_some());
assert!(world.get::<ToleranceThreshold>(entity).is_some());
assert!(world.get::<Contentment>(entity).is_some());
assert!(world.get::<Want>(entity).is_some(), "Want defaults to Safety");
assert!(world.get::<Secret>(entity).is_some(), "Secret built from secret_severity");
// Secret severity must be preserved from the legacy field
let secret = world.get::<Secret>(entity).unwrap();
assert_eq!(secret.severity, SecretSeverity::Moderate);
// Optional axes absent in frozen state → not inserted or use defaults
assert!(world.get::<DailyRoutine>(entity).is_none(), "routine absent when not frozen");
}
#[test]
fn frozen_npc_roundtrips_via_messagepack() {
// Spec (#96): NpcSaveState must survive MessagePack roundtrip.
let mut world = World::new();
let entity = spawn_minimal_npc(&mut world, StableId(55));
let frozen = serialize_npc_to_frozen(entity, &world);
// Wrap in SaveStateV1 for MessagePack encoding
let save = SaveStateV1 {
format_version: SAVE_FORMAT_VERSION,
tick: 100,
tick_rate: TickRate::Full,
seed: 12,
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![frozen],
template_references: TemplateReferenceMap::default(),
triangle_states: vec![],
open_doors: vec![],
modifications: vec![],
};
let bytes = save.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(bytes, bytes2, "frozen NPC state must roundtrip via MessagePack");
}
#[test]
fn serialize_npc_panics_without_stable_entity_id() {
// Spec (#96): StableEntityId is required — missing it is a programmer error.
let mut world = World::new();
let entity = world.spawn((Npc, TilePosition::new(0, 0, 0))).id();
// World doesn't implement UnwindSafe — wrap in AssertUnwindSafe.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
serialize_npc_to_frozen(entity, &world);
}));
assert!(result.is_err(), "must panic without StableEntityId");
}
// -----------------------------------------------------------------------
// Modifications stub round-trip (#567, D-111/D-112)
// -----------------------------------------------------------------------
#[test]
fn empty_modifications_roundtrips_in_save_state() {
// Acceptance (#567): empty modifications field survives save/load.
let state = minimal_save_state();
assert!(state.modifications.is_empty());
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert!(
recovered.modifications.is_empty(),
"empty modifications must roundtrip"
);
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(bytes, bytes2, "modifications roundtrip must be idempotent");
}
#[test]
fn populated_modifications_roundtrips_in_save_state() {
// Acceptance (#567): non-empty modifications field survives save/load.
use crate::simulation::modification::{ModificationType, Modification};
let mut state = minimal_save_state();
state.modifications = vec![
Modification {
position: TilePosition::new(10, 20, 0),
modification_type: ModificationType::Placeholder,
placed_at_tick: 500,
},
Modification {
position: TilePosition::new(3, 7, -1),
modification_type: ModificationType::Placeholder,
placed_at_tick: 1200,
},
];
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(
recovered.modifications.len(),
2,
"two modifications must survive roundtrip"
);
assert_eq!(recovered.modifications[0].position, TilePosition::new(10, 20, 0));
assert_eq!(recovered.modifications[0].placed_at_tick, 500);
assert_eq!(recovered.modifications[1].position, TilePosition::new(3, 7, -1));
assert_eq!(recovered.modifications[1].placed_at_tick, 1200);
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(bytes, bytes2, "modifications roundtrip must be idempotent");
}
}