//! 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` — 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, mut commands: Commands, mut query: Query< ( Entity, Option<&ActivityState>, &mut MoodState, Option<&mut Relationships>, Option<&ToleranceThreshold>, Option<&DailyRoutine>, Option<&Contentment>, Option<&mut JobPerformance>, ), (With, With), >, ) { // 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::(); } } // --- 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::(); 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::().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::(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::().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::(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::().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::(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::().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::(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::().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::(npc).unwrap().mood, NpcMood::Hostile); } #[test] fn mood_content_when_no_tolerance() { let mut world = setup_world(); world.resource_mut::().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::(npc).unwrap().mood, NpcMood::Content); } #[test] fn mood_records_changed_tick() { let mut world = setup_world(); world.resource_mut::().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::(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::().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::(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::().tick = 0; let npc = world .spawn(( Npc, BackgroundSim, MoodState::default(), make_routine(DayPhase::Morning, "Work"), )) .id(); run_system(&mut world); let activity = world.get::(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::().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::(npc).is_none()); } #[test] fn schedule_preserves_correct_activity_state() { let mut world = setup_world(); world.resource_mut::().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::(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::().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::(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::().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::(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::().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::(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::().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::(npc).unwrap(); assert_eq!(job.score, 61); } #[test] fn job_performance_falls_with_negative_contentment() { let mut world = setup_world(); world.resource_mut::().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::(npc).unwrap(); assert_eq!(job.score, 59); } #[test] fn job_performance_unchanged_at_zero_contentment() { let mut world = setup_world(); world.resource_mut::().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::(npc).unwrap(); assert_eq!(job.score, 50); } #[test] fn job_performance_without_component_no_panic() { let mut world = setup_world(); world.resource_mut::().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::().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::(calm).unwrap().mood, NpcMood::Content); assert_eq!( world.get::(hostile).unwrap().mood, NpcMood::Hostile ); } }