Files
settled-reach/server/src/simulation/examine.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

424 lines
14 KiB
Rust

//! 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::{ObjectType, 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,
}
/// Authored examine text for a non-NPC entity (#246).
///
/// Attach to any examinable object (Readable, Terminal, etc.) to provide
/// a fixed description returned when the player examines it.
/// If absent, examining a non-NPC entity returns a generic fallback.
#[derive(Component, Debug, Clone)]
pub struct ExamineText(pub String);
/// 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.
///
/// Handles two target types:
/// - NPC entities: generate character-filtered text from NPC component state.
/// - Non-NPC entities with `ExamineText`: use the authored text directly.
/// - Non-NPC entities without `ExamineText`: generic fallback text.
///
/// 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>,
),
Without<ObjectType>,
>,
examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>,
) {
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();
// Try NPC examine path first
if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) {
let distance = player_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
if distance > CLOSE_RANGE {
tracing::info!(
distance,
"Examine: NPC 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);
kg_events.push(KnowledgeEvent {
observer: player_entity,
tick: time.tick,
event_type: KnowledgeEventType::DirectObservation {
target,
position: *target_pos,
},
});
let target_entity_id = registry
.to_stable(target)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::warn!(?target, "Examine: NPC not in EntityRegistry, using bits");
target.to_bits()
});
result_buffer.result = Some(ExamineResultEvent {
text,
target_entity_id,
});
tracing::debug!(target_entity_id, "Examine: NPC result written to buffer");
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
// Object examine path: entity has a TilePosition but no NPC mood components.
if let Ok((target_pos, examine_text_opt)) = examine_text_query.get(target) {
let distance = player_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
if distance > CLOSE_RANGE {
tracing::info!(
distance,
"Examine: object target out of range (max {})",
CLOSE_RANGE
);
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
let text = examine_text_opt
.map(|et| et.0.clone())
.unwrap_or_else(|| "No further details are apparent.".to_string());
let target_entity_id = registry
.to_stable(target)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::warn!(?target, "Examine: object not in EntityRegistry, using bits");
target.to_bits()
});
result_buffer.result = Some(ExamineResultEvent {
text,
target_entity_id,
});
tracing::debug!(target_entity_id, "Examine: object result written to buffer");
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
tracing::warn!(
?target,
"process_examine_interaction: target has no position component"
);
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
}
}