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>
568 lines
18 KiB
Rust
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);
|
|
}
|
|
}
|