diff --git a/server/src/bin/line_preview.rs b/server/src/bin/line_preview.rs index d6a93955b..5555923cf 100644 --- a/server/src/bin/line_preview.rs +++ b/server/src/bin/line_preview.rs @@ -637,5 +637,6 @@ fn mood_str(m: &Mood) -> &'static str { Mood::Conflicted => "conflicted", Mood::Concerned => "concerned", Mood::Relieved => "relieved", + Mood::Focused => "focused", } } diff --git a/server/src/content/line_pool.rs b/server/src/content/line_pool.rs index 2078ae9b6..79524a7de 100644 --- a/server/src/content/line_pool.rs +++ b/server/src/content/line_pool.rs @@ -157,6 +157,7 @@ impl FromStr for Topic { } /// D-028 Layer 4: Mood tag — influences weighted selection. +/// D-035 amendment (Sprint 8): `Focused` added as 9th variant. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Mood { Fond, @@ -167,6 +168,9 @@ pub enum Mood { Conflicted, Concerned, Relieved, + /// D-035 amendment (Sprint 8): task-focused NPC mood — used at The Terminal + /// and maintenance corridors. Maps from NpcMood::Focused. + Focused, } impl FromStr for Mood { @@ -181,6 +185,7 @@ impl FromStr for Mood { "conflicted" => Ok(Self::Conflicted), "concerned" => Ok(Self::Concerned), "relieved" => Ok(Self::Relieved), + "focused" => Ok(Self::Focused), _ => Err(ParseEnumError { kind: "Mood", value: s.to_string(), diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index abbac1bc6..dfa65c1bf 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -2,6 +2,7 @@ // Implements D-024: 10-axis NPC model + CombatCapability component // Background tier state machines for schedule, mood, relationships, job +pub mod mood; pub mod relationships; pub mod routine; @@ -24,8 +25,13 @@ impl Plugin for NpcPlugin { .init_resource::() .add_systems( Update, - routine::check_phase_transition - .before(crate::simulation::pathfinding::compute_paths), + ( + 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), + ), ); tracing::debug!("NpcPlugin initialized"); diff --git a/server/src/npc/mood.rs b/server/src/npc/mood.rs new file mode 100644 index 000000000..c492f478f --- /dev/null +++ b/server/src/npc/mood.rs @@ -0,0 +1,669 @@ +//! 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::content::line_pool::Mood as ContentMood; +use crate::npc::{Npc, ToleranceThreshold}; +use crate::simulation::dialogue::CurrentMood; +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. + 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. + 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::Comfortable, + NpcMood::Anxious => ContentMood::Worried, + NpcMood::Frustrated => ContentMood::Conflicted, + NpcMood::Content => ContentMood::Relieved, + NpcMood::Suspicious => ContentMood::Suspicious, + NpcMood::Warm => ContentMood::Fond, + NpcMood::Hostile => ContentMood::Concerned, + 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; + +/// 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 * 100 >= threshold * ANXIOUS_STRESS_NUMERATOR { + 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. +/// +/// TODO(#325): wire `warm_active` from `InteractionMemory` when Sprint 14 #325 lands. +pub fn update_mood( + time: Res, + mut query: Query< + ( + &mut MoodState, + Option<&mut CurrentMood>, + Option<&ToleranceThreshold>, + ), + (With, With), + >, +) { + let phase = time.day_phase(); + let tick = time.tick; + + for (mut mood_state, current_mood_opt, tolerance_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 + + // TODO(#325): query InteractionMemory.recent_positive_interaction() + let warm_active = 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_worried() { + assert_eq!(mood_to_content_mood(NpcMood::Anxious), ContentMood::Worried); + } + + #[test] + fn mood_mapping_warm_is_fond() { + assert_eq!(mood_to_content_mood(NpcMood::Warm), ContentMood::Fond); + } + + #[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::Concerned); + } + + #[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 Comfortable + 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 Worried + assert_eq!(current_mood.0, ContentMood::Worried); + } + + #[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); + } + + #[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 + ); + } +}