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