//! Tell state derivation system (#90, D-024 tell system). //! //! Derives the current observable tell category from NPC axis values each tick. //! Tell state is NOT authored per NPC — it flows from simulation state. //! //! ## 5 tell categories (D-024) //! - `Nervous`: Major secret + stress > half the threshold //! - `Angry`: Contentment < −20 AND Hostile mood //! - `Friendly`: Contentment > +20 AND at least one relationship with trust > 3 //! - `Guarded`: Major secret (any stress level) //! - `RoutineDeviation`: NPC has a `RoutineDeviation` component this tick //! //! ## Priority order (highest wins) //! RoutineDeviation > Nervous > Angry > Guarded > Friendly > None //! //! ## v0.1 output //! `DerivedTellState` is read by the observer snapshot system and emitted into //! `ObserverSnapshot.entities[].tell_state`. Client renders as monologue text; //! visual animation is deferred beyond v0.1. use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::types::RelationshipState; use crate::npc::mood::{MoodState, NpcMood}; use crate::npc::{ Contentment, Npc, Relationships, RoutineDeviation, Secret, SecretSeverity, ToleranceThreshold, }; use crate::simulation::tier::ActiveSim; // --------------------------------------------------------------------------- // TellCategory enum // --------------------------------------------------------------------------- /// Observable tell category emitted into the observer snapshot (#90, D-024). /// /// Derived each tick from NPC simulation state — not authored per NPC. /// Five categories correspond to the D-024 tell taxonomy. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum TellCategory { /// NPC exhibits nervous behaviour: Major secret + stress exceeds half of threshold. Nervous, /// NPC exhibits angry behaviour: low contentment and Hostile mood. Angry, /// NPC appears warm and open: high contentment with a positively-trusted relationship. Friendly, /// NPC appears guarded or evasive: Major secret (any stress). Guarded, /// NPC has deviated from their expected routine — primary detective mechanic (D-027). RoutineDeviation, } // --------------------------------------------------------------------------- // DerivedTellState component // --------------------------------------------------------------------------- /// Per-NPC component: the current observable tell category this tick. /// /// Updated each tick by [`derive_tell_state`] for Active-tier NPCs. /// `None` means no notable tell is observable (normal / neutral state). /// /// Read by the observer snapshot system to populate /// `ObserverSnapshot.entities[].tell_state`. #[derive(Component, Debug, Clone, Default)] pub struct DerivedTellState { /// Current tell category, or `None` if no tell is active. pub category: Option, } // --------------------------------------------------------------------------- // Derivation helpers // --------------------------------------------------------------------------- fn derive_category( secret: &Secret, tolerance: &ToleranceThreshold, contentment: &Contentment, mood_state: &MoodState, relationships_opt: Option<&Relationships>, deviation_opt: Option<&RoutineDeviation>, kg_opt: Option<&KnowledgeGraph>, ) -> Option { // Priority 1: RoutineDeviation (primary detective mechanic, D-027 criterion 4) if deviation_opt.is_some() { return Some(TellCategory::RoutineDeviation); } // Priority 2: Nervous — Major secret with stress past the midpoint if secret.severity == SecretSeverity::Major && tolerance.threshold > 0 && tolerance.current_stress * 2 > tolerance.threshold { return Some(TellCategory::Nervous); } // Priority 3: Angry — low contentment combined with Hostile mood if contentment.level < -20 && mood_state.mood == NpcMood::Hostile { return Some(TellCategory::Angry); } // Priority 4: Guarded — Major secret at any stress level if secret.severity == SecretSeverity::Major { return Some(TellCategory::Guarded); } // Priority 5: Friendly — high contentment with at least one trusted relationship // D-082: prefer KG relationship state over ground-truth axis data. // Self-axis components (secret, stress, contentment, mood) remain ground-truth; // OTHER-entity relationship assessment uses the knowledge graph. if contentment.level > 20 { let has_positive_relationship = if let Some(kg) = kg_opt { // D-082: use knowledge graph for other-entity relationship assessment kg.known_entities_iter() .any(|(_, ek)| ek.relationship == RelationshipState::Friendly) } else { // No KG: fall through to ground-truth axis data relationships_opt .map(|rels| rels.entries.iter().any(|r| r.trust_level > 3)) .unwrap_or(false) }; if has_positive_relationship { return Some(TellCategory::Friendly); } } None } // --------------------------------------------------------------------------- // Derivation system // --------------------------------------------------------------------------- /// System: derive tell state from NPC axis values for all Active-tier NPCs. /// /// Runs after `update_mood` — requires a fresh `MoodState`. /// Writes the result into `DerivedTellState`, which the observer snapshot /// system reads to populate `VisibleEntity.tell_state`. /// /// Scoped to `ActiveSim`: Background-tier NPCs retain their last-known tell /// state, consistent with D-026 tier policy. pub fn derive_tell_state( mut npcs: Query< ( &Secret, &ToleranceThreshold, &Contentment, &MoodState, Option<&Relationships>, Option<&RoutineDeviation>, Option<&KnowledgeGraph>, &mut DerivedTellState, ), (With, With), >, ) { for (secret, tolerance, contentment, mood_state, relationships_opt, deviation_opt, kg_opt, mut tell) in npcs.iter_mut() { tell.category = derive_category( secret, tolerance, contentment, mood_state, relationships_opt, deviation_opt, kg_opt, ); } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::knowledge::types::StableId; use crate::npc::{Relationship, RelationshipKind, RoutineDeviation as NpcRoutineDeviation}; use crate::npc::{SecretSeverity, ToleranceThreshold}; use crate::npc::mood::NpcMood; use crate::npc::DeviationTrigger; fn neutral_secret() -> Secret { Secret { description: "minor embarrassment".into(), severity: SecretSeverity::Minor, known_by: vec![], } } fn major_secret() -> Secret { Secret { description: "criminal record".into(), severity: SecretSeverity::Major, known_by: vec![], } } fn moderate_secret() -> Secret { Secret { description: "moderate secret".into(), severity: SecretSeverity::Moderate, known_by: vec![], } } fn tolerance(stress: i16, threshold: i16) -> ToleranceThreshold { ToleranceThreshold { current_stress: stress, threshold, } } fn contentment(level: i16) -> Contentment { Contentment { level } } fn mood(m: NpcMood) -> MoodState { MoodState { mood: m, changed_tick: 0 } } fn positive_relationships() -> Relationships { Relationships { entries: vec![Relationship { target_id: StableId(1), kind: RelationshipKind::Friend, trust_level: 5, history: vec![], }], } } fn neutral_relationships() -> Relationships { Relationships { entries: vec![Relationship { target_id: StableId(1), kind: RelationshipKind::Colleague, trust_level: 0, history: vec![], }], } } fn deviation() -> NpcRoutineDeviation { NpcRoutineDeviation { trigger: DeviationTrigger::WalkAway, tick: 100, expires_at_tick: 500, } } // ----------------------------------------------------------------------- // Priority 1: RoutineDeviation beats everything // ----------------------------------------------------------------------- #[test] fn routine_deviation_beats_nervous() { let result = derive_category( &major_secret(), &tolerance(90, 100), // Stress past midpoint — would be Nervous &contentment(0), &mood(NpcMood::Neutral), None, Some(&deviation()), None, ); assert_eq!(result, Some(TellCategory::RoutineDeviation)); } #[test] fn routine_deviation_beats_angry() { let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(-50), &mood(NpcMood::Hostile), None, Some(&deviation()), None, ); assert_eq!(result, Some(TellCategory::RoutineDeviation)); } #[test] fn no_deviation_component_skips_deviation_category() { let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(0), &mood(NpcMood::Neutral), None, None, // No deviation None, ); assert_ne!(result, Some(TellCategory::RoutineDeviation)); } // ----------------------------------------------------------------------- // Priority 2: Nervous // ----------------------------------------------------------------------- #[test] fn major_secret_stress_past_midpoint_is_nervous() { // stress=60, threshold=100 → stress*2=120 > 100 → nervous let result = derive_category( &major_secret(), &tolerance(60, 100), &contentment(0), &mood(NpcMood::Neutral), None, None, None, ); assert_eq!(result, Some(TellCategory::Nervous)); } #[test] fn major_secret_stress_at_midpoint_is_guarded_not_nervous() { // stress=50, threshold=100 → stress*2=100 is NOT > 100 → Guarded fallthrough let result = derive_category( &major_secret(), &tolerance(50, 100), &contentment(0), &mood(NpcMood::Neutral), None, None, None, ); assert_eq!(result, Some(TellCategory::Guarded)); } #[test] fn minor_secret_high_stress_not_nervous() { let result = derive_category( &neutral_secret(), // Minor — not Major &tolerance(90, 100), &contentment(0), &mood(NpcMood::Neutral), None, None, None, ); assert_ne!(result, Some(TellCategory::Nervous)); } #[test] fn nervous_requires_nonzero_threshold() { // threshold=0: stress*2=0 NOT > 0 → skip nervous let result = derive_category( &major_secret(), &tolerance(0, 0), &contentment(0), &mood(NpcMood::Neutral), None, None, None, ); // Still Guarded (Major secret, priority 4) assert_eq!(result, Some(TellCategory::Guarded)); } // ----------------------------------------------------------------------- // Priority 3: Angry // ----------------------------------------------------------------------- #[test] fn low_contentment_hostile_mood_is_angry() { let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(-30), // Below -20 &mood(NpcMood::Hostile), None, None, None, ); assert_eq!(result, Some(TellCategory::Angry)); } #[test] fn hostile_mood_without_low_contentment_not_angry() { let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(0), // Not low enough &mood(NpcMood::Hostile), None, None, None, ); assert_ne!(result, Some(TellCategory::Angry)); } #[test] fn low_contentment_without_hostile_mood_not_angry() { let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(-30), &mood(NpcMood::Anxious), // Not Hostile None, None, None, ); assert_ne!(result, Some(TellCategory::Angry)); } #[test] fn contentment_at_boundary_minus_20_not_angry() { // -20 is NOT < -20 let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(-20), &mood(NpcMood::Hostile), None, None, None, ); assert_ne!(result, Some(TellCategory::Angry)); } // ----------------------------------------------------------------------- // Priority 4: Guarded // ----------------------------------------------------------------------- #[test] fn major_secret_low_stress_is_guarded() { let result = derive_category( &major_secret(), &tolerance(5, 100), // Low stress, not nervous &contentment(0), &mood(NpcMood::Neutral), None, None, None, ); assert_eq!(result, Some(TellCategory::Guarded)); } #[test] fn moderate_secret_not_guarded() { let result = derive_category( &moderate_secret(), &tolerance(0, 50), &contentment(0), &mood(NpcMood::Neutral), None, None, None, ); // Moderate secret doesn't trigger Guarded assert_ne!(result, Some(TellCategory::Guarded)); } // ----------------------------------------------------------------------- // Priority 5: Friendly // ----------------------------------------------------------------------- #[test] fn high_contentment_positive_relationship_is_friendly() { let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(30), // Above +20 &mood(NpcMood::Neutral), Some(&positive_relationships()), // Trust > 3 None, None, ); assert_eq!(result, Some(TellCategory::Friendly)); } #[test] fn high_contentment_no_positive_relationship_not_friendly() { let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(30), &mood(NpcMood::Neutral), Some(&neutral_relationships()), // Trust = 0 None, None, ); assert_ne!(result, Some(TellCategory::Friendly)); } #[test] fn high_contentment_no_relationships_not_friendly() { let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(30), &mood(NpcMood::Neutral), None, // No relationships at all None, None, ); assert_ne!(result, Some(TellCategory::Friendly)); } #[test] fn contentment_at_boundary_plus_20_not_friendly() { // +20 is NOT > 20 let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(20), &mood(NpcMood::Neutral), Some(&positive_relationships()), None, None, ); assert_ne!(result, Some(TellCategory::Friendly)); } // ----------------------------------------------------------------------- // None result // ----------------------------------------------------------------------- #[test] fn neutral_npc_returns_none() { let result = derive_category( &neutral_secret(), &tolerance(10, 50), &contentment(0), &mood(NpcMood::Neutral), None, None, None, ); assert_eq!(result, None); } #[test] fn no_tell_for_minor_secret_low_stress_neutral_mood() { let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(-5), // Not low enough for angry &mood(NpcMood::Anxious), // Not Hostile None, None, None, ); assert_eq!(result, None); } // ----------------------------------------------------------------------- // Bevy ECS integration: system updates DerivedTellState // ----------------------------------------------------------------------- #[test] fn system_updates_derived_tell_state() { use bevy_ecs::world::World; use crate::npc::Npc; let mut world = World::new(); // Spawn an NPC that should have RoutineDeviation tell let entity = world .spawn(( Npc, ActiveSim, major_secret(), tolerance(60, 100), contentment(0), mood(NpcMood::Neutral), DerivedTellState::default(), deviation(), // RoutineDeviation present )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(derive_tell_state); schedule.run(&mut world); let state = world.get::(entity).unwrap(); assert_eq!(state.category, Some(TellCategory::RoutineDeviation)); } #[test] fn system_sets_none_for_neutral_npc() { use bevy_ecs::world::World; use crate::npc::Npc; let mut world = World::new(); let entity = world .spawn(( Npc, ActiveSim, neutral_secret(), tolerance(10, 80), contentment(5), mood(NpcMood::Neutral), DerivedTellState::default(), )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(derive_tell_state); schedule.run(&mut world); let state = world.get::(entity).unwrap(); assert_eq!(state.category, None); } // ----------------------------------------------------------------------- // D-082: KG-aware Friendly tell // ----------------------------------------------------------------------- fn kg_with_friendly_entity() -> KnowledgeGraph { use crate::simulation::movement::TilePosition; let mut kg = KnowledgeGraph::new(); kg.observe_entity(StableId(1), TilePosition::new(5, 5, 0), 10); kg.set_relationship(&StableId(1), RelationshipState::Friendly); kg } fn kg_with_known_entity() -> KnowledgeGraph { use crate::simulation::movement::TilePosition; let mut kg = KnowledgeGraph::new(); kg.observe_entity(StableId(1), TilePosition::new(5, 5, 0), 10); kg.set_relationship(&StableId(1), RelationshipState::Known); kg } #[test] fn kg_friendly_relationship_triggers_friendly_tell() { let kg = kg_with_friendly_entity(); let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(30), &mood(NpcMood::Neutral), None, // Relationships component doesn't matter when KG exists None, Some(&kg), ); assert_eq!(result, Some(TellCategory::Friendly)); } #[test] fn kg_known_relationship_does_not_trigger_friendly_tell() { // Known != Friendly — only Friendly relationship triggers the tell let kg = kg_with_known_entity(); let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(30), &mood(NpcMood::Neutral), None, None, Some(&kg), ); assert_ne!(result, Some(TellCategory::Friendly)); } #[test] fn kg_empty_does_not_trigger_friendly_tell() { let kg = KnowledgeGraph::new(); let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(30), &mood(NpcMood::Neutral), None, None, Some(&kg), ); assert_ne!(result, Some(TellCategory::Friendly)); } #[test] fn no_kg_falls_through_to_relationships_component() { // Without KG, the old behavior (Relationships component) should work let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(30), &mood(NpcMood::Neutral), Some(&positive_relationships()), None, None, // No KG ); assert_eq!(result, Some(TellCategory::Friendly)); } #[test] fn kg_overrides_relationships_component() { // KG says no Friendly relationships, even though Relationships // component has trust > 3 — KG wins (D-082). let kg = kg_with_known_entity(); // Known, not Friendly let result = derive_category( &neutral_secret(), &tolerance(0, 50), &contentment(30), &mood(NpcMood::Neutral), Some(&positive_relationships()), // ground-truth says Friendly None, Some(&kg), // KG says Known (not Friendly) ); assert_ne!( result, Some(TellCategory::Friendly), "KG should override Relationships component for Friendly tell" ); } // ----------------------------------------------------------------------- // Priority chain: verify ordering at boundaries (QA gap closure) // ----------------------------------------------------------------------- #[test] fn nervous_beats_angry_when_both_conditions_met() { // NPC has: Major secret, stress past midpoint (Nervous), AND // low contentment + Hostile mood (Angry). // Priority 2 (Nervous) must win over Priority 3 (Angry). let result = derive_category( &major_secret(), &tolerance(80, 100), // stress*2=160 > 100 → Nervous &contentment(-50), // < -20 → Angry condition met &mood(NpcMood::Hostile), // Angry condition met None, None, None, ); assert_eq!( result, Some(TellCategory::Nervous), "Nervous (priority 2) must beat Angry (priority 3)" ); } #[test] fn angry_beats_guarded_when_both_conditions_met() { // NPC has: Major secret (Guarded), AND low contentment + Hostile (Angry). // Priority 3 (Angry) must win over Priority 4 (Guarded). // Note: stress is LOW so Nervous does not trigger. let result = derive_category( &major_secret(), &tolerance(10, 100), // Low stress — not Nervous &contentment(-30), // < -20 → Angry &mood(NpcMood::Hostile), None, None, None, ); assert_eq!( result, Some(TellCategory::Angry), "Angry (priority 3) must beat Guarded (priority 4)" ); } #[test] fn guarded_beats_friendly_when_both_conditions_met() { // NPC has: Major secret (Guarded), AND high contentment with positive // relationship (Friendly). Priority 4 (Guarded) must win over Priority 5. let result = derive_category( &major_secret(), &tolerance(0, 100), // Low stress — not Nervous &contentment(50), // > +20 → Friendly condition met &mood(NpcMood::Neutral), Some(&positive_relationships()), // Friendly condition met None, None, ); assert_eq!( result, Some(TellCategory::Guarded), "Guarded (priority 4) must beat Friendly (priority 5)" ); } }