//! Tolerance threshold monitoring system (#105). //! //! Monitors Active-tier NPCs each tick. When `current_stress >= threshold`, //! emits a `ToleranceBreachEvent` (once per crossing) and marks the NPC with //! `ToleranceBreached`. When stress drops below threshold, clears the marker. //! //! ## Integration //! - `npc::mood::update_mood` reads `ToleranceThreshold` directly to derive //! `Hostile`/`Anxious` mood — no duplication needed here. //! - Future #250 (Triangle escalation) consumes `ToleranceBreachEventQueue`. //! //! All arithmetic is integer-only (D-010 determinism). use bevy_ecs::prelude::*; use crate::npc::{Npc, ToleranceThreshold}; use crate::simulation::tier::ActiveSim; use crate::simulation::time::SimulationTime; // --------------------------------------------------------------------------- // Event and queue // --------------------------------------------------------------------------- /// Emitted when an NPC's stress first crosses their tolerance threshold. /// /// Produced once per crossing transition (not every tick while breached). /// Consumed by future #250 (Triangle escalation system). /// /// Threshold value comes from per-NPC generation seed — not hardcoded. #[derive(Debug, Clone)] pub struct ToleranceBreachEvent { /// The NPC entity that exceeded their threshold. pub entity: Entity, /// Tick when the breach occurred. pub tick: u64, /// Stress level at the moment of breach. pub stress_at_breach: i16, /// The NPC's tolerance threshold (per-NPC, seeded at generation). pub threshold: i16, } /// Resource: queue of tolerance breach events. /// /// Drained once per tick by consumers. Multiple consumers may drain the queue /// in sequence — the first consumer gets all events, subsequent consumers get /// nothing (caller's responsibility to coordinate if multiple consumers exist). #[derive(Resource, Default)] pub struct ToleranceBreachEventQueue { pub events: Vec, } impl ToleranceBreachEventQueue { /// Push a breach event into the queue. pub fn push(&mut self, event: ToleranceBreachEvent) { self.events.push(event); } /// Drain all pending events. pub fn drain(&mut self) -> Vec { std::mem::take(&mut self.events) } /// Number of pending events. pub fn len(&self) -> usize { self.events.len() } /// Whether the queue is empty. pub fn is_empty(&self) -> bool { self.events.is_empty() } } // --------------------------------------------------------------------------- // Marker component // --------------------------------------------------------------------------- /// Marker: NPC is currently in a tolerance breach state (stress >= threshold). /// /// Inserted by `check_tolerance_threshold` on first breach. /// Removed when stress drops below threshold. /// Guards against duplicate events on consecutive breach ticks. #[derive(Component, Debug, Clone, Copy)] pub struct ToleranceBreached; // --------------------------------------------------------------------------- // System // --------------------------------------------------------------------------- /// System: monitor tolerance thresholds for Active-tier NPCs. /// /// Runs once per tick. For each NPC with `ToleranceThreshold`: /// - `stress >= threshold` and not yet marked: insert `ToleranceBreached`, emit event. /// - `stress < threshold` and currently marked: remove `ToleranceBreached`. /// - Already in correct state: no action. /// /// Mood shift (Hostile/Anxious) is delegated to `npc::mood::update_mood`, /// which reads `ToleranceThreshold` each tick. No duplication here. /// /// Future behavioral reactions (confrontation-initiation, avoidance): /// other systems should read `ToleranceBreachEventQueue` or query for /// `ToleranceBreached` components. /// /// Scoped to `ActiveSim` — Background-tier NPCs are not monitored per /// tick (D-026). Background NPCs retain their last-known mood state. pub fn check_tolerance_threshold( mut commands: Commands, time: Res, mut queue: ResMut, npcs: Query< (Entity, &ToleranceThreshold, Option<&ToleranceBreached>), (With, With), >, ) { let tick = time.tick; for (entity, tolerance, breach_opt) in npcs.iter() { let is_breached = tolerance.current_stress >= tolerance.threshold; let was_breached = breach_opt.is_some(); match (is_breached, was_breached) { (true, false) => { // Transition: OK → Breached. Insert marker and emit event. commands.entity(entity).insert(ToleranceBreached); queue.push(ToleranceBreachEvent { entity, tick, stress_at_breach: tolerance.current_stress, threshold: tolerance.threshold, }); tracing::debug!( "Entity {:?}: tolerance breached (stress={}, threshold={}) at tick {}", entity, tolerance.current_stress, tolerance.threshold, tick ); } (false, true) => { // Transition: Breached → OK. Remove marker. commands.entity(entity).remove::(); tracing::debug!( "Entity {:?}: tolerance breach resolved (stress={}, threshold={}) at tick {}", entity, tolerance.current_stress, tolerance.threshold, tick ); } // (true, true): still breached — no action, no duplicate event. // (false, false): still fine — no action. _ => {} } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::npc::{Npc, ToleranceThreshold}; use crate::simulation::tier::{ActiveSim, BackgroundSim}; use crate::simulation::time::SimulationTime; use bevy_ecs::world::World; fn setup_world() -> World { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world } fn run_system(world: &mut World) { let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_tolerance_threshold); schedule.run(world); world.flush(); } // ----------------------------------------------------------------------- // Breach event emission // ----------------------------------------------------------------------- #[test] fn breach_event_emitted_when_stress_equals_threshold() { let mut world = setup_world(); world.spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 50, threshold: 50, }, )); run_system(&mut world); let queue = world.resource::(); assert_eq!(queue.len(), 1, "should emit exactly one breach event"); assert_eq!(queue.events[0].stress_at_breach, 50); assert_eq!(queue.events[0].threshold, 50); } #[test] fn breach_event_emitted_when_stress_above_threshold() { let mut world = setup_world(); world.spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 80, threshold: 50, }, )); run_system(&mut world); let queue = world.resource::(); assert_eq!(queue.len(), 1); assert_eq!(queue.events[0].stress_at_breach, 80); assert_eq!(queue.events[0].threshold, 50); } #[test] fn no_breach_event_when_stress_below_threshold() { let mut world = setup_world(); world.spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 49, threshold: 50, }, )); run_system(&mut world); let queue = world.resource::(); assert!(queue.is_empty(), "stress=49 < threshold=50 should not breach"); } #[test] fn no_breach_event_at_zero_stress() { let mut world = setup_world(); world.spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 0, threshold: 50, }, )); run_system(&mut world); let queue = world.resource::(); assert!(queue.is_empty()); } // ----------------------------------------------------------------------- // Breach event tick field // ----------------------------------------------------------------------- #[test] fn breach_event_records_current_tick() { let mut world = setup_world(); world.resource_mut::().tick = 42; world.spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 60, threshold: 50, }, )); run_system(&mut world); let queue = world.resource::(); assert_eq!(queue.events[0].tick, 42); } // ----------------------------------------------------------------------- // No duplicate events (ToleranceBreached marker) // ----------------------------------------------------------------------- #[test] fn no_duplicate_event_on_consecutive_breach_ticks() { let mut world = setup_world(); let entity = world .spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 60, threshold: 50, }, ToleranceBreached, // Already marked — already breached )) .id(); run_system(&mut world); // Still breached but marker was already present → no new event let queue = world.resource::(); assert!( queue.is_empty(), "no duplicate event when already in breach state" ); // Marker should still be present assert!(world.get::(entity).is_some()); } // ----------------------------------------------------------------------- // Breach resolution // ----------------------------------------------------------------------- #[test] fn breach_marker_cleared_when_stress_drops_below_threshold() { let mut world = setup_world(); let entity = world .spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 30, // Dropped below threshold threshold: 50, }, ToleranceBreached, // Was breached )) .id(); run_system(&mut world); assert!( world.get::(entity).is_none(), "marker should be removed when stress drops below threshold" ); // No new breach event on resolution let queue = world.resource::(); assert!(queue.is_empty(), "resolution should not emit a new event"); } #[test] fn breach_event_emitted_again_after_recovery() { let mut world = setup_world(); // Start: stress resolved (marker absent, stress below threshold) let entity = world .spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 60, // Re-breached threshold: 50, }, // No ToleranceBreached — recovery had cleared it )) .id(); run_system(&mut world); // Should emit new event after recovery let queue = world.resource::(); assert_eq!( queue.len(), 1, "new breach event after recovery and re-breach" ); assert!(world.get::(entity).is_some()); } // ----------------------------------------------------------------------- // Tier scoping // ----------------------------------------------------------------------- #[test] fn background_npc_not_monitored() { let mut world = setup_world(); world.spawn(( Npc, BackgroundSim, // Background tier — excluded ToleranceThreshold { current_stress: 100, threshold: 50, }, )); run_system(&mut world); let queue = world.resource::(); assert!( queue.is_empty(), "Background-tier NPCs must not generate breach events" ); } #[test] fn active_npc_without_tolerance_threshold_not_matched() { // Should not crash — query requires ToleranceThreshold, so entity is simply skipped let mut world = setup_world(); world.spawn(( Npc, ActiveSim, // No ToleranceThreshold component )); run_system(&mut world); // must not panic let queue = world.resource::(); assert!(queue.is_empty()); } // ----------------------------------------------------------------------- // Multiple NPCs // ----------------------------------------------------------------------- #[test] fn multiple_npcs_breached_independently() { let mut world = setup_world(); // NPC 1: will breach (stress >= threshold) let e1 = world .spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 60, threshold: 50, }, )) .id(); // NPC 2: fine (stress < threshold) let e2 = world .spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 30, threshold: 50, }, )) .id(); // NPC 3: will breach (different threshold — per-NPC, not hardcoded) let e3 = world .spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 90, threshold: 80, }, )) .id(); run_system(&mut world); let queue = world.resource::(); assert_eq!(queue.len(), 2, "two NPCs should breach independently"); let breached_entities: Vec = queue.events.iter().map(|e| e.entity).collect(); assert!(breached_entities.contains(&e1)); assert!(!breached_entities.contains(&e2)); assert!(breached_entities.contains(&e3)); } #[test] fn breach_event_entity_field_matches_spawned_entity() { let mut world = setup_world(); let entity = world .spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 50, threshold: 50, }, )) .id(); run_system(&mut world); let queue = world.resource::(); assert_eq!(queue.events[0].entity, entity); } // ----------------------------------------------------------------------- // Edge cases // ----------------------------------------------------------------------- #[test] fn zero_threshold_with_zero_stress_breaches() { // 0 >= 0 → breach. Edge case: entity with threshold=0 is always breached. let mut world = setup_world(); let entity = world .spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 0, threshold: 0, }, )) .id(); run_system(&mut world); let queue = world.resource::(); assert_eq!(queue.len(), 1, "stress=0 >= threshold=0 must breach"); assert!(world.get::(entity).is_some()); } #[test] fn threshold_at_100_only_breaches_at_100() { let mut world = setup_world(); world.spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 99, threshold: 100, }, )); run_system(&mut world); let queue = world.resource::(); assert!(queue.is_empty(), "stress=99 < threshold=100 should not breach"); } #[test] fn mixed_active_and_background_npcs() { let mut world = setup_world(); // Active NPC — should breach let active = world .spawn(( Npc, ActiveSim, ToleranceThreshold { current_stress: 60, threshold: 50, }, )) .id(); // Background NPC — should NOT breach world.spawn(( Npc, BackgroundSim, ToleranceThreshold { current_stress: 100, threshold: 50, }, )); run_system(&mut world); let queue = world.resource::(); assert_eq!(queue.len(), 1); assert_eq!(queue.events[0].entity, active); } }