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,374 @@
|
||||
//! Examine interaction system (#242).
|
||||
//!
|
||||
//! Handles the Examine verb: player examines an NPC or object at close range,
|
||||
//! generating character-filtered observation text and a DirectObservation
|
||||
//! KnowledgeGraph entry.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//! Interact { verb: "Examine NPC" | "ExamineNpc" | "ExamineObject" }
|
||||
//! → process_player_input inserts ExamineRequest on player
|
||||
//! → process_examine_interaction reads request, generates text, pushes KG event
|
||||
//! → ExamineResultBuffer consumed by compute_observer_snapshot
|
||||
//! → ObserverSnapshot.examine_result delivered to client
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bridge::types::CharacterArchetype;
|
||||
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold};
|
||||
use crate::simulation::interaction::CLOSE_RANGE;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker: player requested Examine interaction with a target entity this tick.
|
||||
///
|
||||
/// Inserted by process_player_input when verb == "Examine NPC", "ExamineNpc",
|
||||
/// "Examine Object", or "ExamineObject". Consumed and removed by
|
||||
/// process_examine_interaction each tick.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ExamineRequest {
|
||||
pub target: Entity,
|
||||
}
|
||||
|
||||
/// Character-filtered examination result for snapshot delivery.
|
||||
///
|
||||
/// Content differs per CharacterArchetype:
|
||||
/// Smuggler — physical threat read, cargo-handling posture, opportunity windows.
|
||||
/// Detective — procedural tells, behavioral inconsistencies, stress indicators.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExamineResultEvent {
|
||||
/// Character-filtered observation text for client display.
|
||||
pub text: String,
|
||||
/// Wire-format entity identifier of the examined entity.
|
||||
pub target_entity_id: u64,
|
||||
}
|
||||
|
||||
/// Buffer holding the examine result for snapshot inclusion.
|
||||
///
|
||||
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
|
||||
/// Attach to the player entity alongside other buffer components.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct ExamineResultBuffer {
|
||||
pub(crate) result: Option<ExamineResultEvent>,
|
||||
}
|
||||
|
||||
impl ExamineResultBuffer {
|
||||
/// Drain and return the examine result, leaving the buffer empty.
|
||||
pub fn take(&mut self) -> Option<ExamineResultEvent> {
|
||||
self.result.take()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text generation (deterministic, integer-only — D-010)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Stress ratio 0..=100 derived from ToleranceThreshold components.
|
||||
/// Uses integer multiplication to avoid division by zero.
|
||||
fn stress_ratio(threshold: &ToleranceThreshold) -> u8 {
|
||||
if threshold.threshold <= 0 {
|
||||
return 0;
|
||||
}
|
||||
((threshold.current_stress.max(0) as i32 * 100) / threshold.threshold as i32).clamp(0, 100)
|
||||
as u8
|
||||
}
|
||||
|
||||
/// Map NpcMood to a terse descriptor shared by both archetypes.
|
||||
fn mood_word(mood: NpcMood) -> &'static str {
|
||||
match mood {
|
||||
NpcMood::Neutral => "neutral",
|
||||
NpcMood::Anxious => "anxious",
|
||||
NpcMood::Frustrated => "frustrated",
|
||||
NpcMood::Content => "at ease",
|
||||
NpcMood::Suspicious => "watchful",
|
||||
NpcMood::Warm => "open",
|
||||
NpcMood::Hostile => "hostile",
|
||||
NpcMood::Focused => "focused",
|
||||
}
|
||||
}
|
||||
|
||||
fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> bool {
|
||||
traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Generate character-filtered examination text from NPC component state.
|
||||
/// All logic is pure, deterministic, and integer-based (D-010).
|
||||
pub fn generate_examine_text(
|
||||
mood: NpcMood,
|
||||
ratio: u8,
|
||||
archetype: CharacterArchetype,
|
||||
traits_opt: Option<&PersonalityTraits>,
|
||||
) -> String {
|
||||
let stress_label = match ratio {
|
||||
0..=30 => "relaxed",
|
||||
31..=60 => "tense",
|
||||
61..=85 => "stressed",
|
||||
_ => "near breaking point",
|
||||
};
|
||||
|
||||
let mood_label = mood_word(mood);
|
||||
|
||||
match archetype {
|
||||
CharacterArchetype::Smuggler => {
|
||||
// Physical threat read + cargo opportunity window
|
||||
let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) {
|
||||
"Threat posture. Don't push it."
|
||||
} else if has_trait(traits_opt, PersonalityTrait::Bold) {
|
||||
"Confident bearing. Will push back if cornered."
|
||||
} else if has_trait(traits_opt, PersonalityTrait::Cautious) {
|
||||
"Nervous type. Predictable under pressure."
|
||||
} else {
|
||||
"No obvious threat read."
|
||||
};
|
||||
|
||||
let window = if ratio > 60 {
|
||||
"Too distracted to track cargo movement."
|
||||
} else if matches!(mood, NpcMood::Focused) {
|
||||
"Paying close attention to this section."
|
||||
} else {
|
||||
"Standard patrol pattern. Window is there."
|
||||
};
|
||||
|
||||
format!("Appears {mood_label}, {stress_label}. {threat} {window}")
|
||||
}
|
||||
|
||||
CharacterArchetype::Detective => {
|
||||
// Procedural tells + behavioral read
|
||||
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
|
||||
"Controlled affect — practiced concealment."
|
||||
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
|
||||
"Involuntary stress markers present."
|
||||
} else if matches!(mood, NpcMood::Suspicious) {
|
||||
"Scanning. Aware of being observed."
|
||||
} else {
|
||||
"Baseline presentation."
|
||||
};
|
||||
|
||||
let read = if ratio > 60 {
|
||||
"Under pressure — potential liability or asset."
|
||||
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
|
||||
"Comfortable. Less guarded than usual."
|
||||
} else {
|
||||
"Routine behavior pattern."
|
||||
};
|
||||
|
||||
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process examine interaction: generate character-filtered observation text,
|
||||
/// push DirectObservation to KnowledgeGraph, write result to ExamineResultBuffer.
|
||||
///
|
||||
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn process_examine_interaction(
|
||||
mut commands: Commands,
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut kg_events: ResMut<KnowledgeEventQueue>,
|
||||
mut player_query: Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
&ExamineRequest,
|
||||
Option<&CharacterArchetype>,
|
||||
&mut ExamineResultBuffer,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
npc_query: Query<(
|
||||
&TilePosition,
|
||||
Option<&MoodState>,
|
||||
Option<&ToleranceThreshold>,
|
||||
Option<&PersonalityTraits>,
|
||||
)>,
|
||||
) {
|
||||
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
|
||||
player_query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target = examine_req.target;
|
||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
||||
|
||||
// Range check — examine requires close range (same as Talk/Confront)
|
||||
let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) else {
|
||||
tracing::warn!(?target, "process_examine_interaction: target not in query");
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
return;
|
||||
};
|
||||
|
||||
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
|
||||
if distance > CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
distance,
|
||||
"Examine: target out of range (max {})",
|
||||
CLOSE_RANGE
|
||||
);
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
return;
|
||||
}
|
||||
|
||||
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
|
||||
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
|
||||
|
||||
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
|
||||
|
||||
// Push DirectObservation to KnowledgeEventQueue
|
||||
kg_events.push(KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick: time.tick,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target,
|
||||
position: *target_pos,
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve target wire ID for snapshot
|
||||
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
|
||||
tracing::warn!(?target, "Examine: target not in EntityRegistry, using bits");
|
||||
target.to_bits()
|
||||
});
|
||||
|
||||
result_buffer.result = Some(ExamineResultEvent {
|
||||
text,
|
||||
target_entity_id,
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
target_entity_id,
|
||||
"Examine: DirectObservation pushed, result written to buffer"
|
||||
);
|
||||
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::PersonalityTrait;
|
||||
|
||||
fn traits(t: &[PersonalityTrait]) -> PersonalityTraits {
|
||||
PersonalityTraits { traits: t.to_vec() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_hostile_npc_gives_threat_read() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Hostile,
|
||||
20,
|
||||
CharacterArchetype::Smuggler,
|
||||
None,
|
||||
);
|
||||
assert!(text.contains("Threat posture"), "expected threat read, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_focused_npc_notes_attention() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Focused,
|
||||
30,
|
||||
CharacterArchetype::Smuggler,
|
||||
None,
|
||||
);
|
||||
assert!(text.contains("close attention"), "expected attention note, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_high_stress_identifies_distraction() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Anxious,
|
||||
80,
|
||||
CharacterArchetype::Smuggler,
|
||||
None,
|
||||
);
|
||||
assert!(text.contains("Too distracted"), "expected distraction read, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_deceptive_npc_notes_concealment() {
|
||||
let t = traits(&[PersonalityTrait::Deceptive]);
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Neutral,
|
||||
20,
|
||||
CharacterArchetype::Detective,
|
||||
Some(&t),
|
||||
);
|
||||
assert!(text.contains("Controlled affect"), "expected concealment note, got: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_anxious_npc_notes_stress_markers() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Anxious,
|
||||
50,
|
||||
CharacterArchetype::Detective,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
text.contains("stress markers"),
|
||||
"expected stress markers, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_content_npc_notes_low_guard() {
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Content,
|
||||
10,
|
||||
CharacterArchetype::Detective,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
text.contains("Less guarded"),
|
||||
"expected low guard note, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_ratio_zero_when_threshold_zero() {
|
||||
let t = ToleranceThreshold { current_stress: 50, threshold: 0 };
|
||||
assert_eq!(stress_ratio(&t), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_ratio_clamped_at_100() {
|
||||
let t = ToleranceThreshold { current_stress: 200, threshold: 100 };
|
||||
assert_eq!(stress_ratio(&t), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_ratio_negative_stress_is_zero() {
|
||||
let t = ToleranceThreshold { current_stress: -10, threshold: 70 };
|
||||
assert_eq!(stress_ratio(&t), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn examine_result_buffer_take_drains() {
|
||||
let mut buf = ExamineResultBuffer::default();
|
||||
assert!(buf.take().is_none());
|
||||
buf.result = Some(ExamineResultEvent {
|
||||
text: "Test".into(),
|
||||
target_entity_id: 42,
|
||||
});
|
||||
assert!(buf.take().is_some());
|
||||
assert!(buf.take().is_none()); // idempotent drain
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,16 @@ pub fn process_player_input(
|
||||
current_tick,
|
||||
);
|
||||
}
|
||||
Some("Examine NPC") | Some("ExamineNpc") | Some("Examine Object")
|
||||
| Some("ExamineObject") | Some("Observe") => {
|
||||
handle_examine(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
&all_positions,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Confront") => {
|
||||
handle_confront(
|
||||
&mut commands,
|
||||
@@ -471,6 +481,65 @@ fn handle_talk(
|
||||
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
|
||||
}
|
||||
|
||||
/// Handle Examine verb: insert ExamineRequest marker on the player entity (#242).
|
||||
/// Examine is available at close range (same as Talk). Range check here matches
|
||||
/// the server-side guard in process_examine_interaction.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn handle_examine(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&mut crate::simulation::stance::Stance>,
|
||||
Option<&mut crate::simulation::stance::PlayerMoveCooldown>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
all_positions: &Query<&TilePosition>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Examine verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Examine: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Server-side range check: reject Examine if target is beyond close range
|
||||
if let Ok(target_pos) = all_positions.get(target_entity) {
|
||||
let distance = player_pos
|
||||
.manhattan_distance(target_pos)
|
||||
.unwrap_or(u32::MAX);
|
||||
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
distance,
|
||||
"Examine: target out of range (max {})",
|
||||
crate::simulation::interaction::CLOSE_RANGE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(crate::simulation::examine::ExamineRequest {
|
||||
target: target_entity,
|
||||
});
|
||||
|
||||
tracing::debug!(target_id, "Examine: ExamineRequest marker set on player");
|
||||
}
|
||||
|
||||
/// Handle DialogueResponse action: set DialogueResponseRequest marker (#539).
|
||||
/// The follow-up dialogue pipeline runs in process_dialogue_response (dialogue.rs).
|
||||
///
|
||||
|
||||
@@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
pub mod contraband;
|
||||
pub mod conversation;
|
||||
pub mod dialogue;
|
||||
pub mod examine;
|
||||
pub mod follow;
|
||||
pub mod input;
|
||||
pub mod interaction;
|
||||
@@ -19,7 +20,9 @@ pub mod path_follow;
|
||||
pub mod pathfinding;
|
||||
pub mod poi;
|
||||
pub mod poi_discovery;
|
||||
pub mod pressure;
|
||||
pub mod rng;
|
||||
pub mod save_state;
|
||||
pub mod sound;
|
||||
pub mod spatial;
|
||||
pub mod stance;
|
||||
@@ -78,6 +81,13 @@ impl Plugin for SimulationPlugin {
|
||||
poi_discovery::discover_pois
|
||||
.after(crate::perception::observer::compute_visibility_geometry)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
examine::process_examine_interaction
|
||||
.after(input::process_player_input)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
// Character pressure (#248) — reads NPC awareness + relationship graph
|
||||
pressure::update_character_pressure
|
||||
.after(crate::npc::awareness::detect_player_awareness)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
//! Character goal/pressure framework (#248).
|
||||
//!
|
||||
//! Defines systemic pressures on the player character that modulate monologue
|
||||
//! salience and observation priority. Not scripted arcs — emergent from
|
||||
//! interaction of existing D-024 axes.
|
||||
//!
|
||||
//! ## Three pressure axes
|
||||
//!
|
||||
//! - **Exposure**: rises when NPCs notice the player (#244 PlayerAwareness)
|
||||
//! - **Relationship**: rises when NPCs distrust the player (negative trust in
|
||||
//! `RelationshipGraph`)
|
||||
//! - **Institutional**: detective-specific pressure (stub in v0.1)
|
||||
//!
|
||||
//! ## Update frequency
|
||||
//!
|
||||
//! Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute) to avoid
|
||||
//! per-tick overhead of relationship graph scans.
|
||||
//!
|
||||
//! ## Output surface
|
||||
//!
|
||||
//! - `CharacterPressureWire` in `ObserverSnapshot` for client HUD
|
||||
//! - `pressure_mood()` helper for monologue salience weighting — high pressure
|
||||
//! biases toward anxiety-tagged lines (D-035 mood tags)
|
||||
//!
|
||||
//! All arithmetic is integer-only (D-010 determinism).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::npc::awareness::PlayerAwareness;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ticks between pressure recalculations. 10 ticks = 1 game-minute (D-031).
|
||||
pub const PRESSURE_UPDATE_INTERVAL: u64 = 10;
|
||||
|
||||
/// Exposure pressure per suspicious NPC. Scaled so ~5 suspicious NPCs ≈ 50 pressure.
|
||||
pub const EXPOSURE_PER_SUSPICIOUS_NPC: i32 = 10;
|
||||
|
||||
/// Relationship pressure per hostile edge (trust ≤ -3). Scaled so ~4 hostile NPCs ≈ 60 pressure.
|
||||
pub const RELATIONSHIP_PER_HOSTILE_EDGE: i32 = 15;
|
||||
|
||||
/// Pressure threshold above which monologue mood shifts to "anxious".
|
||||
pub const MOOD_ANXIOUS_THRESHOLD: i32 = 50;
|
||||
|
||||
/// Pressure threshold above which monologue mood shifts to "frustrated".
|
||||
/// Below anxious threshold but above this → frustrated.
|
||||
pub const MOOD_FRUSTRATED_THRESHOLD: i32 = 30;
|
||||
|
||||
/// Trust value at or below which a relationship counts as "hostile" for
|
||||
/// relationship pressure.
|
||||
pub const HOSTILE_TRUST_THRESHOLD: i8 = -3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Systemic pressure on the player character (#248).
|
||||
///
|
||||
/// Attached to the player entity. Updated every game-minute by
|
||||
/// `update_character_pressure`. Feeds into monologue salience weighting
|
||||
/// and ObserverSnapshot HUD data.
|
||||
///
|
||||
/// All values are 0–100, integer for D-010 determinism.
|
||||
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CharacterPressure {
|
||||
/// Exposure pressure: rises when NPCs notice the player watching them.
|
||||
/// Derived from aggregate `PlayerAwareness.suspicion_level` across Active NPCs.
|
||||
pub exposure: i32,
|
||||
/// Institutional pressure: detective-specific systemic pressure.
|
||||
/// Stub in v0.1 — future sprints wire this to detective interaction patterns.
|
||||
pub institutional: i32,
|
||||
/// Relationship pressure: rises when NPCs distrust the player.
|
||||
/// Derived from negative trust edges in the `RelationshipGraph`.
|
||||
pub relationship: i32,
|
||||
}
|
||||
|
||||
impl CharacterPressure {
|
||||
/// Total pressure as a weighted average of all axes (0–100).
|
||||
pub fn total(&self) -> i32 {
|
||||
// Simple average, clamped. Integer arithmetic only (D-010).
|
||||
((self.exposure + self.institutional + self.relationship) / 3).clamp(0, 100)
|
||||
}
|
||||
|
||||
/// Dominant mood tag for monologue salience weighting.
|
||||
///
|
||||
/// Returns the D-035 mood tag that should be preferred when selecting
|
||||
/// monologue lines. `None` when pressure is low — baseline monologue
|
||||
/// selection applies.
|
||||
pub fn pressure_mood(&self) -> Option<&'static str> {
|
||||
let total = self.total();
|
||||
if total >= MOOD_ANXIOUS_THRESHOLD {
|
||||
Some("anxious")
|
||||
} else if total >= MOOD_FRUSTRATED_THRESHOLD {
|
||||
Some("frustrated")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire type for ObserverSnapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Character pressure data for client HUD display (#248).
|
||||
///
|
||||
/// Included in `ObserverSnapshot` when pressure is non-zero.
|
||||
/// Client renders as a tension/pressure indicator widget.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CharacterPressureWire {
|
||||
/// Exposure pressure (0–100).
|
||||
pub exposure: i32,
|
||||
/// Institutional pressure (0–100).
|
||||
pub institutional: i32,
|
||||
/// Relationship pressure (0–100).
|
||||
pub relationship: i32,
|
||||
/// Total pressure (0–100).
|
||||
pub total: i32,
|
||||
/// Dominant mood tag, if any.
|
||||
pub mood: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&CharacterPressure> for CharacterPressureWire {
|
||||
fn from(p: &CharacterPressure) -> Self {
|
||||
Self {
|
||||
exposure: p.exposure,
|
||||
institutional: p.institutional,
|
||||
relationship: p.relationship,
|
||||
total: p.total(),
|
||||
mood: p.pressure_mood().map(String::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Update character pressure from NPC awareness and relationship state.
|
||||
///
|
||||
/// Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute).
|
||||
///
|
||||
/// - **Exposure**: count Active NPCs with `suspicion_level > 0`, scale by
|
||||
/// `EXPOSURE_PER_SUSPICIOUS_NPC`, cap at 100
|
||||
/// - **Relationship**: count hostile trust edges (trust ≤ -3) toward the
|
||||
/// player in `RelationshipGraph`, scale by `RELATIONSHIP_PER_HOSTILE_EDGE`
|
||||
/// - **Institutional**: stub (0) in v0.1
|
||||
pub fn update_character_pressure(
|
||||
time: Res<SimulationTime>,
|
||||
awareness_query: Query<&PlayerAwareness, (With<Npc>, With<ActiveSim>)>,
|
||||
relationship_graph: Res<crate::npc::relationships::RelationshipGraph>,
|
||||
registry: Res<crate::knowledge::EntityRegistry>,
|
||||
mut player_query: Query<(Entity, &mut CharacterPressure), With<PlayerCharacter>>,
|
||||
) {
|
||||
// Only run on interval ticks
|
||||
if time.tick % PRESSURE_UPDATE_INTERVAL != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok((player_entity, mut pressure)) = player_query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// --- Exposure pressure ---
|
||||
let suspicious_count = awareness_query
|
||||
.iter()
|
||||
.filter(|a| a.suspicion_level > 0)
|
||||
.count() as i32;
|
||||
pressure.exposure = (suspicious_count * EXPOSURE_PER_SUSPICIOUS_NPC).min(100);
|
||||
|
||||
// --- Relationship pressure ---
|
||||
let player_stable = registry.to_stable(player_entity);
|
||||
if let Some(player_sid) = player_stable {
|
||||
let hostile_edges = relationship_graph
|
||||
.who_knows_full_scan(&player_sid)
|
||||
.iter()
|
||||
.filter(|(_, edge)| edge.trust <= HOSTILE_TRUST_THRESHOLD)
|
||||
.count() as i32;
|
||||
pressure.relationship = (hostile_edges * RELATIONSHIP_PER_HOSTILE_EDGE).min(100);
|
||||
}
|
||||
|
||||
// --- Institutional pressure ---
|
||||
// Stub: v0.1 has no detective-specific interaction patterns yet.
|
||||
// Future sprints wire this to game-time progression, investigation progress,
|
||||
// and institutional NPC interactions.
|
||||
// pressure.institutional stays at whatever it was (default 0).
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::awareness::PlayerAwareness;
|
||||
use crate::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use crate::npc::{Npc, RelationshipKind};
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.init_resource::<RelationshipGraph>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
fn run_system(world: &mut World) {
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_character_pressure);
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Update interval gating
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn skips_non_interval_ticks() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 13; // not on interval
|
||||
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
PlayerAwareness {
|
||||
suspicion_level: 50,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(pressure.exposure, 0, "should not update on non-interval tick");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_on_interval_tick() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10; // on interval
|
||||
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
PlayerAwareness {
|
||||
suspicion_level: 50,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
pressure.exposure, EXPOSURE_PER_SUSPICIOUS_NPC,
|
||||
"should update on interval tick"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Exposure pressure
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn exposure_from_suspicious_npcs() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
// 3 suspicious NPCs
|
||||
for _ in 0..3 {
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
PlayerAwareness {
|
||||
suspicion_level: 10,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
// 2 non-suspicious NPCs
|
||||
for _ in 0..2 {
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
PlayerAwareness::default(),
|
||||
));
|
||||
}
|
||||
|
||||
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
pressure.exposure,
|
||||
3 * EXPOSURE_PER_SUSPICIOUS_NPC,
|
||||
"3 suspicious NPCs × {} per NPC",
|
||||
EXPOSURE_PER_SUSPICIOUS_NPC
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposure_caps_at_100() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
// 20 suspicious NPCs — would be 200, should cap at 100
|
||||
for _ in 0..20 {
|
||||
world.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
PlayerAwareness {
|
||||
suspicion_level: 50,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(pressure.exposure, 100, "exposure should cap at 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_suspicious_npcs_zero_exposure() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
world.spawn((Npc, ActiveSim, PlayerAwareness::default()));
|
||||
world.spawn((PlayerCharacter, CharacterPressure::default()));
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(pressure.exposure, 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Relationship pressure
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn relationship_pressure_from_hostile_edges() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, CharacterPressure::default()))
|
||||
.id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
// Two NPCs with hostile trust toward the player
|
||||
let npc1 = world.spawn(Npc).id();
|
||||
let npc1_sid = registry.register(npc1);
|
||||
let npc2 = world.spawn(Npc).id();
|
||||
let npc2_sid = registry.register(npc2);
|
||||
|
||||
let mut graph = RelationshipGraph::new();
|
||||
graph.set_relationship(
|
||||
npc1_sid,
|
||||
player_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: -5, // hostile
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
graph.set_relationship(
|
||||
npc2_sid,
|
||||
player_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: -4, // hostile
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(graph);
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
pressure.relationship,
|
||||
2 * RELATIONSHIP_PER_HOSTILE_EDGE,
|
||||
"2 hostile edges × {} per edge",
|
||||
RELATIONSHIP_PER_HOSTILE_EDGE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_trust_no_relationship_pressure() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, CharacterPressure::default()))
|
||||
.id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
let npc = world.spawn(Npc).id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut graph = RelationshipGraph::new();
|
||||
graph.set_relationship(
|
||||
npc_sid,
|
||||
player_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 0, // neutral
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(graph);
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(pressure.relationship, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_at_boundary_not_hostile() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, CharacterPressure::default()))
|
||||
.id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
let npc = world.spawn(Npc).id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut graph = RelationshipGraph::new();
|
||||
graph.set_relationship(
|
||||
npc_sid,
|
||||
player_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: -2, // above hostile threshold (-3)
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource(graph);
|
||||
run_system(&mut world);
|
||||
|
||||
let mut q = world.query::<&CharacterPressure>();
|
||||
let pressure = q.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
pressure.relationship, 0,
|
||||
"trust -2 should not count as hostile (threshold is -3)"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Total + mood
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn total_is_average_clamped() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 60,
|
||||
institutional: 30,
|
||||
relationship: 45,
|
||||
};
|
||||
// (60 + 30 + 45) / 3 = 45
|
||||
assert_eq!(p.total(), 45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_does_not_go_below_zero() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 0,
|
||||
institutional: 0,
|
||||
relationship: 0,
|
||||
};
|
||||
assert_eq!(p.total(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_mood_anxious() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 100,
|
||||
institutional: 100,
|
||||
relationship: 100,
|
||||
};
|
||||
// total = 100, >= 50 → anxious
|
||||
assert_eq!(p.pressure_mood(), Some("anxious"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_mood_frustrated() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 50,
|
||||
institutional: 50,
|
||||
relationship: 20,
|
||||
};
|
||||
// total = (50+50+20)/3 = 40, >= 30 but < 50 → frustrated
|
||||
assert_eq!(p.pressure_mood(), Some("frustrated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_mood_none_when_low() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 10,
|
||||
institutional: 0,
|
||||
relationship: 10,
|
||||
};
|
||||
// total = (10+0+10)/3 = 6, < 30 → None
|
||||
assert_eq!(p.pressure_mood(), None);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Wire roundtrip
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn wire_roundtrip() {
|
||||
let p = CharacterPressure {
|
||||
exposure: 30,
|
||||
institutional: 10,
|
||||
relationship: 50,
|
||||
};
|
||||
let wire = CharacterPressureWire::from(&p);
|
||||
let json = serde_json::to_string(&wire).expect("should serialize");
|
||||
let decoded: CharacterPressureWire =
|
||||
serde_json::from_str(&json).expect("should deserialize");
|
||||
assert_eq!(decoded.exposure, 30);
|
||||
assert_eq!(decoded.institutional, 10);
|
||||
assert_eq!(decoded.relationship, 50);
|
||||
assert_eq!(decoded.total, p.total());
|
||||
assert_eq!(decoded.mood, p.pressure_mood().map(String::from));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// No player entity — no panic
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn no_player_no_panic() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
run_system(&mut world); // should not panic
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constant value assertions (#248 spec compliance)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pressure_constants_have_expected_values() {
|
||||
assert_eq!(
|
||||
PRESSURE_UPDATE_INTERVAL, 10,
|
||||
"#248: pressure updates every 10 ticks (1 game-minute, D-031)"
|
||||
);
|
||||
assert_eq!(EXPOSURE_PER_SUSPICIOUS_NPC, 10);
|
||||
assert_eq!(RELATIONSHIP_PER_HOSTILE_EDGE, 15);
|
||||
assert_eq!(MOOD_ANXIOUS_THRESHOLD, 50);
|
||||
assert_eq!(MOOD_FRUSTRATED_THRESHOLD, 30);
|
||||
assert_eq!(HOSTILE_TRUST_THRESHOLD, -3);
|
||||
}
|
||||
}
|
||||
@@ -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