feat(simulation): NPC mood state machine (#323, D-024, D-035)

8-state NPC mood FSM driven by stress, time of day, and interaction events.
Adds MoodState component, derive_mood() pure function, and update_mood() Bevy
system wired into NpcPlugin. Adds Focused as 9th Mood content tag (D-035
Sprint 8 amendment). Syncs CurrentMood for Layer 4 dialogue selection.

31 unit tests covering priority ordering, boundary conditions, and system
integration. All random values route through SimRng (D-010 determinism).
No floats. No HashMap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 18:41:51 +01:00
co-authored by Claude Sonnet 4.6
parent aa0d8ced37
commit 9eba259fd5
4 changed files with 683 additions and 2 deletions
+1
View File
@@ -637,5 +637,6 @@ fn mood_str(m: &Mood) -> &'static str {
Mood::Conflicted => "conflicted",
Mood::Concerned => "concerned",
Mood::Relieved => "relieved",
Mood::Focused => "focused",
}
}
+5
View File
@@ -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(),
+8 -2
View File
@@ -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::<routine::PreviousDayPhase>()
.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");
+669
View File
@@ -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<SimulationTime>,
mut query: Query<
(
&mut MoodState,
Option<&mut CurrentMood>,
Option<&ToleranceThreshold>,
),
(With<Npc>, With<ActiveSim>),
>,
) {
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::<SimulationTime>();
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::<MoodState>(npc).unwrap();
assert_eq!(mood_state.mood, NpcMood::Hostile);
let current_mood = world.get::<CurrentMood>(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::<MoodState>(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::<SimulationTime>().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::<MoodState>(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::<SimulationTime>().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::<MoodState>(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::<MoodState>(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::<CurrentMood>(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::<MoodState>(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::<MoodState>(calm).unwrap().mood, NpcMood::Content);
assert_eq!(
world.get::<MoodState>(stressed).unwrap().mood,
NpcMood::Hostile
);
}
}