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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user