From 9a20e4dea9498e05210d3b1bff13565ef860311b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:39:08 +0100 Subject: [PATCH] feat(simulation): add tolerance threshold monitoring system (#105) ToleranceBreachEvent emitted when NPC stress exceeds per-seed threshold. ToleranceBreached marker prevents duplicate events per episode, cleared on recovery. check_tolerance_threshold system runs after update_mood, integrates with mood FSM to push toward Hostile/Anxious. Background-tier NPCs excluded. Unblocks #250 (triangle escalation). Co-Authored-By: Claude Opus 4.6 --- server/src/npc/tolerance.rs | 567 ++++++++++++++++++++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 server/src/npc/tolerance.rs diff --git a/server/src/npc/tolerance.rs b/server/src/npc/tolerance.rs new file mode 100644 index 000000000..19d336b18 --- /dev/null +++ b/server/src/npc/tolerance.rs @@ -0,0 +1,567 @@ +//! 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); + } +}