// NPC module - NPC entity definitions and AI systems // Implements D-024: 10-axis NPC model + CombatCapability component // Background tier state machines for schedule, mood, relationships, job pub mod awareness; pub mod background; pub mod blueprint; pub mod disclosure; pub mod generate; pub mod interaction; pub mod mood; pub mod relationships; pub mod routine; pub mod tell_state; pub mod tolerance; pub mod trait_modifiers; pub mod vision; use bevy_app::prelude::*; use bevy_ecs::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId}; use crate::simulation::movement::TilePosition; use crate::simulation::time::DayPhase; /// NPC plugin: initializes NPC-related resources and systems. pub struct NpcPlugin; impl Plugin for NpcPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_systems( Update, ( routine::check_phase_transition .before(crate::simulation::pathfinding::compute_paths), mood::update_mood .after(routine::check_phase_transition) .before(crate::simulation::dialogue::process_talk_interaction), relationships::update_trust .after(crate::simulation::dialogue::process_talk_interaction) .after(crate::simulation::dialogue::process_walk_away) .after(crate::simulation::dialogue::process_confrontation_response) .after(crate::simulation::dialogue::process_dialogue_response) .before(crate::simulation::time::advance_tick), relationships::propagate_social_actions .after(relationships::update_trust) .before(crate::simulation::time::advance_tick), relationships::update_relationship_dynamics .after(relationships::update_trust) .before(crate::simulation::time::advance_tick), routine::enter_activity .after(crate::simulation::movement::validate_movement) .before(crate::perception::observer::compute_observer_snapshot), tolerance::check_tolerance_threshold .after(mood::update_mood) .before(crate::simulation::time::advance_tick), routine::detect_routine_deviation .after(routine::enter_activity) .before(crate::perception::observer::compute_observer_snapshot), tell_state::derive_tell_state .after(mood::update_mood) .after(routine::detect_routine_deviation) .before(crate::perception::observer::compute_observer_snapshot), background::background_tick .after(crate::simulation::movement::validate_movement) .before(crate::simulation::time::advance_tick), disclosure::derive_disclosure_candidates .before(crate::perception::observer::compute_observer_snapshot), disclosure::process_unprompted_disclosure .after(disclosure::derive_disclosure_candidates) .before(crate::perception::observer::compute_observer_snapshot), // NPC player-awareness (#244) awareness::detect_player_awareness .after(vision::compute_npc_vision) .before(tolerance::check_tolerance_threshold), // NPC vision system (#115, D-011) vision::compute_npc_vision .after(crate::simulation::movement::validate_movement) .after(crate::simulation::tier::update_tier_markers) .before(crate::perception::observer::compute_observer_snapshot), vision::emit_npc_vision_events .after(vision::compute_npc_vision) .before(crate::knowledge::events::process_knowledge_events), vision::degrade_npc_inferences .after(vision::emit_npc_vision_events) .before(crate::simulation::time::advance_tick), crate::simulation::dialogue::process_talk_interaction .after(crate::simulation::input::process_player_input), crate::simulation::dialogue::process_walk_away .after(crate::simulation::input::process_player_input) .after(crate::simulation::dialogue::process_talk_interaction), crate::simulation::dialogue::process_confrontation_response .after(crate::simulation::input::process_player_input), crate::simulation::dialogue::process_dialogue_response .after(crate::simulation::input::process_player_input) .after(crate::simulation::dialogue::process_talk_interaction), ), ); tracing::debug!("NpcPlugin initialized"); } } #[derive(Component, Debug)] pub struct Npc; /// NPC animation tier (D-047). /// /// Tier 1 (clear): public daily activities — instantly readable. /// Tier 2 (ambiguous): privately motivated behaviors — player sees the action /// but cannot determine the intention. /// /// NPCs start at Tier 1. Confrontation (D-063) and other triggers shift to Tier 2. #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] pub enum AnimationTier { /// Clear, readable public activities (walking, working, talking). #[default] Tier1, /// Ambiguous, privately motivated behaviors (pausing, lingering, looking around). Tier2, } /// Duration (ticks) a storyteller-escalated RoutineDeviation persists. /// 300 ticks = 30 game-minutes at 10 ticks/game-minute (D-031). pub const TELL_ESCALATION_DURATION_TICKS: u64 = 300; /// Tracks why and when an NPC's routine deviated from normal (D-064 Phase 2). /// /// Inserted when a player action causes an NPC to break from their scheduled /// behavior. Acts as a hook for the storyteller system and affects future /// interactions (e.g., second-approach dialogue differences). /// /// `expires_at_tick`: tick at which this component should be removed. /// 0 means "never expires" (legacy default for old insertions). #[derive(Component, Debug, Clone)] pub struct RoutineDeviation { pub trigger: DeviationTrigger, pub tick: u64, /// Tick at which this deviation expires (component removed by cleanup system). /// Set to `tick + TELL_ESCALATION_DURATION_TICKS` for time-limited deviations. pub expires_at_tick: u64, } /// What caused an NPC's routine deviation. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum DeviationTrigger { /// Player walked away mid-dialogue (D-064 Phase 2). WalkAway, /// Player delivered a confrontation (D-063). Confrontation, /// Storyteller activated a triangle this NPC belongs to (#589, D-024 axis 9). TriangleEscalation, } // --------------------------------------------------------------------------- // Entanglement tag (D-029, #176) // --------------------------------------------------------------------------- /// Marks an NPC's narrative entanglement level (D-029). /// /// - `Flat`: background population — no triangle involvement, minimal story role. /// - `Mundane`: has routines and personality but no active triangle membership. /// - `Intrigue`: participates in at least one triangle — drives narrative tension. /// /// For authored NPCs: determined by YAML `triangle_membership` field. /// For procedural NPCs: assigned by the 30/50/20 ratio via SimRng. #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum EntanglementTag { Flat, Mundane, Intrigue, } // --------------------------------------------------------------------------- // Axis 1: Want (D-024) // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum WantKind { Wealth, Safety, Knowledge, Connection, Power, Freedom, Justice, Revenge, Happiness, } #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct Want { pub primary: WantKind, pub intensity: u8, // 1-10, integer for determinism (D-010) pub description: String, } // --------------------------------------------------------------------------- // Axis 2: Secret / vulnerability (D-024) // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum SecretSeverity { Minor, // Social embarrassment Moderate, // Career-threatening Major, // Criminal / life-threatening } #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct Secret { pub description: String, pub severity: SecretSeverity, pub known_by: Vec, } // --------------------------------------------------------------------------- // Axis 3: Relationships 1-3 (D-024) // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum RelationshipKind { Colleague, Friend, Rival, Romantic, Family, Superior, Subordinate, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelationshipEvent { pub tick: u64, pub description: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Relationship { pub target_id: StableId, pub kind: RelationshipKind, pub trust_level: i8, // -10..+10, integer for determinism (D-010) pub history: Vec, } /// Per-NPC relationship slots. D-024: 3 key relationships for Active-tier. pub const MAX_KEY_RELATIONSHIPS: usize = 3; #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct Relationships { pub entries: Vec, } // --------------------------------------------------------------------------- // Axis 4: Tolerance threshold (D-024) // --------------------------------------------------------------------------- #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct ToleranceThreshold { pub current_stress: i16, // 0-100, integer for determinism (D-010) pub threshold: i16, } // --------------------------------------------------------------------------- // Axis 5: Daily routine (D-024, D-031) // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RoutineEntry { pub phase: DayPhase, pub location: TilePosition, pub activity: String, } #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct DailyRoutine { pub entries: Vec, pub description: String, } impl DailyRoutine { /// Get the routine entry for a given day phase. pub fn entry_for_phase(&self, phase: DayPhase) -> Option<&RoutineEntry> { self.entries.iter().find(|e| e.phase == phase) } /// Get the expected location for a given day phase. pub fn expected_location(&self, phase: DayPhase) -> Option { self.entry_for_phase(phase).map(|e| e.location) } } // --------------------------------------------------------------------------- // Axis 6: Information inventory (D-024) // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KnownFact { pub fact_id: FactId, pub confidence: KnowledgeConfidence, } #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct InformationInventory { pub facts: Vec, } // --------------------------------------------------------------------------- // Axis 7: Contentment (D-024, Gore's thematic axis) // --------------------------------------------------------------------------- #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct Contentment { pub level: i16, // -100..+100, integer for determinism (D-010) } // --------------------------------------------------------------------------- // Job performance (D-026 background state machine — feeds from Contentment) // --------------------------------------------------------------------------- /// Tracks how well an NPC performs their job role. /// /// Drifts based on `Contentment` level in the background-tier state machine: /// - contentment > 0 → score increases toward 100 /// - contentment < 0 → score decreases toward 0 /// - contentment = 0 → no change /// /// Updated by `background::background_tick` once per game-minute for /// Background-tier NPCs. Persists through tier promotions (D-026). #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct JobPerformance { pub score: i16, // 0..=100, integer for determinism (D-010) } impl Default for JobPerformance { fn default() -> Self { Self { score: 50 } } } // --------------------------------------------------------------------------- // Supporting axis 1: Personality traits (D-024) // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum PersonalityTrait { Cautious, Bold, Honest, Deceptive, Compassionate, Ruthless, Curious, Incurious, Social, Reclusive, } #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct PersonalityTraits { pub traits: Vec, } // --------------------------------------------------------------------------- // Supporting axis 2: Tell system (D-024) // --------------------------------------------------------------------------- #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum TellTrigger { StressAboveThreshold, NearSpecificEntity(StableId), DuringActivity(String), TimeOfDay(DayPhase), Always, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tell { pub trigger: TellTrigger, pub behavior: String, } #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct TellSystem { pub tells: Vec, } // --------------------------------------------------------------------------- // Supporting axis 3: Skill set + combat component (D-024) // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum Skill { Combat, Intimidation, Medical, Observation, Persuasion, Piloting, Stealth, Technical, } #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct SkillSet { pub skills: BTreeMap, // Skill -> proficiency (1-10), BTreeMap for determinism pub combat_trained: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum CombatStyle { Ranged, Melee, Evasive, Defensive, } #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct CombatCapability { pub weapon_proficiency: u8, // 1-10 pub combat_style: CombatStyle, } #[cfg(test)] mod tests { use super::*; #[test] fn daily_routine_entry_for_phase() { let routine = DailyRoutine { entries: vec![ RoutineEntry { phase: DayPhase::Morning, location: TilePosition::new(5, 5, 0), activity: "Work".into(), }, RoutineEntry { phase: DayPhase::Evening, location: TilePosition::new(10, 10, 0), activity: "Bar".into(), }, ], description: "Test routine".into(), }; assert_eq!( routine.expected_location(DayPhase::Morning), Some(TilePosition::new(5, 5, 0)) ); assert_eq!( routine.expected_location(DayPhase::Evening), Some(TilePosition::new(10, 10, 0)) ); assert_eq!(routine.expected_location(DayPhase::Afternoon), None); assert_eq!(routine.expected_location(DayPhase::Night), None); } #[test] fn skill_set_btreemap_deterministic() { let mut skills1 = BTreeMap::new(); skills1.insert(Skill::Combat, 5); skills1.insert(Skill::Stealth, 3); skills1.insert(Skill::Persuasion, 7); let mut skills2 = BTreeMap::new(); skills2.insert(Skill::Persuasion, 7); skills2.insert(Skill::Combat, 5); skills2.insert(Skill::Stealth, 3); // Insertion order doesn't matter — iteration is deterministic let keys1: Vec<_> = skills1.keys().collect(); let keys2: Vec<_> = skills2.keys().collect(); assert_eq!(keys1, keys2); } #[test] fn relationship_max_entries() { let rels = Relationships { entries: vec![ Relationship { target_id: StableId(1), kind: RelationshipKind::Friend, trust_level: 5, history: vec![], }, Relationship { target_id: StableId(2), kind: RelationshipKind::Colleague, trust_level: 2, history: vec![], }, Relationship { target_id: StableId(3), kind: RelationshipKind::Rival, trust_level: -3, history: vec![], }, ], }; assert_eq!(rels.entries.len(), MAX_KEY_RELATIONSHIPS); } }