Files
settled-reach/server/src/npc/mood.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

734 lines
24 KiB
Rust

//! 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<SimulationTime>,
mut query: Query<
(
&mut MoodState,
Option<&mut CurrentMood>,
Option<&ToleranceThreshold>,
Option<&InteractionMemory>,
),
(With<Npc>, With<ActiveSim>),
>,
) {
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::<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::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::<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 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::<CurrentMood>(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::<MoodState>(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::<MoodState>(calm).unwrap().mood, NpcMood::Content);
assert_eq!(
world.get::<MoodState>(stressed).unwrap().mood,
NpcMood::Hostile
);
}
}