Files
settled-reach/server/src/npc/background.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

775 lines
23 KiB
Rust

//! Background tier state machines (#95, D-026).
//!
//! Four lightweight state machines for Background-tier NPCs, firing once per
//! game-minute (D-031: `TICKS_PER_GAME_MINUTE` = 10).
//!
//! ## State machines
//! 1. **Schedule** — set `ActivityState` from `DailyRoutine` + `DayPhase` (no pathfinding)
//! 2. **Mood** — stress-based derivation (simplified: no phase or warm flag)
//! 3. **Relationships** — per-NPC `trust_level` drifts toward 0 (baseline)
//! 4. **Job** — `JobPerformance.score` drifts based on `Contentment.level`
//!
//! ## Design constraints (D-026)
//! - No pathfinding, LOS, or dialogue — those are Active-tier only.
//! - All arithmetic is integer-only (D-010 determinism requirement).
//! - Background NPCs promoted to Active retain their state machine state (no reset on promotion).
use bevy_ecs::prelude::*;
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::routine::ActivityState;
use crate::npc::{
Contentment, DailyRoutine, JobPerformance, Npc, Relationships, ToleranceThreshold,
};
use crate::simulation::tier::BackgroundSim;
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
// ---------------------------------------------------------------------------
// Mood derivation (background-tier)
// ---------------------------------------------------------------------------
/// Derive simplified mood for a Background-tier NPC.
///
/// No phase or warm-flag considerations — background NPCs have no active
/// interactions. Priority order:
/// 1. Hostile — stress ≥ threshold
/// 2. Anxious — stress ≥ 60% of threshold (integer arithmetic, D-010)
/// 3. Content — stress < 20
/// 4. Neutral — otherwise
pub fn derive_background_mood(current_stress: i16, threshold: i16) -> NpcMood {
// 1. Hostile: at or above threshold
if current_stress >= threshold {
return NpcMood::Hostile;
}
// 2. Anxious: 60% of threshold reached
// Guard: skip if threshold == 0 (entity already Hostile from rule 1).
if threshold > 0 && (current_stress as i32) * 100 >= (threshold as i32) * 60 {
return NpcMood::Anxious;
}
// 3. Content: low stress
if current_stress < 20 {
return NpcMood::Content;
}
// 4. Neutral: default
NpcMood::Neutral
}
// ---------------------------------------------------------------------------
// Job performance drift
// ---------------------------------------------------------------------------
/// Drift job performance score one point per game-minute based on contentment.
///
/// - contentment > 0 → score + 1 (clamped at 100)
/// - contentment < 0 → score - 1 (clamped at 0)
/// - contentment = 0 → unchanged
pub fn drift_job_performance(score: i16, contentment_level: i16) -> i16 {
match contentment_level.cmp(&0) {
std::cmp::Ordering::Greater => (score + 1).min(100),
std::cmp::Ordering::Less => (score - 1).max(0),
std::cmp::Ordering::Equal => score,
}
}
// ---------------------------------------------------------------------------
// System: background_tick
// ---------------------------------------------------------------------------
/// System: run all four background-tier state machines once per game-minute.
///
/// Fires when `time.tick % TICKS_PER_GAME_MINUTE == 0`. Scoped to
/// `With<BackgroundSim>` — Active-tier NPCs are handled by their dedicated
/// per-tick systems.
///
/// Machine execution order per NPC:
/// 1. Schedule — insert/update `ActivityState` from `DailyRoutine` + `DayPhase`
/// 2. Mood — update `MoodState` from stress/threshold (simplified)
/// 3. Relationships — drift `Relationships.entries[].trust_level` toward 0
/// 4. Job — drift `JobPerformance.score` from `Contentment.level`
///
/// Commands for `ActivityState` are deferred (applied after system runs).
/// Mutable component mutations happen immediately within the iteration.
pub fn background_tick(
time: Res<SimulationTime>,
mut commands: Commands,
mut query: Query<
(
Entity,
Option<&ActivityState>,
&mut MoodState,
Option<&mut Relationships>,
Option<&ToleranceThreshold>,
Option<&DailyRoutine>,
Option<&Contentment>,
Option<&mut JobPerformance>,
),
(With<Npc>, With<BackgroundSim>),
>,
) {
// Fire once per game-minute (D-031: 10 ticks/minute)
if !time.tick.is_multiple_of(TICKS_PER_GAME_MINUTE) {
return;
}
let phase = time.day_phase();
let tick = time.tick;
for (
entity,
activity_opt,
mut mood_state,
rels_opt,
tolerance_opt,
routine_opt,
contentment_opt,
job_opt,
) in query.iter_mut()
{
// --- 1. Schedule: sync ActivityState to current DayPhase ---
// Background NPCs don't pathfind — we directly declare the activity.
if let Some(routine) = routine_opt {
if let Some(entry) = routine.entry_for_phase(phase) {
let needs_update = match activity_opt {
Some(a) => a.phase != phase || a.activity != entry.activity,
None => true,
};
if needs_update {
commands.entity(entity).insert(ActivityState {
activity: entry.activity.clone(),
phase,
started_tick: tick,
});
}
} else if activity_opt.is_some() {
// No routine entry for this phase — clear stale activity
commands.entity(entity).remove::<ActivityState>();
}
}
// --- 2. Mood: simplified stress-based derivation ---
let (stress, threshold) = tolerance_opt
.map(|t| (t.current_stress, t.threshold))
.unwrap_or((0, 50)); // Default: no stress, moderate threshold
let new_mood = derive_background_mood(stress, threshold);
if mood_state.mood != new_mood {
mood_state.mood = new_mood;
mood_state.changed_tick = tick;
}
// --- 3. Relationships: trust drift toward 0 (baseline) ---
if let Some(mut rels) = rels_opt {
for rel in &mut rels.entries {
if rel.trust_level > 0 {
rel.trust_level -= 1;
} else if rel.trust_level < 0 {
rel.trust_level += 1;
}
}
}
// --- 4. Job: performance drift from contentment ---
if let Some(mut job) = job_opt {
let contentment = contentment_opt.map(|c| c.level).unwrap_or(0);
job.score = drift_job_performance(job.score, contentment);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::types::StableId;
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::routine::ActivityState;
use crate::npc::{
Contentment, DailyRoutine, JobPerformance, Npc, Relationship, RelationshipKind,
Relationships, RoutineEntry, ToleranceThreshold,
};
use crate::simulation::movement::TilePosition;
use crate::simulation::tier::{ActiveSim, BackgroundSim};
use crate::simulation::time::{DayPhase, SimulationTime, TICKS_PER_GAME_MINUTE};
use bevy_ecs::world::World;
// --- derive_background_mood ---
#[test]
fn background_mood_hostile_at_threshold() {
assert_eq!(derive_background_mood(50, 50), NpcMood::Hostile);
}
#[test]
fn background_mood_hostile_above_threshold() {
assert_eq!(derive_background_mood(80, 50), NpcMood::Hostile);
}
#[test]
fn background_mood_anxious_at_60_percent() {
// 60% of threshold=100 is 60. stress=60 → Anxious (60*100 >= 100*60)
assert_eq!(derive_background_mood(60, 100), NpcMood::Anxious);
}
#[test]
fn background_mood_anxious_boundary_below_threshold() {
// threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000)
assert_eq!(derive_background_mood(30, 50), NpcMood::Anxious);
}
#[test]
fn background_mood_content_low_stress() {
// stress=10 < 20 → Content (not hostile, not anxious)
assert_eq!(derive_background_mood(10, 50), NpcMood::Content);
}
#[test]
fn background_mood_neutral_moderate_stress() {
// stress=25, threshold=50: not hostile, not anxious (25*100=2500 < 50*60=3000),
// not content (25 >= 20) → Neutral
assert_eq!(derive_background_mood(25, 50), NpcMood::Neutral);
}
#[test]
fn background_mood_zero_threshold_is_hostile() {
// stress=0 >= threshold=0 → Hostile
assert_eq!(derive_background_mood(0, 0), NpcMood::Hostile);
}
#[test]
fn background_mood_zero_stress_moderate_threshold_is_content() {
// stress=0 < 20 → Content
assert_eq!(derive_background_mood(0, 50), NpcMood::Content);
}
// --- drift_job_performance ---
#[test]
fn job_drift_up_when_positive_contentment() {
assert_eq!(drift_job_performance(50, 10), 51);
}
#[test]
fn job_drift_down_when_negative_contentment() {
assert_eq!(drift_job_performance(50, -10), 49);
}
#[test]
fn job_drift_unchanged_at_zero_contentment() {
assert_eq!(drift_job_performance(50, 0), 50);
}
#[test]
fn job_drift_clamps_at_100() {
assert_eq!(drift_job_performance(100, 5), 100);
}
#[test]
fn job_drift_clamps_at_0() {
assert_eq!(drift_job_performance(0, -5), 0);
}
// --- background_tick system integration tests ---
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world
}
fn run_system(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(background_tick);
schedule.run(world);
}
fn make_routine(phase: DayPhase, activity: &str) -> DailyRoutine {
DailyRoutine {
entries: vec![RoutineEntry {
phase,
location: TilePosition::new(5, 5, 0),
activity: activity.to_string(),
}],
description: "Test routine".into(),
}
}
// --- Tick gating ---
#[test]
fn does_not_fire_on_non_minute_tick() {
let mut world = setup_world();
// Set tick to 5 — not a multiple of TICKS_PER_GAME_MINUTE
world.resource_mut::<SimulationTime>().tick = 5;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold {
current_stress: 60,
threshold: 50,
},
))
.id();
run_system(&mut world);
// Mood should NOT have updated (Hostile would fire if it ran)
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Neutral, "should not fire at tick=5");
}
#[test]
fn fires_at_tick_zero() {
let mut world = setup_world();
// tick=0 is 0 % 10 == 0, so it fires
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold {
current_stress: 60,
threshold: 50,
},
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Hostile);
}
#[test]
fn fires_at_tick_multiple_of_ticks_per_game_minute() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 5;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold {
current_stress: 60,
threshold: 50,
},
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Hostile);
}
// --- Active-tier NPCs not processed ---
#[test]
fn active_npcs_not_processed() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// ActiveSim NPC — must NOT be processed by background_tick
let npc = world
.spawn((
Npc,
ActiveSim,
MoodState {
mood: NpcMood::Warm,
changed_tick: 0,
},
ToleranceThreshold {
current_stress: 60,
threshold: 50,
},
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(
mood.mood,
NpcMood::Warm,
"ActiveSim NPC must not be updated by background_tick"
);
}
// --- Mood state machine ---
#[test]
fn mood_hostile_when_stress_at_threshold() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold {
current_stress: 50,
threshold: 50,
},
))
.id();
run_system(&mut world);
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Hostile);
}
#[test]
fn mood_content_when_no_tolerance() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// No ToleranceThreshold → defaults (0, 50) → Content (0 < 20)
let npc = world.spawn((Npc, BackgroundSim, MoodState::default())).id();
run_system(&mut world);
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Content);
}
#[test]
fn mood_records_changed_tick() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 3;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState {
mood: NpcMood::Warm,
changed_tick: 0,
},
ToleranceThreshold {
current_stress: 55,
threshold: 50,
},
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Hostile);
assert_eq!(mood.changed_tick, TICKS_PER_GAME_MINUTE * 3);
}
#[test]
fn mood_unchanged_tick_not_updated() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// Already Content, will derive Content → no change to changed_tick
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState {
mood: NpcMood::Content,
changed_tick: 42,
},
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Content);
assert_eq!(
mood.changed_tick, 42,
"changed_tick must not update when mood unchanged"
);
}
// --- Schedule state machine ---
#[test]
fn schedule_sets_activity_state_for_current_phase() {
let mut world = setup_world();
// tick=0 → DayPhase::Morning
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
make_routine(DayPhase::Morning, "Work"),
))
.id();
run_system(&mut world);
let activity = world.get::<ActivityState>(npc).unwrap();
assert_eq!(activity.activity, "Work");
assert_eq!(activity.phase, DayPhase::Morning);
}
#[test]
fn schedule_no_activity_when_no_routine_entry_for_phase() {
let mut world = setup_world();
// tick=0 → Morning, but routine only has Afternoon
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
make_routine(DayPhase::Afternoon, "Meeting"),
))
.id();
run_system(&mut world);
// No ActivityState should be inserted (Morning has no entry)
assert!(world.get::<ActivityState>(npc).is_none());
}
#[test]
fn schedule_preserves_correct_activity_state() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0; // Morning
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
make_routine(DayPhase::Morning, "Work"),
// Already has correct ActivityState — should not be re-inserted
ActivityState {
activity: "Work".into(),
phase: DayPhase::Morning,
started_tick: 0,
},
))
.id();
run_system(&mut world);
let activity = world.get::<ActivityState>(npc).unwrap();
assert_eq!(activity.activity, "Work");
}
// --- Relationships state machine ---
#[test]
fn relationships_positive_trust_drifts_toward_zero() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
Relationships {
entries: vec![Relationship {
target_id: StableId(1),
kind: RelationshipKind::Friend,
trust_level: 5,
history: vec![],
}],
},
))
.id();
run_system(&mut world);
let rels = world.get::<Relationships>(npc).unwrap();
assert_eq!(
rels.entries[0].trust_level, 4,
"positive trust decrements by 1"
);
}
#[test]
fn relationships_negative_trust_drifts_toward_zero() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
Relationships {
entries: vec![Relationship {
target_id: StableId(2),
kind: RelationshipKind::Rival,
trust_level: -4,
history: vec![],
}],
},
))
.id();
run_system(&mut world);
let rels = world.get::<Relationships>(npc).unwrap();
assert_eq!(
rels.entries[0].trust_level, -3,
"negative trust increments by 1"
);
}
#[test]
fn relationships_zero_trust_stays_zero() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
Relationships {
entries: vec![Relationship {
target_id: StableId(3),
kind: RelationshipKind::Colleague,
trust_level: 0,
history: vec![],
}],
},
))
.id();
run_system(&mut world);
let rels = world.get::<Relationships>(npc).unwrap();
assert_eq!(rels.entries[0].trust_level, 0, "zero trust unchanged");
}
// --- Job state machine ---
#[test]
fn job_performance_rises_with_positive_contentment() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
JobPerformance { score: 60 },
Contentment { level: 20 },
))
.id();
run_system(&mut world);
let job = world.get::<JobPerformance>(npc).unwrap();
assert_eq!(job.score, 61);
}
#[test]
fn job_performance_falls_with_negative_contentment() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
JobPerformance { score: 60 },
Contentment { level: -15 },
))
.id();
run_system(&mut world);
let job = world.get::<JobPerformance>(npc).unwrap();
assert_eq!(job.score, 59);
}
#[test]
fn job_performance_unchanged_at_zero_contentment() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
JobPerformance { score: 50 },
Contentment { level: 0 },
))
.id();
run_system(&mut world);
let job = world.get::<JobPerformance>(npc).unwrap();
assert_eq!(job.score, 50);
}
#[test]
fn job_performance_without_component_no_panic() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// No JobPerformance — system must not panic
let _npc = world.spawn((Npc, BackgroundSim, MoodState::default())).id();
run_system(&mut world); // must not panic
}
// --- Multiple NPCs independent ---
#[test]
fn multiple_background_npcs_processed_independently() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let calm = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold {
current_stress: 5,
threshold: 50,
},
))
.id();
let hostile = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold {
current_stress: 55,
threshold: 50,
},
))
.id();
run_system(&mut world);
assert_eq!(world.get::<MoodState>(calm).unwrap().mood, NpcMood::Content);
assert_eq!(
world.get::<MoodState>(hostile).unwrap().mood,
NpcMood::Hostile
);
}
}