feat(simulation): Sprint 18 — 9 server systems

Background tier state machines (#95), NPC vision (#115), player-
awareness behavior (#244), skill system & combat flag (#91), social
propagation (#249), examine mechanic (#242), character pressure
framework (#248), save state data model (#256), tell state wiring
(#337). Protocol version bumped 13→14 for examine_result and
character_pressure snapshot fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 09:50:37 +01:00
co-authored by Claude Opus 4.6
parent 2eca8e961b
commit 276538cde1
16 changed files with 4450 additions and 5 deletions
+318
View File
@@ -0,0 +1,318 @@
//! 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 only; ECS extraction and injection is #257.
//!
//! ## 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)
use serde::{Deserialize, Serialize};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::types::StableId;
use crate::npc::{SecretSeverity, Relationships};
use crate::npc::relationships::RelationshipGraph;
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>,
}
/// 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.
#[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 (description is regenerated from content on load)
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>,
}
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)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::graph::KnowledgeGraph;
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![],
}
}
// -----------------------------------------------------------------------
// 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,
},
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,
},
];
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),
}];
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);
}
}