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>
896 lines
36 KiB
Rust
896 lines
36 KiB
Rust
//! 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 (2–3 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");
|
||
}
|
||
}
|