//! 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::simulation::triangle::{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; use crate::storyteller::EngagementRecord; /// 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::(&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, /// 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, /// 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, /// 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, /// Whether contamination has already activated (#254). /// Persisted to prevent double-firing on save/load — without this, /// reloading a save after tick 300 would re-trigger contamination /// and apply a duplicate tension delta to all ActiveFork triangles. #[serde(default)] pub contamination_active: bool, /// Number of triangles activated this session (#572). /// Persisted to prevent double-activation on save/load — without this, /// reloading a save after activation would reset the one-shot guard /// and allow a second triangle to be activated. #[serde(default)] pub activated_count: u32, /// Tick at which the most recent triangle activation occurred (#572). /// `None` if no activation yet. Persisted alongside `activated_count`. #[serde(default)] pub last_activation_tick: Option, } /// 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, // 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, // --- 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, /// Axis 2: Full secret (description + known_by list). /// Supersedes `secret_severity` for full reconstruction. #[serde(default)] pub secret: Option, /// Axis 5: Daily routine (phase → location schedule). #[serde(default)] pub routine: Option, /// Axis 6: Information inventory (facts this NPC carries). #[serde(default)] pub information_inventory: Option, /// Supporting axis 1: Personality traits (2–3 traits, no contradictory pairs). #[serde(default)] pub personality_traits: Option, /// Supporting axis 2: Tell system (behavioral tells tied to stress/personality). #[serde(default)] pub tell_system: Option, /// Supporting axis 3: Skill set (proficiency BTreeMap). #[serde(default)] pub skill_set: Option, /// Optional combat capability (only present for combat-trained NPCs). #[serde(default)] pub combat_capability: Option, /// 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, /// Job performance score — drifts over time, persist across tier transitions. #[serde(default)] pub job_performance: Option, /// 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, } impl SaveStateV1 { /// Serialize to MessagePack bytes. pub fn to_bytes(&self) -> Result, rmp_serde::encode::Error> { rmp_serde::to_vec_named(self) } /// Deserialize from MessagePack bytes. pub fn from_bytes(bytes: &[u8]) -> Result { 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::(entity) .copied() .unwrap_or_else(|| TilePosition::new(0, 0, 0)); let stable_id = world .get::(entity) .map(|s| s.0) .expect("NPC entity must have StableEntityId before serialization (#96)"); let secret = world.get::(entity).cloned(); let secret_severity = secret .as_ref() .map(|s| s.severity) .unwrap_or(SecretSeverity::Minor); let (current_stress, tolerance_threshold) = world .get::(entity) .map(|t| (t.current_stress, t.threshold)) .unwrap_or((0, 50)); NpcSaveState { stable_id, position, secret_severity, relationships: world.get::(entity).cloned(), current_stress, tolerance_threshold, contentment: world .get::(entity) .map(|c| c.level) .unwrap_or(0), knowledge_graph: world.get::(entity).cloned(), want: world.get::(entity).cloned(), secret, routine: world.get::(entity).cloned(), information_inventory: world.get::(entity).cloned(), personality_traits: world.get::(entity).cloned(), tell_system: world.get::(entity).cloned(), skill_set: world.get::(entity).cloned(), combat_capability: world.get::(entity).cloned(), mood_state: world.get::(entity).cloned(), job_performance: world.get::(entity).cloned(), template_ownership: world.get::(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(), EngagementRecord::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![], contamination_active: false, activated_count: 0, last_activation_tick: None, } } // ----------------------------------------------------------------------- // 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::(original).unwrap().0; let rest_stable = world.get::(restored).unwrap().0; assert_eq!(orig_stable, rest_stable, "StableId must match"); // Position let orig_pos = world.get::(original).copied().unwrap(); let rest_pos = world.get::(restored).copied().unwrap(); assert_eq!(orig_pos, rest_pos, "position must match"); // Tolerance let orig_tol = world.get::(original).cloned().unwrap(); let rest_tol = world.get::(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::(original).cloned().unwrap(); let rest_con = world.get::(restored).cloned().unwrap(); assert_eq!(orig_con.level, rest_con.level, "contentment must match"); // Want let orig_want = world.get::(original).cloned().unwrap(); let rest_want = world.get::(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::(original).cloned().unwrap(); let rest_secret = world.get::(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::(entity).is_some()); assert!(world.get::(entity).is_some()); assert!(world.get::(entity).is_some()); assert!(world.get::(entity).is_some()); assert!(world.get::(entity).is_some(), "Want defaults to Safety"); assert!(world.get::(entity).is_some(), "Secret built from secret_severity"); // Secret severity must be preserved from the legacy field let secret = world.get::(entity).unwrap(); assert_eq!(secret.severity, SecretSeverity::Moderate); // Optional axes absent in frozen state → not inserted or use defaults assert!(world.get::(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![], contamination_active: false, activated_count: 0, last_activation_tick: None, }; 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"); } }