//! 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, } impl ExamineResultBuffer { /// Drain and return the examine result, leaving the buffer empty. pub fn take(&mut self) -> Option { 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, registry: Res, mut kg_events: ResMut, mut player_query: Query< ( Entity, &TilePosition, &ExamineRequest, Option<&CharacterArchetype>, &mut ExamineResultBuffer, ), With, >, npc_query: Query< ( &TilePosition, Option<&MoodState>, Option<&ToleranceThreshold>, Option<&PersonalityTraits>, ), Without, >, 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::(); 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::(); 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::(); 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::(); return; } tracing::warn!( ?target, "process_examine_interaction: target has no position component" ); commands.entity(player_entity).remove::(); } // --------------------------------------------------------------------------- // 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 } }