feat(simulation): Sprint 19 — 7 server systems
Protocol handshake (#555): HandshakeMessage as first IPC frame, HandshakeState resource, forward-compatible input handling. State serialization (#96): serialize_npc_to_frozen/deserialize with full D-024 axis coverage (10 new optional fields on NpcSaveState). Scope tags (#98): ScopeTagKind enum, ScopePinned marker, automatic assignment from KnowledgeGraph and RelationshipGraph. Timestamp eviction (#97): LastInteractionTick, SimSpacePressure, BinaryHeap LRU eviction respecting ScopePinned entities. Save/load (#553): save_to_file/load_from_file via MessagePack, SaveGame/LoadGame IPC commands, SaveLoadResultWire on snapshot. Test infrastructure (#200): Layer 3 integration test entry point, three-layer architecture documented per D-030. Information boundary tests (#272): 4 negative tests proving no passive KG leakage, LOS fog holds, tier boundary holds, save isolation per NPC. 1063 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! `SaveStateV1` is the versioned serialization envelope for full game state.
|
||||
//! Shares architecture with #96 (state serialization system) — this module
|
||||
//! defines the data model only; ECS extraction and injection is #257.
|
||||
//! defines the data model AND the per-NPC serialization primitives for tier
|
||||
//! eviction freeze/thaw (#96).
|
||||
//!
|
||||
//! ## Write format: MessagePack
|
||||
//!
|
||||
@@ -34,12 +35,22 @@
|
||||
//! - `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::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::registry::StableEntityId;
|
||||
use crate::knowledge::types::StableId;
|
||||
use crate::npc::{SecretSeverity, Relationships};
|
||||
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;
|
||||
|
||||
@@ -76,9 +87,36 @@ pub struct SaveStateV1 {
|
||||
|
||||
/// Per-NPC state snapshot for `SaveStateV1`.
|
||||
///
|
||||
/// Captures the D-024 axis values and position. On load, the full NPC entity
|
||||
/// is reconstructed by injecting these values into the appropriate components.
|
||||
/// Field order matches the 10-axis model (D-024) for readability.
|
||||
/// 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).
|
||||
@@ -86,7 +124,8 @@ pub struct NpcSaveState {
|
||||
/// Last known tile position.
|
||||
pub position: TilePosition,
|
||||
|
||||
// Axis 2: Secret severity (description is regenerated from content on load)
|
||||
// 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>,
|
||||
@@ -99,6 +138,52 @@ pub struct NpcSaveState {
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl SaveStateV1 {
|
||||
@@ -113,6 +198,166 @@ impl SaveStateV1 {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
entity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -121,6 +366,7 @@ impl SaveStateV1 {
|
||||
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;
|
||||
@@ -193,6 +439,16 @@ mod tests {
|
||||
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,
|
||||
},
|
||||
NpcSaveState {
|
||||
stable_id: StableId(202),
|
||||
@@ -203,6 +459,16 @@ mod tests {
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -301,6 +567,16 @@ mod tests {
|
||||
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,
|
||||
}];
|
||||
|
||||
let bytes = state.to_bytes().expect("serialize");
|
||||
@@ -317,4 +593,202 @@ mod tests {
|
||||
// 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,
|
||||
};
|
||||
|
||||
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],
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user