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>
734 lines
24 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|