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
+374
View File
@@ -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
}
}
+69
View File
@@ -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,
&registry,
&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).
///
+10
View File
@@ -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),
),
);
+595
View File
@@ -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);
}
}
+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);
}
}