Files
settled-reach/server/src/npc/tolerance.rs
T
jpmschweitzerandClaude Opus 4.6 9a20e4dea9 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 <noreply@anthropic.com>
2026-02-21 14:39:08 +01:00

568 lines
18 KiB
Rust

//! 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<ToleranceBreachEvent>,
}
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<ToleranceBreachEvent> {
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<SimulationTime>,
mut queue: ResMut<ToleranceBreachEventQueue>,
npcs: Query<
(Entity, &ToleranceThreshold, Option<&ToleranceBreached>),
(With<Npc>, With<ActiveSim>),
>,
) {
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::<ToleranceBreached>();
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::<SimulationTime>();
world.init_resource::<ToleranceBreachEventQueue>();
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::<ToleranceBreachEventQueue>();
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::<ToleranceBreachEventQueue>();
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::<ToleranceBreachEventQueue>();
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::<ToleranceBreachEventQueue>();
assert!(queue.is_empty());
}
// -----------------------------------------------------------------------
// Breach event tick field
// -----------------------------------------------------------------------
#[test]
fn breach_event_records_current_tick() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 42;
world.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 60,
threshold: 50,
},
));
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
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::<ToleranceBreachEventQueue>();
assert!(
queue.is_empty(),
"no duplicate event when already in breach state"
);
// Marker should still be present
assert!(world.get::<ToleranceBreached>(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::<ToleranceBreached>(entity).is_none(),
"marker should be removed when stress drops below threshold"
);
// No new breach event on resolution
let queue = world.resource::<ToleranceBreachEventQueue>();
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::<ToleranceBreachEventQueue>();
assert_eq!(
queue.len(),
1,
"new breach event after recovery and re-breach"
);
assert!(world.get::<ToleranceBreached>(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::<ToleranceBreachEventQueue>();
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::<ToleranceBreachEventQueue>();
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::<ToleranceBreachEventQueue>();
assert_eq!(queue.len(), 2, "two NPCs should breach independently");
let breached_entities: Vec<Entity> = 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::<ToleranceBreachEventQueue>();
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::<ToleranceBreachEventQueue>();
assert_eq!(queue.len(), 1, "stress=0 >= threshold=0 must breach");
assert!(world.get::<ToleranceBreached>(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::<ToleranceBreachEventQueue>();
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::<ToleranceBreachEventQueue>();
assert_eq!(queue.len(), 1);
assert_eq!(queue.events[0].entity, active);
}
}