//! NPC mood state machine (#323). //! //! Implements the 8-state NPC mood FSM (D-024 MoodState axis, D-035 taxonomy). //! Mood is derived each tick from simulation inputs (stress, time of day, //! recent interactions) and drives Layer 4 dialogue selection and monologue tone. //! //! All state transitions are deterministic — integer arithmetic only (D-010). //! No floats. No HashMap. //! //! ## Integration points //! - `ToleranceThreshold.current_stress` → primary mood driver //! - `SimulationTime.day_phase()` → Evening phase adds Frustrated pressure //! - `InteractionMemory` (Sprint 14, #325) → will set warm_active flag //! - `CurrentMood` (dialogue.rs) → synced each tick for Layer 4 selection //! - Monologue trigger system → reads NpcMood for tone selection (D-016, future) //! - Tell system (#337, deferred to Sprint 15) → reads MoodState use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; use crate::npc::interaction::InteractionMemory; use crate::npc::{Npc, ToleranceThreshold}; use crate::simulation::dialogue::CurrentMood; use crate::simulation::line_pool::Mood as ContentMood; use crate::simulation::tier::ActiveSim; use crate::simulation::time::{DayPhase, SimulationTime}; // --------------------------------------------------------------------------- // NpcMood enum // --------------------------------------------------------------------------- /// NPC simulation mood — 8-state FSM (D-024, D-035 converged taxonomy). /// /// Driven by `ToleranceThreshold` stress, time of day, and interaction events. /// Maps to `content::line_pool::Mood` for Layer 4 dialogue tag matching. /// /// Copy team references this enum when scripting mood conditions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] pub enum NpcMood { /// Default state: no notable stressors, no recent positive events. #[default] Neutral, /// Elevated stress approaching threshold — heightened wariness. Anxious, /// Late-shift fatigue or repeated minor irritations. Frustrated, /// Low stress, positive recent context — settled and cooperative. Content, /// Observing unusual or off-script behavior — targeted wariness. /// Not reachable from `derive_mood()` — set externally by observation pipeline. Suspicious, /// Recent positive player interaction within memory window. Warm, /// Stress at or above threshold — confrontational or withdrawn. Hostile, /// Actively engaged in a scheduled activity — task-focused. /// Not reachable from `derive_mood()` — set externally by activity scheduler (#101). Focused, } // --------------------------------------------------------------------------- // MoodState component // --------------------------------------------------------------------------- /// Per-NPC mood component — wraps NpcMood for ECS queries. /// /// Updated each tick by `update_mood` for Active-tier NPCs. /// Read by: dialogue Layer 4 (via CurrentMood sync), tell system (#337), /// monologue tone selection (D-016, future scope). #[derive(Component, Debug, Clone, Default, Serialize, Deserialize)] pub struct MoodState { pub mood: NpcMood, /// Tick when mood last changed — guards against thrashing in tests. pub changed_tick: u64, } // --------------------------------------------------------------------------- // Mood mapping: NpcMood → content::line_pool::Mood // --------------------------------------------------------------------------- /// Map NPC simulation mood to the content dialogue tag. /// /// Bridges the simulation FSM (NpcMood) with the dialogue line pool system /// (content::line_pool::Mood). The mapping is intentionally lossy in some /// directions — multiple simulation moods map to the same content tag when /// the distinction matters for behavior but not for line selection. pub fn mood_to_content_mood(mood: NpcMood) -> ContentMood { match mood { NpcMood::Neutral => ContentMood::Content, NpcMood::Anxious => ContentMood::Anxious, NpcMood::Frustrated => ContentMood::Frustrated, NpcMood::Content => ContentMood::Relieved, NpcMood::Suspicious => ContentMood::Suspicious, NpcMood::Warm => ContentMood::Warm, NpcMood::Hostile => ContentMood::Hostile, NpcMood::Focused => ContentMood::Focused, } } // --------------------------------------------------------------------------- // Mood derivation (pure, testable) // --------------------------------------------------------------------------- /// Stress fraction threshold for Anxious: 60% of tolerance threshold. /// /// Uses integer multiplication to avoid division: /// Anxious when `current_stress * 100 >= threshold * ANXIOUS_STRESS_NUMERATOR` /// Equivalent to: `current_stress >= threshold * 0.60` const ANXIOUS_STRESS_NUMERATOR: i16 = 60; /// Stress level below which an NPC is considered Content (no notable pressure). const CONTENT_STRESS_CEILING: i16 = 20; /// Minimum stress for Evening → Frustrated (avoids Frustrated at zero stress). const FRUSTRATED_STRESS_FLOOR: i16 = 10; /// Ticks within which a completed Talk interaction keeps the Warm mood active. /// 300 ticks = 30 game-minutes (D-031: 10 ticks/minute). pub const WARM_INTERACTION_WINDOW_TICKS: u64 = 300; /// Derive NPC mood from simulation inputs. /// /// Priority ordering (high to low): /// 1. Hostile — stress at or above threshold /// 2. Anxious — stress at 60% of threshold or above /// 3. Warm — recent positive player interaction /// 4. Frustrated — Evening phase with non-trivial stress /// 5. Content — very low stress (< CONTENT_STRESS_CEILING) /// 6. Neutral — everything else /// /// Inputs are all integer or enum — no floats (D-010 determinism). /// /// `warm_active`: set by InteractionMemory (#325, Sprint 14) when a positive /// interaction occurred within the memory window. Placeholder `false` until /// #325 is wired. pub fn derive_mood( current_stress: i16, threshold: i16, phase: DayPhase, warm_active: bool, ) -> NpcMood { // 1. Hostile: at or above threshold if current_stress >= threshold { return NpcMood::Hostile; } // 2. Anxious: above 60% of threshold. // Guard: skip if threshold == 0 (divide-by-zero equivalent — entity // has no tolerance and is already Hostile from rule 1). if threshold > 0 && (current_stress as i32) * 100 >= (threshold as i32) * (ANXIOUS_STRESS_NUMERATOR as i32) { return NpcMood::Anxious; } // 3. Warm: recent positive interaction (priority over Frustrated/Content) if warm_active { return NpcMood::Warm; } // 4. Frustrated: Evening phase with non-trivial stress if phase == DayPhase::Evening && current_stress >= FRUSTRATED_STRESS_FLOOR { return NpcMood::Frustrated; } // 5. Content: very low stress if current_stress < CONTENT_STRESS_CEILING { return NpcMood::Content; } // 6. Neutral: moderate stress, no special conditions NpcMood::Neutral } // --------------------------------------------------------------------------- // System: update_mood // --------------------------------------------------------------------------- /// System: update NpcMood and sync CurrentMood for Active-tier NPCs. /// /// Reads `ToleranceThreshold` stress and `SimulationTime` day phase to derive /// the new mood. Updates `MoodState` when mood changes (records changed_tick). /// Syncs `CurrentMood` (used by dialogue Layer 4) every tick regardless of /// whether MoodState changed. /// /// Scoped to `ActiveSim` — Background-tier NPCs retain their last mood state /// (D-026). This is intentional: background NPCs simulate passage of time via /// last-known state, not per-tick derivation. /// pub fn update_mood( time: Res, mut query: Query< ( &mut MoodState, Option<&mut CurrentMood>, Option<&ToleranceThreshold>, Option<&InteractionMemory>, ), (With, With), >, ) { let phase = time.day_phase(); let tick = time.tick; for (mut mood_state, current_mood_opt, tolerance_opt, interaction_mem_opt) in query.iter_mut() { let (stress, threshold) = tolerance_opt .map(|t| (t.current_stress, t.threshold)) .unwrap_or((0, 50)); // Default: no stress, moderate threshold // Warm: recent positive player interaction within memory window (#325) let warm_active = interaction_mem_opt .map(|mem| { mem.interaction_count > 0 && tick.saturating_sub(mem.last_interaction_tick) < WARM_INTERACTION_WINDOW_TICKS }) .unwrap_or(false); let new_mood = derive_mood(stress, threshold, phase, warm_active); if mood_state.mood != new_mood { mood_state.mood = new_mood; mood_state.changed_tick = tick; } // Sync CurrentMood for dialogue pipeline — always, not just on change. // CurrentMood drives Layer 4 scoring; it must reflect current simulation // state even if MoodState itself didn't change this tick. if let Some(mut current_mood) = current_mood_opt { current_mood.0 = mood_to_content_mood(new_mood); } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::npc::{Npc, ToleranceThreshold}; use crate::simulation::dialogue::CurrentMood; use crate::simulation::tier::{ActiveSim, BackgroundSim}; use crate::simulation::time::{DayPhase, SimulationTime}; use bevy_ecs::world::World; // --- derive_mood unit tests --- #[test] fn mood_hostile_when_stress_equals_threshold() { assert_eq!( derive_mood(50, 50, DayPhase::Morning, false), NpcMood::Hostile ); } #[test] fn mood_hostile_when_stress_above_threshold() { assert_eq!( derive_mood(80, 50, DayPhase::Morning, false), NpcMood::Hostile ); } #[test] fn mood_anxious_at_60_percent_threshold() { // 60% of threshold=100 is 60. stress=60 → Anxious. assert_eq!( derive_mood(60, 100, DayPhase::Morning, false), NpcMood::Anxious ); } #[test] fn mood_anxious_boundary_above() { // threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000). assert_eq!( derive_mood(30, 50, DayPhase::Morning, false), NpcMood::Anxious ); } #[test] fn mood_not_anxious_just_below_boundary() { // threshold=50: 60% = 30. stress=29 → not Anxious (29*100=2900 < 3000). // stress=29 < 20 is false, so → Neutral. assert_eq!( derive_mood(29, 50, DayPhase::Morning, false), NpcMood::Neutral ); } #[test] fn mood_warm_when_positive_interaction() { assert_eq!(derive_mood(0, 50, DayPhase::Morning, true), NpcMood::Warm); } #[test] fn mood_frustrated_when_evening_with_stress() { // stress=25 (not hostile/anxious), Evening phase → Frustrated assert_eq!( derive_mood(25, 50, DayPhase::Evening, false), NpcMood::Frustrated ); } #[test] fn mood_not_frustrated_in_morning() { assert_eq!( derive_mood(25, 50, DayPhase::Morning, false), NpcMood::Neutral ); } #[test] fn mood_not_frustrated_when_stress_below_floor() { // stress=5 < FRUSTRATED_STRESS_FLOOR=10 → Content (stress < 20) assert_eq!( derive_mood(5, 50, DayPhase::Evening, false), NpcMood::Content ); } #[test] fn mood_content_when_low_stress() { assert_eq!( derive_mood(15, 50, DayPhase::Morning, false), NpcMood::Content ); } #[test] fn mood_content_boundary_at_19() { // stress=19 < CONTENT_STRESS_CEILING=20 → Content assert_eq!( derive_mood(19, 50, DayPhase::Morning, false), NpcMood::Content ); } #[test] fn mood_neutral_otherwise() { // stress=25, not anxious (25*100=2500 < 50*60=3000), not Warm, morning, not Content // Wait: 25*100=2500, 50*60=3000 → not Anxious. 25 >= 20 → not Content. Morning → not Frustrated. → Neutral assert_eq!( derive_mood(25, 50, DayPhase::Morning, false), NpcMood::Neutral ); } #[test] fn mood_priority_hostile_over_anxious_at_threshold() { // At exactly threshold → Hostile, not Anxious assert_eq!( derive_mood(50, 50, DayPhase::Morning, false), NpcMood::Hostile ); } #[test] fn mood_priority_hostile_over_frustrated_evening() { assert_eq!( derive_mood(50, 50, DayPhase::Evening, false), NpcMood::Hostile ); } #[test] fn mood_priority_anxious_over_warm() { // Anxious takes priority over Warm interaction assert_eq!( derive_mood(60, 100, DayPhase::Morning, true), NpcMood::Anxious ); } #[test] fn mood_priority_warm_over_frustrated() { // Warm takes priority over Frustrated (checked before Evening test) assert_eq!(derive_mood(25, 50, DayPhase::Evening, true), NpcMood::Warm); } #[test] fn mood_zero_threshold_is_hostile() { // stress=0, threshold=0: 0 >= 0 → Hostile assert_eq!( derive_mood(0, 0, DayPhase::Morning, false), NpcMood::Hostile ); } #[test] fn mood_content_zero_stress_moderate_threshold() { // stress=0, threshold=50: not hostile, not anxious (threshold > 0, 0*100=0 < 50*60=3000), // not warm, not evening, stress < 20 → Content assert_eq!( derive_mood(0, 50, DayPhase::Morning, false), NpcMood::Content ); } // --- mood_to_content_mood mapping coverage --- #[test] fn mood_mapping_covers_all_variants() { for mood in [ NpcMood::Neutral, NpcMood::Anxious, NpcMood::Frustrated, NpcMood::Content, NpcMood::Suspicious, NpcMood::Warm, NpcMood::Hostile, NpcMood::Focused, ] { let _ = mood_to_content_mood(mood); // must not panic } } #[test] fn mood_mapping_anxious_is_anxious() { assert_eq!(mood_to_content_mood(NpcMood::Anxious), ContentMood::Anxious); } #[test] fn mood_mapping_warm_is_warm() { assert_eq!(mood_to_content_mood(NpcMood::Warm), ContentMood::Warm); } #[test] fn mood_mapping_suspicious_is_suspicious() { assert_eq!( mood_to_content_mood(NpcMood::Suspicious), ContentMood::Suspicious ); } #[test] fn mood_mapping_focused_is_focused() { assert_eq!(mood_to_content_mood(NpcMood::Focused), ContentMood::Focused); } // --- update_mood system integration tests --- fn setup_world() -> World { let mut world = World::new(); world.init_resource::(); world } #[test] fn update_mood_sets_hostile_when_stress_at_threshold() { let mut world = setup_world(); let npc = world .spawn(( Npc, ActiveSim, MoodState::default(), CurrentMood::default(), ToleranceThreshold { current_stress: 50, threshold: 50, }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(update_mood); schedule.run(&mut world); let mood_state = world.get::(npc).unwrap(); assert_eq!(mood_state.mood, NpcMood::Hostile); let current_mood = world.get::(npc).unwrap(); assert_eq!(current_mood.0, ContentMood::Hostile); } #[test] fn update_mood_defaults_to_content_without_tolerance() { let mut world = setup_world(); // No ToleranceThreshold → defaults (stress=0, threshold=50) → Content let npc = world .spawn((Npc, ActiveSim, MoodState::default(), CurrentMood::default())) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(update_mood); schedule.run(&mut world); let mood_state = world.get::(npc).unwrap(); assert_eq!(mood_state.mood, NpcMood::Content); } #[test] fn update_mood_records_changed_tick_on_transition() { let mut world = setup_world(); world.resource_mut::().tick = 42; let npc = world .spawn(( Npc, ActiveSim, // Start Warm, will transition to Hostile MoodState { mood: NpcMood::Warm, changed_tick: 0, }, CurrentMood::default(), ToleranceThreshold { current_stress: 50, threshold: 50, }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(update_mood); schedule.run(&mut world); let mood_state = world.get::(npc).unwrap(); assert_eq!(mood_state.mood, NpcMood::Hostile); assert_eq!(mood_state.changed_tick, 42); } #[test] fn update_mood_does_not_update_changed_tick_when_unchanged() { let mut world = setup_world(); world.resource_mut::().tick = 42; let npc = world .spawn(( Npc, ActiveSim, // Already Content; no tolerance → will derive Content again MoodState { mood: NpcMood::Content, changed_tick: 5, }, CurrentMood::default(), )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(update_mood); schedule.run(&mut world); let mood_state = world.get::(npc).unwrap(); assert_eq!(mood_state.mood, NpcMood::Content); assert_eq!(mood_state.changed_tick, 5); // unchanged } #[test] fn update_mood_skips_background_npcs() { let mut world = setup_world(); // BackgroundSim NPC — must not be updated let npc = world .spawn(( Npc, BackgroundSim, MoodState { mood: NpcMood::Warm, changed_tick: 0, }, CurrentMood::default(), ToleranceThreshold { current_stress: 50, threshold: 50, // Would → Hostile if processed }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(update_mood); schedule.run(&mut world); let mood_state = world.get::(npc).unwrap(); // Must remain Warm — not processed because BackgroundSim, not ActiveSim assert_eq!(mood_state.mood, NpcMood::Warm); } #[test] fn update_mood_syncs_current_mood_when_present() { let mut world = setup_world(); let npc = world .spawn(( Npc, ActiveSim, MoodState::default(), CurrentMood::default(), // Starts at Content ToleranceThreshold { current_stress: 70, threshold: 100, // → Anxious (70% of 100) }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(update_mood); schedule.run(&mut world); let current_mood = world.get::(npc).unwrap(); // Anxious maps to Anxious assert_eq!(current_mood.0, ContentMood::Anxious); } #[test] fn update_mood_works_without_current_mood() { let mut world = setup_world(); // NPC without CurrentMood — system must not panic let npc = world .spawn(( Npc, ActiveSim, MoodState::default(), // No CurrentMood )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(update_mood); schedule.run(&mut world); // must not panic let mood_state = world.get::(npc).unwrap(); assert_eq!(mood_state.mood, NpcMood::Content); } // -- Additional QA coverage (Hoshe, Sprint 14) -------------------------- #[test] fn derive_mood_negative_stress_is_content() { // i16 stress can be negative (e.g. buffs reducing stress below zero). // Negative stress is well below CONTENT_STRESS_CEILING (20) → Content. // Note: `current_stress * 100` in the Anxious check can overflow i16 for extreme // values (stress < -327 or > 327 at threshold=50). Realistic game values stay small. assert_eq!( derive_mood(-10, 50, DayPhase::Morning, false), NpcMood::Content, "Negative stress not hostile/anxious, morning, stress<20 → Content" ); assert_eq!( derive_mood(-50, 50, DayPhase::Evening, false), NpcMood::Content, "Negative stress in Evening: stress < FRUSTRATED_STRESS_FLOOR (10) → Content not Frustrated" ); } #[test] fn derive_mood_cannot_return_suspicious_or_focused() { // Suspicious and Focused are valid NpcMood states but are NOT reachable // from derive_mood(). They must be set externally by other systems // (e.g., observation pipeline for Suspicious, activity scheduler for Focused). // This test documents the invariant: derive_mood never emits these states. use std::collections::HashSet; let phases = [ DayPhase::Morning, DayPhase::Afternoon, DayPhase::Evening, DayPhase::Night, ]; let stresses: &[i16] = &[-50, -1, 0, 1, 19, 20, 29, 30, 49, 50, 51, 100]; let thresholds: &[i16] = &[0, 1, 50, 100]; let warm_flags = [false, true]; let mut observed = HashSet::new(); for &phase in &phases { for &stress in stresses { for &threshold in thresholds { for warm in warm_flags { let m = derive_mood(stress, threshold, phase, warm); observed.insert(format!("{:?}", m)); } } } } assert!( !observed.contains("Suspicious"), "derive_mood should never return Suspicious — set by observation pipeline" ); assert!( !observed.contains("Focused"), "derive_mood should never return Focused — set by activity scheduler (#101)" ); } #[test] fn update_mood_multiple_npcs_independent() { let mut world = setup_world(); let calm = world .spawn(( Npc, ActiveSim, MoodState::default(), CurrentMood::default(), ToleranceThreshold { current_stress: 5, threshold: 50, }, )) .id(); let stressed = world .spawn(( Npc, ActiveSim, MoodState::default(), CurrentMood::default(), ToleranceThreshold { current_stress: 50, threshold: 50, }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(update_mood); schedule.run(&mut world); assert_eq!(world.get::(calm).unwrap().mood, NpcMood::Content); assert_eq!( world.get::(stressed).unwrap().mood, NpcMood::Hostile ); } }