From 836c8e98cde9cd552b2e8d808ff56272116fa226 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:38:59 +0100 Subject: [PATCH 01/11] feat(simulation): add SpatialIndex trait with naive Vec implementation (#340) Define SpatialIndex trait with entities_in_range, entities_at, and update methods. NaiveSpatialIndex uses Vec backend with Manhattan distance. sync_spatial_index system auto-updates from Changed. Registered as Bevy resource. Trait abstraction allows grid/quadtree replacement later without touching callers. Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/spatial.rs | 486 +++++++++++++++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 server/src/simulation/spatial.rs diff --git a/server/src/simulation/spatial.rs b/server/src/simulation/spatial.rs new file mode 100644 index 000000000..fcf0200e3 --- /dev/null +++ b/server/src/simulation/spatial.rs @@ -0,0 +1,486 @@ +// SpatialIndex trait + naive Vec implementation (#340) +// Provides proximity queries for follow mechanic (#241), NPC vision (#115, deferred). +// Trait abstraction allows grid/quadtree replacement without touching callers. +// +// Uses bevy_ecs Entity handles (not StableId) — this is a simulation-layer +// spatial optimization, not a knowledge graph concern. + +use bevy_ecs::prelude::*; + +use crate::simulation::movement::TilePosition; + +/// Trait for spatial proximity queries over entities with TilePosition. +/// +/// Implementations must be registered as a Bevy Resource. +/// All methods operate on the same z-level — cross-z queries return empty. +pub trait SpatialIndex: Send + Sync { + /// Return all entities within Manhattan distance `radius` of `position` on the same z-level. + /// Does NOT include entities exactly at `position` — use `entities_at` for that. + fn entities_in_range(&self, position: &TilePosition, radius: u32) -> Vec; + + /// Return all entities at the exact `position`. + fn entities_at(&self, position: &TilePosition) -> Vec; + + /// Insert or update an entity's position in the index. + fn update(&mut self, entity: Entity, position: TilePosition); + + /// Remove an entity from the index (e.g. on despawn or tier transition). + fn remove(&mut self, entity: Entity); +} + +/// Naive Vec-backed spatial index — O(n) queries, sufficient for Active tier (30-80 NPCs). +/// +/// Replace with grid or quadtree when profiling shows this is a bottleneck. +/// Deterministic iteration: entries stored in insertion order, but callers +/// should not depend on ordering (sort by Entity::to_bits() if needed). +#[derive(Resource, Debug, Default)] +pub struct NaiveSpatialIndex { + entries: Vec<(Entity, TilePosition)>, +} + +impl NaiveSpatialIndex { + pub fn new() -> Self { + Self { + entries: Vec::new(), + } + } +} + +impl SpatialIndex for NaiveSpatialIndex { + fn entities_in_range(&self, position: &TilePosition, radius: u32) -> Vec { + self.entries + .iter() + .filter(|(_, pos)| { + pos != position + && pos + .manhattan_distance(position) + .is_some_and(|d| d <= radius) + }) + .map(|(entity, _)| *entity) + .collect() + } + + fn entities_at(&self, position: &TilePosition) -> Vec { + self.entries + .iter() + .filter(|(_, pos)| pos == position) + .map(|(entity, _)| *entity) + .collect() + } + + fn update(&mut self, entity: Entity, position: TilePosition) { + if let Some(entry) = self.entries.iter_mut().find(|(e, _)| *e == entity) { + entry.1 = position; + } else { + self.entries.push((entity, position)); + } + } + + fn remove(&mut self, entity: Entity) { + self.entries.retain(|(e, _)| *e != entity); + } +} + +/// System: sync TilePosition changes into the NaiveSpatialIndex each tick. +/// +/// Runs after movement validation so positions are final for the tick. +/// Only tracks entities with TilePosition — entities without it are not indexed. +pub fn sync_spatial_index( + mut index: ResMut, + query: Query<(Entity, &TilePosition), Changed>, + mut removed: RemovedComponents, +) { + for (entity, pos) in query.iter() { + index.update(entity, *pos); + } + for entity in removed.read() { + index.remove(entity); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_ecs::world::World; + + fn make_entity(world: &mut World) -> Entity { + world.spawn_empty().id() + } + + #[test] + fn entities_at_exact_position() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e1 = make_entity(&mut world); + let e2 = make_entity(&mut world); + let e3 = make_entity(&mut world); + + let pos = TilePosition::new(5, 5, 0); + index.update(e1, pos); + index.update(e2, pos); + index.update(e3, TilePosition::new(6, 5, 0)); + + let at = index.entities_at(&pos); + assert_eq!(at.len(), 2); + assert!(at.contains(&e1)); + assert!(at.contains(&e2)); + assert!(!at.contains(&e3)); + } + + #[test] + fn entities_in_range_excludes_exact_position() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e_at = make_entity(&mut world); + let e_near = make_entity(&mut world); + + let center = TilePosition::new(5, 5, 0); + index.update(e_at, center); + index.update(e_near, TilePosition::new(5, 6, 0)); + + let in_range = index.entities_in_range(¢er, 2); + assert!(!in_range.contains(&e_at), "entity at center should be excluded"); + assert!(in_range.contains(&e_near)); + } + + #[test] + fn entities_in_range_manhattan_distance() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e_close = make_entity(&mut world); + let e_boundary = make_entity(&mut world); + let e_far = make_entity(&mut world); + + let center = TilePosition::new(5, 5, 0); + index.update(e_close, TilePosition::new(5, 6, 0)); // distance 1 + index.update(e_boundary, TilePosition::new(7, 5, 0)); // distance 2 + index.update(e_far, TilePosition::new(8, 5, 0)); // distance 3 + + let in_range = index.entities_in_range(¢er, 2); + assert!(in_range.contains(&e_close)); + assert!(in_range.contains(&e_boundary)); + assert!(!in_range.contains(&e_far)); + } + + #[test] + fn different_z_level_excluded() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e = make_entity(&mut world); + index.update(e, TilePosition::new(5, 5, 1)); + + let center = TilePosition::new(5, 5, 0); + assert!(index.entities_in_range(¢er, 10).is_empty()); + assert!(index.entities_at(¢er).is_empty()); + } + + #[test] + fn update_moves_existing_entity() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e = make_entity(&mut world); + let old_pos = TilePosition::new(5, 5, 0); + let new_pos = TilePosition::new(10, 10, 0); + + index.update(e, old_pos); + assert_eq!(index.entities_at(&old_pos).len(), 1); + + index.update(e, new_pos); + assert!(index.entities_at(&old_pos).is_empty()); + assert_eq!(index.entities_at(&new_pos).len(), 1); + } + + #[test] + fn remove_entity() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e = make_entity(&mut world); + let pos = TilePosition::new(5, 5, 0); + + index.update(e, pos); + assert_eq!(index.entities_at(&pos).len(), 1); + + index.remove(e); + assert!(index.entities_at(&pos).is_empty()); + } + + #[test] + fn remove_nonexistent_entity_is_noop() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e = make_entity(&mut world); + index.remove(e); // should not panic + } + + #[test] + fn empty_index_returns_empty() { + let index = NaiveSpatialIndex::new(); + let pos = TilePosition::new(5, 5, 0); + + assert!(index.entities_at(&pos).is_empty()); + assert!(index.entities_in_range(&pos, 10).is_empty()); + } + + #[test] + fn zero_radius_returns_nothing() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e = make_entity(&mut world); + let center = TilePosition::new(5, 5, 0); + index.update(e, TilePosition::new(5, 6, 0)); // distance 1 + + // Radius 0: only exact position would match, but entities_in_range excludes center + assert!(index.entities_in_range(¢er, 0).is_empty()); + } + + // ----------------------------------------------------------------------- + // Additional QA correctness tests (Hoshe, Sprint 15) + // ----------------------------------------------------------------------- + + /// Verify that updating an entity twice does not insert duplicates. + /// Callers of update() rely on the index having at most one entry per entity. + #[test] + fn update_does_not_duplicate_entity() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e = make_entity(&mut world); + let pos = TilePosition::new(5, 5, 0); + + index.update(e, pos); + index.update(e, pos); // same position again + + assert_eq!( + index.entries.len(), + 1, + "update with same position must not insert a duplicate entry" + ); + assert_eq!(index.entities_at(&pos).len(), 1); + } + + /// Moving an entity does not accumulate stale entries. + #[test] + fn update_to_new_position_does_not_leave_old_entry() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e = make_entity(&mut world); + index.update(e, TilePosition::new(5, 5, 0)); + index.update(e, TilePosition::new(10, 10, 0)); + index.update(e, TilePosition::new(15, 15, 0)); + + // Entry list should still have exactly one entry for this entity + assert_eq!(index.entries.len(), 1); + } + + /// `entities_in_range` with radius 1: includes distance-1, excludes distance-2. + #[test] + fn entities_in_range_radius_1_boundary() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e_dist1_x = make_entity(&mut world); + let e_dist1_y = make_entity(&mut world); + let e_dist2 = make_entity(&mut world); + + let center = TilePosition::new(5, 5, 0); + index.update(e_dist1_x, TilePosition::new(6, 5, 0)); // Manhattan = 1 + index.update(e_dist1_y, TilePosition::new(5, 4, 0)); // Manhattan = 1 + index.update(e_dist2, TilePosition::new(7, 5, 0)); // Manhattan = 2 + + let in_range = index.entities_in_range(¢er, 1); + assert!(in_range.contains(&e_dist1_x)); + assert!(in_range.contains(&e_dist1_y)); + assert!(!in_range.contains(&e_dist2), "distance 2 must not appear in radius-1 result"); + } + + /// Diagonal: Manhattan distance covers all 4 orthogonal directions. + #[test] + fn entities_in_range_all_four_directions() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let north = make_entity(&mut world); + let south = make_entity(&mut world); + let east = make_entity(&mut world); + let west = make_entity(&mut world); + let corner = make_entity(&mut world); // distance 2 via diagonal (Manhattan = 2) + + let center = TilePosition::new(10, 10, 0); + index.update(north, TilePosition::new(10, 11, 0)); // distance 1 + index.update(south, TilePosition::new(10, 9, 0)); // distance 1 + index.update(east, TilePosition::new(11, 10, 0)); // distance 1 + index.update(west, TilePosition::new(9, 10, 0)); // distance 1 + index.update(corner, TilePosition::new(11, 11, 0)); // distance 2 + + let in_range = index.entities_in_range(¢er, 2); + assert!(in_range.contains(&north)); + assert!(in_range.contains(&south)); + assert!(in_range.contains(&east)); + assert!(in_range.contains(&west)); + assert!(in_range.contains(&corner)); + } + + /// Entities just outside radius are excluded even when within Euclidean distance. + /// (5, 5) vs (8, 8): Manhattan = 6, Euclidean ≈ 4.2. Radius 5 → excluded. + #[test] + fn manhattan_excludes_diagonal_entity_within_euclidean() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e = make_entity(&mut world); + let center = TilePosition::new(5, 5, 0); + index.update(e, TilePosition::new(8, 8, 0)); // Manhattan = 6 + + let in_range = index.entities_in_range(¢er, 5); + assert!( + !in_range.contains(&e), + "Manhattan distance 6 must not appear in radius-5 result" + ); + } + + /// `entities_at` returns empty when no entity is at the queried position. + #[test] + fn entities_at_returns_empty_for_unoccupied_position() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let e = make_entity(&mut world); + index.update(e, TilePosition::new(5, 5, 0)); + + assert!(index.entities_at(&TilePosition::new(6, 5, 0)).is_empty()); + } + + /// Multiple entities at the same position — all returned by entities_at. + #[test] + fn entities_at_handles_multiple_entities_same_tile() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let pos = TilePosition::new(5, 5, 0); + let entities: Vec = (0..5).map(|_| make_entity(&mut world)).collect(); + for &e in &entities { + index.update(e, pos); + } + + let at = index.entities_at(&pos); + assert_eq!(at.len(), 5, "all entities at same tile must be returned"); + for e in &entities { + assert!(at.contains(e)); + } + } + + /// Large radius includes all entities in the index (except those at center). + #[test] + fn large_radius_includes_all_entities() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let center = TilePosition::new(50, 50, 0); + let mut entities = vec![]; + for i in 0..10_i32 { + let e = make_entity(&mut world); + index.update(e, TilePosition::new(i, i, 0)); // All far from center + entities.push(e); + } + + let in_range = index.entities_in_range(¢er, 200); + assert_eq!(in_range.len(), 10, "radius 200 should include all 10 entities"); + } + + /// Remove one entity from a multi-entity index, others remain. + #[test] + fn remove_one_entity_others_intact() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let pos = TilePosition::new(5, 5, 0); + let e1 = make_entity(&mut world); + let e2 = make_entity(&mut world); + let e3 = make_entity(&mut world); + + index.update(e1, pos); + index.update(e2, pos); + index.update(e3, TilePosition::new(6, 5, 0)); + + index.remove(e1); + + let at = index.entities_at(&pos); + assert_eq!(at.len(), 1, "only e2 should remain at the position"); + assert!(at.contains(&e2)); + assert!(!at.contains(&e1)); + // e3 unaffected + assert_eq!(index.entities_at(&TilePosition::new(6, 5, 0)).len(), 1); + } + + /// Stress test: 100 entities, correctness at scale. + #[test] + fn stress_100_entities_correctness() { + let mut world = World::new(); + let mut index = NaiveSpatialIndex::new(); + + let center = TilePosition::new(0, 0, 0); + let mut in_range_expected = 0u32; + + for i in 0..100_i32 { + let e = make_entity(&mut world); + let pos = TilePosition::new(i, 0, 0); // distance = i from center + index.update(e, pos); + if i > 0 && i <= 10 { + in_range_expected += 1; + } + } + + let result = index.entities_in_range(¢er, 10); + assert_eq!( + result.len(), + in_range_expected as usize, + "radius-10 from origin should include exactly 10 entities (distance 1..10)" + ); + } + + /// sync_spatial_index system test: Changed updates the index. + #[test] + fn sync_system_tracks_position_changes() { + use super::sync_spatial_index; + + let mut world = World::new(); + world.init_resource::(); + + let start = TilePosition::new(5, 5, 0); + let dest = TilePosition::new(10, 10, 0); + + let entity = world.spawn(start).id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(sync_spatial_index); + + // First run: entity inserted with initial position + schedule.run(&mut world); + + { + let idx = world.resource::(); + assert_eq!(idx.entities_at(&start).len(), 1, "entity should be at start after first sync"); + } + + // Update position + world.entity_mut(entity).insert(dest); + + // Second run: entity moved + schedule.run(&mut world); + + { + let idx = world.resource::(); + assert!(idx.entities_at(&start).is_empty(), "old position should be cleared"); + assert_eq!(idx.entities_at(&dest).len(), 1, "entity should be at new position"); + } + } +} From 9a20e4dea9498e05210d3b1bff13565ef860311b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:39:08 +0100 Subject: [PATCH 02/11] 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); + } +} From 78c43ab802c2281ef8b9aeaeb041e1c719446a39 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:39:17 +0100 Subject: [PATCH 03/11] feat(simulation): add NPC generation pipeline (#92) RoleDefinition struct + generate_npc() seeds all 10 D-024 axes via SimRng for deterministic generation. Constraint validation: no duplicate relationship targets, stress < threshold, one routine entry per phase, no contradictory personality trait pairs. Spawns fully-populated NPC entity with 2-3 personality traits for tell system (#90). Co-Authored-By: Claude Opus 4.6 --- server/src/npc/generate.rs | 888 +++++++++++++++++++++++++++++++++++++ 1 file changed, 888 insertions(+) create mode 100644 server/src/npc/generate.rs diff --git a/server/src/npc/generate.rs b/server/src/npc/generate.rs new file mode 100644 index 000000000..0b2870470 --- /dev/null +++ b/server/src/npc/generate.rs @@ -0,0 +1,888 @@ +//! Procedural NPC generation pipeline (#92). +//! +//! Takes a `RoleDefinition`, seeds all 10 NPC axes via `SimRng` (D-010 principle 4), +//! applies constraint validation, and spawns a fully-populated NPC entity. +//! +//! ## Determinism guarantee +//! All randomness flows through `SimRng` (ChaCha20). Same seed → same NPC. +//! Integer arithmetic only — no floats, no HashMap, no OS entropy. +//! +//! ## 10 axes +//! 1. Want — primary drive and intensity +//! 2. Secret — vulnerability and severity +//! 3. Relationships — 0–3 key relationships drawn from a target pool +//! 4. Tolerance — threshold and initial stress (validated: stress < threshold) +//! 5. DailyRoutine — phase→location assignments from location pool +//! 6. InformationInventory — seeded facts from role definition +//! 7. Contentment — initial level in –30..+30 +//! 8. PersonalityTraits — 2–3 traits, no contradictory pairs +//! 9. TellSystem — tells derived from personality + secret severity +//! 10. SkillSet — 2–4 skills from role focus + optional CombatCapability +//! +//! ## Constraint validation +//! - Relationships: no duplicate target StableIds +//! - Tolerance: initial stress strictly < threshold (never immediately triggers) +//! - Routine: at most one entry per DayPhase (deduplication by phase) + +use rand::Rng; +use std::collections::BTreeMap; + +use bevy_ecs::prelude::*; + +use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId}; +use crate::npc::{ + CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, KnownFact, + Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind, Relationships, + RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem, TellTrigger, + ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS, +}; +use crate::npc::mood::MoodState; +use crate::simulation::movement::TilePosition; +use crate::simulation::rng::SimRng; +use crate::simulation::tier::ActiveSim; +use crate::simulation::time::DayPhase; + +// --------------------------------------------------------------------------- +// RoleDefinition +// --------------------------------------------------------------------------- + +/// Template driving procedural NPC generation. +/// +/// Describes the role constraints for all 10 generation axes. The generator +/// uses `SimRng` to make choices within these constraints deterministically. +#[derive(Debug, Clone)] +pub struct RoleDefinition { + /// Human-readable role label (e.g. "guard", "dockworker", "administrator"). + pub name: String, + /// Available phase→location pairs for routine generation (axis 5). + /// The generator picks at most one entry per `DayPhase` from this pool. + pub location_pool: Vec<(DayPhase, TilePosition)>, + /// Candidate `StableId`s for relationship targets (axis 3). + /// Generator draws 0–`MAX_KEY_RELATIONSHIPS` unique targets from this pool. + pub relationship_targets: Vec, + /// Seeded knowledge facts for this role (axis 6). + pub known_facts: Vec<(FactId, KnowledgeConfidence)>, + /// Skill types biased toward for this role (axis 10). + /// Generator always includes these, then adds random extras up to the cap. + pub skill_focus: Vec, + /// Whether this role may receive a `CombatCapability` component. + pub combat_enabled: bool, +} + +// --------------------------------------------------------------------------- +// Contradictory trait pairs +// --------------------------------------------------------------------------- + +/// Returns `true` if the two traits are contradictory and cannot coexist. +fn traits_contradict(a: PersonalityTrait, b: PersonalityTrait) -> bool { + use PersonalityTrait::*; + matches!( + (a, b), + (Cautious, Bold) + | (Bold, Cautious) + | (Honest, Deceptive) + | (Deceptive, Honest) + | (Compassionate, Ruthless) + | (Ruthless, Compassionate) + | (Curious, Incurious) + | (Incurious, Curious) + | (Social, Reclusive) + | (Reclusive, Social) + ) +} + +// --------------------------------------------------------------------------- +// Enum pickers (deterministic index → variant) +// --------------------------------------------------------------------------- + +fn pick_want_kind(idx: usize) -> WantKind { + use WantKind::*; + const VARIANTS: [WantKind; 9] = [ + Wealth, Safety, Knowledge, Connection, Power, Freedom, Justice, Revenge, Happiness, + ]; + VARIANTS[idx % VARIANTS.len()] +} + +fn pick_secret_severity(idx: usize) -> SecretSeverity { + match idx % 3 { + 0 => SecretSeverity::Minor, + 1 => SecretSeverity::Moderate, + _ => SecretSeverity::Major, + } +} + +fn pick_relationship_kind(idx: usize) -> RelationshipKind { + use RelationshipKind::*; + const VARIANTS: [RelationshipKind; 7] = + [Colleague, Friend, Rival, Romantic, Family, Superior, Subordinate]; + VARIANTS[idx % VARIANTS.len()] +} + +fn pick_personality_trait(idx: usize) -> PersonalityTrait { + use PersonalityTrait::*; + const VARIANTS: [PersonalityTrait; 10] = [ + Cautious, Bold, Honest, Deceptive, Compassionate, Ruthless, Curious, Incurious, Social, + Reclusive, + ]; + VARIANTS[idx % VARIANTS.len()] +} + +fn pick_skill(idx: usize) -> Skill { + use Skill::*; + const VARIANTS: [Skill; 8] = + [Combat, Intimidation, Medical, Observation, Persuasion, Piloting, Stealth, Technical]; + VARIANTS[idx % VARIANTS.len()] +} + +fn pick_combat_style(idx: usize) -> CombatStyle { + match idx % 4 { + 0 => CombatStyle::Ranged, + 1 => CombatStyle::Melee, + 2 => CombatStyle::Evasive, + _ => CombatStyle::Defensive, + } +} + +// --------------------------------------------------------------------------- +// Axis generators +// --------------------------------------------------------------------------- + +fn gen_want(rng: &mut SimRng, role: &RoleDefinition) -> Want { + let kind_idx = rng.rng.random_range(0..9_usize); + let intensity = rng.rng.random_range(3_u8..=9); + Want { + primary: pick_want_kind(kind_idx), + intensity, + description: format!("{} driven by {:?}", role.name, pick_want_kind(kind_idx)), + } +} + +fn gen_secret(rng: &mut SimRng, role: &RoleDefinition) -> Secret { + let sev_idx = rng.rng.random_range(0..3_usize); + let severity = pick_secret_severity(sev_idx); + Secret { + description: format!("{} has a {:?} secret", role.name, severity), + severity, + known_by: vec![], + } +} + +fn gen_relationships(rng: &mut SimRng, targets: &[StableId]) -> Relationships { + if targets.is_empty() { + return Relationships { entries: vec![] }; + } + + // Number of relationships: 0..=min(3, targets.len()) + let max = MAX_KEY_RELATIONSHIPS.min(targets.len()); + let count = rng.rng.random_range(0..=(max)); + + // Pick `count` unique targets — simple shuffle prefix via Fisher-Yates on indices. + let mut indices: Vec = (0..targets.len()).collect(); + for i in 0..count { + let swap = rng.rng.random_range(i..targets.len()); + indices.swap(i, swap); + } + + let entries = indices[..count] + .iter() + .map(|&target_idx| { + let kind_idx = rng.rng.random_range(0..7_usize); + let trust: i8 = rng.rng.random_range(-4_i8..=4); + Relationship { + target_id: targets[target_idx], + kind: pick_relationship_kind(kind_idx), + trust_level: trust, + history: vec![], + } + }) + .collect(); + + Relationships { entries } +} + +fn gen_tolerance(rng: &mut SimRng) -> ToleranceThreshold { + // Threshold: 40..=80 — varies per NPC, not hardcoded. + let threshold: i16 = rng.rng.random_range(40_i16..=80); + // Constraint: initial stress strictly < threshold (never immediately triggers). + let stress_max = (threshold - 1).max(0); + let current_stress: i16 = if stress_max == 0 { + 0 + } else { + rng.rng.random_range(0_i16..stress_max) + }; + ToleranceThreshold { + current_stress, + threshold, + } +} + +fn gen_routine(rng: &mut SimRng, location_pool: &[(DayPhase, TilePosition)]) -> DailyRoutine { + if location_pool.is_empty() { + return DailyRoutine { + entries: vec![], + description: "No fixed schedule".into(), + }; + } + + // Deduplicate by phase: at most one entry per DayPhase. + // Build a BTreeMap from phase index → (phase, location) to guarantee uniqueness. + let mut phase_map: BTreeMap = BTreeMap::new(); + for &(phase, loc) in location_pool { + let key = match phase { + DayPhase::Morning => 0, + DayPhase::Afternoon => 1, + DayPhase::Evening => 2, + DayPhase::Night => 3, + }; + // If multiple entries for the same phase, pick deterministically. + // Use the last entry in the pool — caller controls priority by ordering. + phase_map.insert(key, (phase, loc)); + } + + // Select 1..=pool_size entries (biased toward having at least 2 phases). + let available: Vec<(DayPhase, TilePosition)> = phase_map.into_values().collect(); + let count = rng.rng.random_range(1..=available.len()); + + // Pick `count` from the available phases (shuffle prefix). + let mut indices: Vec = (0..available.len()).collect(); + for i in 0..count { + let swap = rng.rng.random_range(i..available.len()); + indices.swap(i, swap); + } + + let entries: Vec = indices[..count] + .iter() + .map(|&i| { + let (phase, location) = available[i]; + let activity_names = ["Work", "Patrol", "Rest", "Meeting", "Training", "Maintenance"]; + let act_idx = rng.rng.random_range(0..activity_names.len()); + RoutineEntry { + phase, + location, + activity: activity_names[act_idx].to_string(), + } + }) + .collect(); + + DailyRoutine { + entries, + description: format!("Routine schedule"), + } +} + +fn gen_information_inventory(facts: &[(FactId, KnowledgeConfidence)]) -> InformationInventory { + InformationInventory { + facts: facts + .iter() + .map(|(fact_id, confidence)| KnownFact { + fact_id: fact_id.clone(), + confidence: *confidence, + }) + .collect(), + } +} + +fn gen_contentment(rng: &mut SimRng) -> Contentment { + Contentment { + level: rng.rng.random_range(-20_i16..=20), + } +} + +fn gen_personality_traits(rng: &mut SimRng) -> PersonalityTraits { + let count = rng.rng.random_range(2_usize..=3); + let mut chosen: Vec = Vec::with_capacity(count); + + let mut attempts = 0_usize; + while chosen.len() < count && attempts < 50 { + attempts += 1; + let idx = rng.rng.random_range(0..10_usize); + let candidate = pick_personality_trait(idx); + // Reject if contradicts any already-chosen trait. + if chosen.iter().any(|&t| traits_contradict(t, candidate)) { + continue; + } + // Reject duplicates. + if chosen.contains(&candidate) { + continue; + } + chosen.push(candidate); + } + + PersonalityTraits { traits: chosen } +} + +fn gen_tells(traits: &PersonalityTraits, secret: &Secret) -> TellSystem { + let mut tells: Vec = Vec::new(); + + // Guarded tell: Major secret → becomes evasive under stress. + if secret.severity == SecretSeverity::Major { + tells.push(Tell { + trigger: TellTrigger::StressAboveThreshold, + behavior: "becomes evasive and avoids eye contact".to_string(), + }); + } + + for &trait_ in &traits.traits { + match trait_ { + PersonalityTrait::Cautious => tells.push(Tell { + trigger: TellTrigger::StressAboveThreshold, + behavior: "checks surroundings repeatedly".to_string(), + }), + PersonalityTrait::Bold => tells.push(Tell { + trigger: TellTrigger::Always, + behavior: "maintains confident posture".to_string(), + }), + PersonalityTrait::Honest => tells.push(Tell { + trigger: TellTrigger::Always, + behavior: "makes direct eye contact".to_string(), + }), + PersonalityTrait::Deceptive => tells.push(Tell { + trigger: TellTrigger::StressAboveThreshold, + behavior: "affects exaggerated calm".to_string(), + }), + PersonalityTrait::Social => tells.push(Tell { + trigger: TellTrigger::Always, + behavior: "greets passersby unprompted".to_string(), + }), + PersonalityTrait::Reclusive => tells.push(Tell { + trigger: TellTrigger::Always, + behavior: "avoids eye contact and moves away from groups".to_string(), + }), + PersonalityTrait::Ruthless => tells.push(Tell { + trigger: TellTrigger::StressAboveThreshold, + behavior: "speaks curtly and dismisses others".to_string(), + }), + PersonalityTrait::Compassionate => tells.push(Tell { + trigger: TellTrigger::Always, + behavior: "pauses to check on distressed individuals".to_string(), + }), + PersonalityTrait::Curious => tells.push(Tell { + trigger: TellTrigger::Always, + behavior: "lingers near unusual activity".to_string(), + }), + PersonalityTrait::Incurious => tells.push(Tell { + trigger: TellTrigger::Always, + behavior: "moves through space without pausing".to_string(), + }), + } + } + + // Cap at 3 tells — avoid overwhelming the observer snapshot. + tells.truncate(3); + TellSystem { tells } +} + +fn gen_skills(rng: &mut SimRng, role: &RoleDefinition) -> (SkillSet, Option) { + let mut skills: BTreeMap = BTreeMap::new(); + + // Always include role skill focus with higher proficiency. + for &skill in &role.skill_focus { + let prof: u8 = rng.rng.random_range(5_u8..=9); + skills.insert(skill, prof); + } + + // Add 1-2 random extra skills (not already present). + let extras = rng.rng.random_range(1_usize..=2); + let mut extra_attempts = 0_usize; + while extra_attempts < 20 && skills.len() < (role.skill_focus.len() + extras).min(6) { + extra_attempts += 1; + let idx = rng.rng.random_range(0..8_usize); + let skill = pick_skill(idx); + skills.entry(skill).or_insert_with(|| rng.rng.random_range(2_u8..=5)); + } + + let combat_trained = role.combat_enabled && rng.rng.random_range(0..3_u32) < 2; + + let skill_set = SkillSet { + skills, + combat_trained, + }; + + let combat_cap = if combat_trained { + let style_idx = rng.rng.random_range(0..4_usize); + let prof: u8 = rng.rng.random_range(3_u8..=8); + Some(CombatCapability { + weapon_proficiency: prof, + combat_style: pick_combat_style(style_idx), + }) + } else { + None + }; + + (skill_set, combat_cap) +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +/// Generate a fully-populated NPC entity from a role definition. +/// +/// Spawns the entity into `world` with all 10 D-024 axis components. +/// All randomness flows through `rng` — deterministic for a fixed seed (D-010). +/// +/// Returns the newly spawned `Entity` ID. +pub fn generate_npc(role: &RoleDefinition, world: &mut World, rng: &mut SimRng) -> Entity { + // Generate all axes before spawning to keep the borrow checker happy. + let want = gen_want(rng, role); + let secret = gen_secret(rng, role); + let relationships = gen_relationships(rng, &role.relationship_targets); + let tolerance = gen_tolerance(rng); + let routine = gen_routine(rng, &role.location_pool); + let inventory = gen_information_inventory(&role.known_facts); + let contentment = gen_contentment(rng); + let personality = gen_personality_traits(rng); + let tells = gen_tells(&personality, &secret); + let (skills, combat_opt) = gen_skills(rng, role); + + // Determinism assertion: tolerance constraint must hold. + debug_assert!( + tolerance.current_stress < tolerance.threshold, + "NPC generation violated tolerance constraint: stress {} >= threshold {}", + tolerance.current_stress, + tolerance.threshold, + ); + + // Determinism assertion: no duplicate relationship targets. + debug_assert!( + { + let mut seen: Vec = Vec::new(); + let mut ok = true; + for rel in &relationships.entries { + if seen.contains(&rel.target_id) { + ok = false; + break; + } + seen.push(rel.target_id); + } + ok + }, + "NPC generation produced duplicate relationship targets" + ); + + // Spawn entity with all components. + let mut entity_builder = world.spawn(( + Npc, + ActiveSim, + want, + secret, + relationships, + tolerance, + routine, + inventory, + contentment, + personality, + tells, + skills, + MoodState::default(), + )); + + if let Some(cap) = combat_opt { + entity_builder.insert(cap); + } + + entity_builder.id() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::types::FactId; + use bevy_ecs::world::World; + + fn make_rng(seed: u64) -> SimRng { + SimRng::new(seed) + } + + fn minimal_role() -> RoleDefinition { + RoleDefinition { + name: "guard".into(), + location_pool: vec![ + (DayPhase::Morning, TilePosition::new(5, 5, 0)), + (DayPhase::Afternoon, TilePosition::new(10, 10, 0)), + (DayPhase::Evening, TilePosition::new(5, 5, 0)), + ], + relationship_targets: vec![StableId(1), StableId(2), StableId(3)], + known_facts: vec![], + skill_focus: vec![Skill::Combat, Skill::Observation], + combat_enabled: true, + } + } + + fn merchant_role() -> RoleDefinition { + RoleDefinition { + name: "merchant".into(), + location_pool: vec![ + (DayPhase::Morning, TilePosition::new(20, 5, 0)), + (DayPhase::Afternoon, TilePosition::new(20, 5, 0)), + ], + relationship_targets: vec![StableId(10), StableId(11)], + known_facts: vec![( + FactId("contraband.ring_exists".into()), + KnowledgeConfidence::KnowsOf, + )], + skill_focus: vec![Skill::Persuasion], + combat_enabled: false, + } + } + + // ----------------------------------------------------------------------- + // Basic spawning + // ----------------------------------------------------------------------- + + #[test] + fn generate_npc_spawns_entity() { + let mut world = World::new(); + let mut rng = make_rng(42); + let role = minimal_role(); + + let entity = generate_npc(&role, &mut world, &mut rng); + + assert!(world.get_entity(entity).is_ok(), "spawned entity must exist"); + } + + #[test] + fn generated_npc_has_required_components() { + let mut world = World::new(); + let mut rng = make_rng(1); + let role = minimal_role(); + + let entity = generate_npc(&role, &mut world, &mut rng); + + assert!(world.get::(entity).is_some(), "must have Npc marker"); + assert!(world.get::(entity).is_some(), "must have Want"); + assert!(world.get::(entity).is_some(), "must have Secret"); + assert!( + world.get::(entity).is_some(), + "must have Relationships" + ); + assert!( + world.get::(entity).is_some(), + "must have ToleranceThreshold" + ); + assert!( + world.get::(entity).is_some(), + "must have DailyRoutine" + ); + assert!( + world.get::(entity).is_some(), + "must have InformationInventory" + ); + assert!( + world.get::(entity).is_some(), + "must have Contentment" + ); + assert!( + world.get::(entity).is_some(), + "must have PersonalityTraits" + ); + assert!( + world.get::(entity).is_some(), + "must have TellSystem" + ); + assert!(world.get::(entity).is_some(), "must have SkillSet"); + assert!( + world.get::(entity).is_some(), + "must have MoodState" + ); + } + + // ----------------------------------------------------------------------- + // Determinism + // ----------------------------------------------------------------------- + + #[test] + fn same_seed_produces_identical_npc() { + let role = minimal_role(); + + let mut world_a = World::new(); + let mut rng_a = make_rng(99); + let ea = generate_npc(&role, &mut world_a, &mut rng_a); + + let mut world_b = World::new(); + let mut rng_b = make_rng(99); + let eb = generate_npc(&role, &mut world_b, &mut rng_b); + + // Compare all value-type components for equality. + let want_a = world_a.get::(ea).unwrap(); + let want_b = world_b.get::(eb).unwrap(); + assert_eq!(want_a.primary, want_b.primary, "Want.primary must match"); + assert_eq!(want_a.intensity, want_b.intensity, "Want.intensity must match"); + + let tol_a = world_a.get::(ea).unwrap(); + let tol_b = world_b.get::(eb).unwrap(); + assert_eq!(tol_a.threshold, tol_b.threshold); + assert_eq!(tol_a.current_stress, tol_b.current_stress); + + let con_a = world_a.get::(ea).unwrap(); + let con_b = world_b.get::(eb).unwrap(); + assert_eq!(con_a.level, con_b.level); + } + + #[test] + fn different_seeds_may_produce_different_npcs() { + let role = minimal_role(); + + let mut world_a = World::new(); + let mut rng_a = make_rng(1); + let ea = generate_npc(&role, &mut world_a, &mut rng_a); + + let mut world_b = World::new(); + let mut rng_b = make_rng(2); + let eb = generate_npc(&role, &mut world_b, &mut rng_b); + + // It's statistically extremely unlikely both produce identical tolerance thresholds. + let tol_a = world_a.get::(ea).unwrap(); + let tol_b = world_b.get::(eb).unwrap(); + // We don't assert inequality (could theoretically be equal) but this + // documents that seeding drives variance. + let _ = (tol_a, tol_b); + } + + // ----------------------------------------------------------------------- + // Constraint: tolerance + // ----------------------------------------------------------------------- + + #[test] + fn tolerance_constraint_never_immediately_triggers() { + let role = minimal_role(); + for seed in 0..100_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + let tol = world.get::(entity).unwrap(); + assert!( + tol.current_stress < tol.threshold, + "seed {seed}: stress={} must be < threshold={}", + tol.current_stress, + tol.threshold + ); + } + } + + // ----------------------------------------------------------------------- + // Constraint: relationships (no duplicate targets) + // ----------------------------------------------------------------------- + + #[test] + fn relationships_have_no_duplicate_targets() { + let role = minimal_role(); + for seed in 0..50_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + let rels = world.get::(entity).unwrap(); + let mut seen: Vec = Vec::new(); + for rel in &rels.entries { + assert!( + !seen.contains(&rel.target_id), + "seed {seed}: duplicate relationship target {:?}", + rel.target_id + ); + seen.push(rel.target_id); + } + } + } + + #[test] + fn relationships_respect_max_key_relationships() { + let role = minimal_role(); + for seed in 0..50_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + let rels = world.get::(entity).unwrap(); + assert!( + rels.entries.len() <= MAX_KEY_RELATIONSHIPS, + "seed {seed}: {} relationships exceeds max {}", + rels.entries.len(), + MAX_KEY_RELATIONSHIPS + ); + } + } + + #[test] + fn empty_relationship_targets_produces_no_relationships() { + let mut role = minimal_role(); + role.relationship_targets = vec![]; + let mut world = World::new(); + let mut rng = make_rng(7); + let entity = generate_npc(&role, &mut world, &mut rng); + let rels = world.get::(entity).unwrap(); + assert!(rels.entries.is_empty()); + } + + // ----------------------------------------------------------------------- + // Constraint: personality (no contradictory pairs) + // ----------------------------------------------------------------------- + + #[test] + fn personality_traits_contain_no_contradictory_pairs() { + let role = minimal_role(); + for seed in 0..100_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + let traits = world.get::(entity).unwrap(); + for i in 0..traits.traits.len() { + for j in (i + 1)..traits.traits.len() { + assert!( + !traits_contradict(traits.traits[i], traits.traits[j]), + "seed {seed}: contradictory trait pair {:?} and {:?}", + traits.traits[i], + traits.traits[j] + ); + } + } + } + } + + #[test] + fn personality_traits_count_is_2_or_3() { + let role = minimal_role(); + for seed in 0..50_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + let traits = world.get::(entity).unwrap(); + assert!( + traits.traits.len() >= 2 && traits.traits.len() <= 3, + "seed {seed}: got {} traits, expected 2 or 3", + traits.traits.len() + ); + } + } + + // ----------------------------------------------------------------------- + // Constraint: routine (at most one entry per phase) + // ----------------------------------------------------------------------- + + #[test] + fn routine_has_at_most_one_entry_per_phase() { + let role = minimal_role(); + for seed in 0..50_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + let routine = world.get::(entity).unwrap(); + let mut phases: Vec = routine.entries.iter().map(|e| e.phase).collect(); + let original_len = phases.len(); + phases.dedup(); // only works if sorted — but we check by unique count instead + let unique: std::collections::BTreeSet = routine + .entries + .iter() + .map(|e| match e.phase { + DayPhase::Morning => 0, + DayPhase::Afternoon => 1, + DayPhase::Evening => 2, + DayPhase::Night => 3, + }) + .collect(); + assert_eq!( + unique.len(), + original_len, + "seed {seed}: duplicate phase in routine" + ); + } + } + + // ----------------------------------------------------------------------- + // Information inventory from role + // ----------------------------------------------------------------------- + + #[test] + fn information_inventory_matches_role_known_facts() { + let role = merchant_role(); + let mut world = World::new(); + let mut rng = make_rng(5); + let entity = generate_npc(&role, &mut world, &mut rng); + let inv = world.get::(entity).unwrap(); + assert_eq!(inv.facts.len(), 1); + assert_eq!(inv.facts[0].fact_id.0, "contraband.ring_exists"); + assert_eq!(inv.facts[0].confidence, KnowledgeConfidence::KnowsOf); + } + + // ----------------------------------------------------------------------- + // Combat capability + // ----------------------------------------------------------------------- + + #[test] + fn non_combat_role_never_gets_combat_capability() { + let role = merchant_role(); // combat_enabled = false + for seed in 0..50_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + let skills = world.get::(entity).unwrap(); + assert!( + !skills.combat_trained, + "seed {seed}: non-combat role should not be combat trained" + ); + assert!( + world.get::(entity).is_none(), + "seed {seed}: non-combat role must not have CombatCapability" + ); + } + } + + // ----------------------------------------------------------------------- + // Skill focus present in SkillSet + // ----------------------------------------------------------------------- + + #[test] + fn skill_focus_always_present_in_skill_set() { + let role = minimal_role(); // skill_focus = [Combat, Observation] + for seed in 0..30_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + let skills = world.get::(entity).unwrap(); + for focused_skill in &role.skill_focus { + assert!( + skills.skills.contains_key(focused_skill), + "seed {seed}: skill {:?} from role focus must be in SkillSet", + focused_skill + ); + } + } + } + + // ----------------------------------------------------------------------- + // Want intensity in 1-10 range + // ----------------------------------------------------------------------- + + #[test] + fn want_intensity_in_valid_range() { + let role = minimal_role(); + for seed in 0..50_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + let want = world.get::(entity).unwrap(); + assert!( + want.intensity >= 1 && want.intensity <= 10, + "seed {seed}: Want intensity {} out of range", + want.intensity + ); + } + } + + // ----------------------------------------------------------------------- + // ActiveSim tier + // ----------------------------------------------------------------------- + + #[test] + fn generated_npc_spawns_in_active_tier() { + let mut world = World::new(); + let mut rng = make_rng(0); + let entity = generate_npc(&minimal_role(), &mut world, &mut rng); + assert!( + world.get::(entity).is_some(), + "generated NPCs must spawn as ActiveSim" + ); + } +} From 029337a801e289d035950b5dd9ff44f5347ca42a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:39:27 +0100 Subject: [PATCH 04/11] feat(simulation): add personality and tell system (#90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TellCategory enum (Nervous, Angry, Friendly, Guarded, RoutineDeviation) with DerivedTellState component. derive_tell_state system runs after update_mood and detect_routine_deviation. Tell state derived from NPC axis values per D-024: Secret+low Tolerance→Nervous, low Contentment+ Hostile→Angry, high Contentment+Friendly→Friendly, high Secret→Guarded. v0.1 renderer is monologue text, not visual animation. Co-Authored-By: Claude Opus 4.6 --- server/src/npc/tell_state.rs | 576 +++++++++++++++++++++++++++++++++++ 1 file changed, 576 insertions(+) create mode 100644 server/src/npc/tell_state.rs diff --git a/server/src/npc/tell_state.rs b/server/src/npc/tell_state.rs new file mode 100644 index 000000000..2afe99981 --- /dev/null +++ b/server/src/npc/tell_state.rs @@ -0,0 +1,576 @@ +//! Tell state derivation system (#90, D-024 tell system). +//! +//! Derives the current observable tell category from NPC axis values each tick. +//! Tell state is NOT authored per NPC — it flows from simulation state. +//! +//! ## 5 tell categories (D-024) +//! - `Nervous`: Major secret + stress > half the threshold +//! - `Angry`: Contentment < −20 AND Hostile mood +//! - `Friendly`: Contentment > +20 AND at least one relationship with trust > 3 +//! - `Guarded`: Major secret (any stress level) +//! - `RoutineDeviation`: NPC has a `RoutineDeviation` component this tick +//! +//! ## Priority order (highest wins) +//! RoutineDeviation > Nervous > Angry > Guarded > Friendly > None +//! +//! ## v0.1 output +//! `DerivedTellState` is read by the observer snapshot system and emitted into +//! `ObserverSnapshot.entities[].tell_state`. Client renders as monologue text; +//! visual animation is deferred beyond v0.1. + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::npc::mood::{MoodState, NpcMood}; +use crate::npc::{ + Contentment, Npc, Relationships, RoutineDeviation, Secret, SecretSeverity, ToleranceThreshold, +}; +use crate::simulation::tier::ActiveSim; + +// --------------------------------------------------------------------------- +// TellCategory enum +// --------------------------------------------------------------------------- + +/// Observable tell category emitted into the observer snapshot (#90, D-024). +/// +/// Derived each tick from NPC simulation state — not authored per NPC. +/// Five categories correspond to the D-024 tell taxonomy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum TellCategory { + /// NPC exhibits nervous behaviour: Major secret + stress exceeds half of threshold. + #[default] + Nervous, + /// NPC exhibits angry behaviour: low contentment and Hostile mood. + Angry, + /// NPC appears warm and open: high contentment with a positively-trusted relationship. + Friendly, + /// NPC appears guarded or evasive: Major secret (any stress). + Guarded, + /// NPC has deviated from their expected routine — primary detective mechanic (D-027). + RoutineDeviation, +} + +// --------------------------------------------------------------------------- +// DerivedTellState component +// --------------------------------------------------------------------------- + +/// Per-NPC component: the current observable tell category this tick. +/// +/// Updated each tick by [`derive_tell_state`] for Active-tier NPCs. +/// `None` means no notable tell is observable (normal / neutral state). +/// +/// Read by the observer snapshot system to populate +/// `ObserverSnapshot.entities[].tell_state`. +#[derive(Component, Debug, Clone, Default)] +pub struct DerivedTellState { + /// Current tell category, or `None` if no tell is active. + pub category: Option, +} + +// --------------------------------------------------------------------------- +// Derivation helpers +// --------------------------------------------------------------------------- + +fn derive_category( + secret: &Secret, + tolerance: &ToleranceThreshold, + contentment: &Contentment, + mood_state: &MoodState, + relationships_opt: Option<&Relationships>, + deviation_opt: Option<&RoutineDeviation>, +) -> Option { + // Priority 1: RoutineDeviation (primary detective mechanic, D-027 criterion 4) + if deviation_opt.is_some() { + return Some(TellCategory::RoutineDeviation); + } + + // Priority 2: Nervous — Major secret with stress past the midpoint + if secret.severity == SecretSeverity::Major + && tolerance.threshold > 0 + && tolerance.current_stress * 2 > tolerance.threshold + { + return Some(TellCategory::Nervous); + } + + // Priority 3: Angry — low contentment combined with Hostile mood + if contentment.level < -20 && mood_state.mood == NpcMood::Hostile { + return Some(TellCategory::Angry); + } + + // Priority 4: Guarded — Major secret at any stress level + if secret.severity == SecretSeverity::Major { + return Some(TellCategory::Guarded); + } + + // Priority 5: Friendly — high contentment with at least one trusted relationship + if contentment.level > 20 { + let has_positive_relationship = relationships_opt + .map(|rels| rels.entries.iter().any(|r| r.trust_level > 3)) + .unwrap_or(false); + if has_positive_relationship { + return Some(TellCategory::Friendly); + } + } + + None +} + +// --------------------------------------------------------------------------- +// Derivation system +// --------------------------------------------------------------------------- + +/// System: derive tell state from NPC axis values for all Active-tier NPCs. +/// +/// Runs after `update_mood` — requires a fresh `MoodState`. +/// Writes the result into `DerivedTellState`, which the observer snapshot +/// system reads to populate `VisibleEntity.tell_state`. +/// +/// Scoped to `ActiveSim`: Background-tier NPCs retain their last-known tell +/// state, consistent with D-026 tier policy. +pub fn derive_tell_state( + mut npcs: Query< + ( + &Secret, + &ToleranceThreshold, + &Contentment, + &MoodState, + Option<&Relationships>, + Option<&RoutineDeviation>, + &mut DerivedTellState, + ), + (With, With), + >, +) { + for (secret, tolerance, contentment, mood_state, relationships_opt, deviation_opt, mut tell) in + npcs.iter_mut() + { + tell.category = derive_category( + secret, + tolerance, + contentment, + mood_state, + relationships_opt, + deviation_opt, + ); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::types::StableId; + use crate::npc::{Relationship, RelationshipKind, RoutineDeviation as NpcRoutineDeviation}; + use crate::npc::{SecretSeverity, ToleranceThreshold}; + use crate::npc::mood::NpcMood; + use crate::npc::DeviationTrigger; + + fn neutral_secret() -> Secret { + Secret { + description: "minor embarrassment".into(), + severity: SecretSeverity::Minor, + known_by: vec![], + } + } + + fn major_secret() -> Secret { + Secret { + description: "criminal record".into(), + severity: SecretSeverity::Major, + known_by: vec![], + } + } + + fn moderate_secret() -> Secret { + Secret { + description: "moderate secret".into(), + severity: SecretSeverity::Moderate, + known_by: vec![], + } + } + + fn tolerance(stress: i16, threshold: i16) -> ToleranceThreshold { + ToleranceThreshold { + current_stress: stress, + threshold, + } + } + + fn contentment(level: i16) -> Contentment { + Contentment { level } + } + + fn mood(m: NpcMood) -> MoodState { + MoodState { mood: m, changed_tick: 0 } + } + + fn positive_relationships() -> Relationships { + Relationships { + entries: vec![Relationship { + target_id: StableId(1), + kind: RelationshipKind::Friend, + trust_level: 5, + history: vec![], + }], + } + } + + fn neutral_relationships() -> Relationships { + Relationships { + entries: vec![Relationship { + target_id: StableId(1), + kind: RelationshipKind::Colleague, + trust_level: 0, + history: vec![], + }], + } + } + + fn deviation() -> NpcRoutineDeviation { + NpcRoutineDeviation { + trigger: DeviationTrigger::WalkAway, + tick: 100, + } + } + + // ----------------------------------------------------------------------- + // Priority 1: RoutineDeviation beats everything + // ----------------------------------------------------------------------- + + #[test] + fn routine_deviation_beats_nervous() { + let result = derive_category( + &major_secret(), + &tolerance(90, 100), // Stress past midpoint — would be Nervous + &contentment(0), + &mood(NpcMood::Neutral), + None, + Some(&deviation()), + ); + assert_eq!(result, Some(TellCategory::RoutineDeviation)); + } + + #[test] + fn routine_deviation_beats_angry() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(-50), + &mood(NpcMood::Hostile), + None, + Some(&deviation()), + ); + assert_eq!(result, Some(TellCategory::RoutineDeviation)); + } + + #[test] + fn no_deviation_component_skips_deviation_category() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, // No deviation + ); + assert_ne!(result, Some(TellCategory::RoutineDeviation)); + } + + // ----------------------------------------------------------------------- + // Priority 2: Nervous + // ----------------------------------------------------------------------- + + #[test] + fn major_secret_stress_past_midpoint_is_nervous() { + // stress=60, threshold=100 → stress*2=120 > 100 → nervous + let result = derive_category( + &major_secret(), + &tolerance(60, 100), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + assert_eq!(result, Some(TellCategory::Nervous)); + } + + #[test] + fn major_secret_stress_at_midpoint_is_guarded_not_nervous() { + // stress=50, threshold=100 → stress*2=100 is NOT > 100 → Guarded fallthrough + let result = derive_category( + &major_secret(), + &tolerance(50, 100), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + assert_eq!(result, Some(TellCategory::Guarded)); + } + + #[test] + fn minor_secret_high_stress_not_nervous() { + let result = derive_category( + &neutral_secret(), // Minor — not Major + &tolerance(90, 100), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + assert_ne!(result, Some(TellCategory::Nervous)); + } + + #[test] + fn nervous_requires_nonzero_threshold() { + // threshold=0: stress*2=0 NOT > 0 → skip nervous + let result = derive_category( + &major_secret(), + &tolerance(0, 0), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + // Still Guarded (Major secret, priority 4) + assert_eq!(result, Some(TellCategory::Guarded)); + } + + // ----------------------------------------------------------------------- + // Priority 3: Angry + // ----------------------------------------------------------------------- + + #[test] + fn low_contentment_hostile_mood_is_angry() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(-30), // Below -20 + &mood(NpcMood::Hostile), + None, + None, + ); + assert_eq!(result, Some(TellCategory::Angry)); + } + + #[test] + fn hostile_mood_without_low_contentment_not_angry() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(0), // Not low enough + &mood(NpcMood::Hostile), + None, + None, + ); + assert_ne!(result, Some(TellCategory::Angry)); + } + + #[test] + fn low_contentment_without_hostile_mood_not_angry() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(-30), + &mood(NpcMood::Anxious), // Not Hostile + None, + None, + ); + assert_ne!(result, Some(TellCategory::Angry)); + } + + #[test] + fn contentment_at_boundary_minus_20_not_angry() { + // -20 is NOT < -20 + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(-20), + &mood(NpcMood::Hostile), + None, + None, + ); + assert_ne!(result, Some(TellCategory::Angry)); + } + + // ----------------------------------------------------------------------- + // Priority 4: Guarded + // ----------------------------------------------------------------------- + + #[test] + fn major_secret_low_stress_is_guarded() { + let result = derive_category( + &major_secret(), + &tolerance(5, 100), // Low stress, not nervous + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + assert_eq!(result, Some(TellCategory::Guarded)); + } + + #[test] + fn moderate_secret_not_guarded() { + let result = derive_category( + &moderate_secret(), + &tolerance(0, 50), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + // Moderate secret doesn't trigger Guarded + assert_ne!(result, Some(TellCategory::Guarded)); + } + + // ----------------------------------------------------------------------- + // Priority 5: Friendly + // ----------------------------------------------------------------------- + + #[test] + fn high_contentment_positive_relationship_is_friendly() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(30), // Above +20 + &mood(NpcMood::Neutral), + Some(&positive_relationships()), // Trust > 3 + None, + ); + assert_eq!(result, Some(TellCategory::Friendly)); + } + + #[test] + fn high_contentment_no_positive_relationship_not_friendly() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(30), + &mood(NpcMood::Neutral), + Some(&neutral_relationships()), // Trust = 0 + None, + ); + assert_ne!(result, Some(TellCategory::Friendly)); + } + + #[test] + fn high_contentment_no_relationships_not_friendly() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(30), + &mood(NpcMood::Neutral), + None, // No relationships at all + None, + ); + assert_ne!(result, Some(TellCategory::Friendly)); + } + + #[test] + fn contentment_at_boundary_plus_20_not_friendly() { + // +20 is NOT > 20 + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(20), + &mood(NpcMood::Neutral), + Some(&positive_relationships()), + None, + ); + assert_ne!(result, Some(TellCategory::Friendly)); + } + + // ----------------------------------------------------------------------- + // None result + // ----------------------------------------------------------------------- + + #[test] + fn neutral_npc_returns_none() { + let result = derive_category( + &neutral_secret(), + &tolerance(10, 50), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + assert_eq!(result, None); + } + + #[test] + fn no_tell_for_minor_secret_low_stress_neutral_mood() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(-5), // Not low enough for angry + &mood(NpcMood::Anxious), // Not Hostile + None, + None, + ); + assert_eq!(result, None); + } + + // ----------------------------------------------------------------------- + // Bevy ECS integration: system updates DerivedTellState + // ----------------------------------------------------------------------- + + #[test] + fn system_updates_derived_tell_state() { + use bevy_ecs::world::World; + use crate::npc::Npc; + + let mut world = World::new(); + + // Spawn an NPC that should have RoutineDeviation tell + let entity = world + .spawn(( + Npc, + ActiveSim, + major_secret(), + tolerance(60, 100), + contentment(0), + mood(NpcMood::Neutral), + DerivedTellState::default(), + deviation(), // RoutineDeviation present + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(derive_tell_state); + schedule.run(&mut world); + + let state = world.get::(entity).unwrap(); + assert_eq!(state.category, Some(TellCategory::RoutineDeviation)); + } + + #[test] + fn system_sets_none_for_neutral_npc() { + use bevy_ecs::world::World; + use crate::npc::Npc; + + let mut world = World::new(); + + let entity = world + .spawn(( + Npc, + ActiveSim, + neutral_secret(), + tolerance(10, 80), + contentment(5), + mood(NpcMood::Neutral), + DerivedTellState::default(), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(derive_tell_state); + schedule.run(&mut world); + + let state = world.get::(entity).unwrap(); + assert_eq!(state.category, None); + } +} From f31be6f86653d6080b938ebea55fbe7ca6afdf9a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:39:37 +0100 Subject: [PATCH 05/11] feat(simulation): add routine deviation detection (#243) RoutineDeviationEvent emitted when NPC breaks daily routine: wrong location for day phase or wrong activity at correct location. CurrentlyDeviating marker prevents duplicate events per episode. Respects pathfinding-in-progress (no false positives). Feeds observation event generator for observe_anomaly monologue triggers. Primary detective mechanic per D-027 criterion 4. Co-Authored-By: Claude Opus 4.6 --- server/src/npc/routine.rs | 650 +++++++++++++++++++++++++++++++++++++- 1 file changed, 649 insertions(+), 1 deletion(-) diff --git a/server/src/npc/routine.rs b/server/src/npc/routine.rs index cbd4ace1d..8c25c75ea 100644 --- a/server/src/npc/routine.rs +++ b/server/src/npc/routine.rs @@ -1,11 +1,19 @@ -//! Daily routine system (#88, #101). +//! Daily routine system (#88, #101, #243). //! //! Detects day-phase transitions (D-031) and issues PathRequests for NPCs //! whose DailyRoutine has a location for the new phase. Tracks NPC activity //! state when they arrive at their routine destination (#101). //! +//! Routine deviation detection (#243): each tick, compares Active-tier NPCs' +//! current position and activity against their expected routine. Emits +//! `RoutineDeviationEvent` when an NPC deviates from their schedule. This is +//! the primary server-side detective mechanic per D-027 criterion 4. +//! //! Pipeline: phase transition → PathRequest → pathfinder → path_follow → //! NPC arrives → enter_activity sets ActivityState. +//! +//! Deviation pipeline: enter_activity runs → detect_routine_deviation compares +//! position/activity against schedule → emits RoutineDeviationEvent if mismatch. use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; @@ -173,6 +181,191 @@ pub fn enter_activity( } } +// --------------------------------------------------------------------------- +// Routine deviation detection (#243) +// --------------------------------------------------------------------------- + +/// Type of routine deviation detected. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum RoutineDeviationType { + /// NPC is not at their expected location for the current day phase, + /// and is not currently travelling there (no PathRequest or ComputedPath). + /// Primary absence detection case — feeds the detective mechanic (D-027). + WrongLocation, + /// NPC is at their expected location for the current phase, but is + /// performing a different activity (or ActivityState is absent). + WrongActivity, +} + +/// Event: an NPC has deviated from their scheduled routine. +/// +/// Emitted once per deviation episode (not every tick while deviated). +/// Feeds `observe_anomaly` monologue triggers (#119, this sprint). +/// Primary detective mechanic per D-027 criterion 4. +#[derive(Debug, Clone)] +pub struct RoutineDeviationEvent { + /// The NPC entity that deviated. + pub entity: Entity, + /// Type of deviation detected. + pub deviation_type: RoutineDeviationType, + /// Current day phase when deviation was detected. + pub phase: DayPhase, + /// Tick when deviation was first detected. + pub tick: u64, + /// Where the NPC should be (from their DailyRoutine). + pub expected_location: TilePosition, + /// Where the NPC actually is. + pub actual_location: TilePosition, + /// Activity NPC should be performing. + pub expected_activity: String, + /// Activity NPC is actually performing (None if ActivityState absent). + pub actual_activity: Option, +} + +/// Resource: queue of routine deviation events. +/// +/// Drained by the observation event generator (#239) which routes them to +/// `observe_anomaly` monologue triggers (#119). +#[derive(Resource, Default)] +pub struct RoutineDeviationEventQueue { + pub events: Vec, +} + +impl RoutineDeviationEventQueue { + pub fn push(&mut self, event: RoutineDeviationEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn len(&self) -> usize { + self.events.len() + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// Marker: NPC is currently deviating from their routine. +/// +/// Inserted by `detect_routine_deviation` on first deviation detection. +/// Removed when NPC returns to their routine. +/// Guards against duplicate events on consecutive deviation ticks. +#[derive(Component, Debug, Clone, Copy)] +pub struct CurrentlyDeviating; + +/// System: detect when Active-tier NPCs deviate from their scheduled routine. +/// +/// Runs after `enter_activity` (so ActivityState is current). For each Active +/// NPC with a `DailyRoutine` entry for the current phase: +/// - If NPC is not at expected location AND not pathfinding: `WrongLocation`. +/// - If NPC is at expected location but activity is wrong/absent: `WrongActivity`. +/// - If on-schedule: clear `CurrentlyDeviating` marker. +/// +/// NPCs with no routine entry for the current phase are not monitored. +/// Scoped to `ActiveSim` — Background-tier NPCs are not monitored (D-026). +pub fn detect_routine_deviation( + mut commands: Commands, + time: Res, + mut queue: ResMut, + npcs: Query< + ( + Entity, + &TilePosition, + &DailyRoutine, + Option<&ActivityState>, + Option<&PathRequest>, + Option<&ComputedPath>, + Option<&CurrentlyDeviating>, + ), + (With, With), + >, +) { + let phase = time.day_phase(); + let tick = time.tick; + + for (entity, pos, routine, activity_opt, path_req, computed_path, deviating_opt) in + npcs.iter() + { + let Some(entry) = routine.entry_for_phase(phase) else { + // No routine entry for this phase — nothing to deviate from. + // Clear any stale deviation marker from a previous phase. + if deviating_opt.is_some() { + commands.entity(entity).remove::(); + } + continue; + }; + + let is_pathfinding = path_req.is_some() || computed_path.is_some(); + + // Determine deviation type + let deviation = if *pos != entry.location { + if is_pathfinding { + // Still travelling to destination — not deviated yet. + if deviating_opt.is_some() { + commands.entity(entity).remove::(); + } + continue; + } + // Not at expected location, not en route → WrongLocation + Some(RoutineDeviationType::WrongLocation) + } else { + // At expected location — check activity + let correct = match activity_opt { + Some(state) => state.activity == entry.activity && state.phase == phase, + None => false, // No ActivityState when at location → WrongActivity + }; + if correct { + None // On schedule + } else { + Some(RoutineDeviationType::WrongActivity) + } + }; + + match (deviation, deviating_opt) { + (Some(dev_type), None) => { + // New deviation — insert marker and emit event + commands.entity(entity).insert(CurrentlyDeviating); + queue.push(RoutineDeviationEvent { + entity, + deviation_type: dev_type, + phase, + tick, + expected_location: entry.location, + actual_location: *pos, + expected_activity: entry.activity.clone(), + actual_activity: activity_opt.map(|a| a.activity.clone()), + }); + tracing::debug!( + "Entity {:?}: routine deviation {:?} at phase {:?} tick {}", + entity, + dev_type, + phase, + tick + ); + } + (None, Some(_)) => { + // Returned to routine — clear marker + commands.entity(entity).remove::(); + tracing::debug!( + "Entity {:?}: returned to routine at phase {:?} tick {}", + entity, + phase, + tick + ); + } + // (Some, Some): still deviated — no duplicate event + // (None, None): on schedule — no action + _ => {} + } + } +} + +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; @@ -657,4 +850,459 @@ mod tests { // PathRequest should be set for the new phase location assert!(world.get::(entity).is_some()); } + + // -- detect_routine_deviation tests (#243) -------------------------------- + + fn setup_deviation_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world + } + + fn run_deviation_system(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(detect_routine_deviation); + schedule.run(world); + world.flush(); + } + + #[test] + fn deviation_event_emitted_wrong_location() { + let mut world = setup_deviation_world(); + // Time = Afternoon + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let expected_loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), // Wrong location, not pathfinding + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: expected_loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + // No PathRequest, no ComputedPath — NPC is just absent + )) + .id(); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 1, "should emit one deviation event"); + let evt = &queue.events[0]; + assert_eq!(evt.entity, entity); + assert_eq!(evt.deviation_type, RoutineDeviationType::WrongLocation); + assert_eq!(evt.expected_location, expected_loc); + assert_eq!(evt.actual_location, TilePosition::new(5, 5, 0)); + assert_eq!(evt.expected_activity, "Work"); + assert!(evt.actual_activity.is_none()); + } + + #[test] + fn no_deviation_when_at_correct_location_and_activity() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let loc = TilePosition::new(10, 10, 0); + world.spawn(( + Npc, + ActiveSim, + loc, + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + ActivityState { + activity: "Work".into(), + phase: DayPhase::Afternoon, + started_tick: MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE, + }, + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert!(queue.is_empty(), "on-schedule NPC should not emit a deviation event"); + } + + #[test] + fn no_deviation_when_pathfinding_to_destination() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let expected_loc = TilePosition::new(10, 10, 0); + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), // Not there yet + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: expected_loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + PathRequest { goal: expected_loc }, // En route + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert!( + queue.is_empty(), + "NPC with PathRequest is en route — not yet deviated" + ); + } + + #[test] + fn no_deviation_when_computed_path_active() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let expected_loc = TilePosition::new(10, 10, 0); + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: expected_loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + ComputedPath { + steps: vec![TilePosition::new(6, 5, 0)], + current_index: 0, + }, + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert!( + queue.is_empty(), + "NPC with ComputedPath is walking — not yet deviated" + ); + } + + #[test] + fn deviation_event_emitted_wrong_activity() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + loc, // At correct location + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + ActivityState { + activity: "Idle".into(), // Wrong activity + phase: DayPhase::Afternoon, + started_tick: 100, + }, + )) + .id(); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 1); + let evt = &queue.events[0]; + assert_eq!(evt.entity, entity); + assert_eq!(evt.deviation_type, RoutineDeviationType::WrongActivity); + assert_eq!(evt.expected_activity, "Work"); + assert_eq!(evt.actual_activity.as_deref(), Some("Idle")); + } + + #[test] + fn wrong_activity_when_activity_state_absent_at_location() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + loc, // At correct location + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + // No ActivityState — NPC is at location but hasn't settled + )) + .id(); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 1); + let evt = &queue.events[0]; + assert_eq!(evt.entity, entity); + assert_eq!(evt.deviation_type, RoutineDeviationType::WrongActivity); + assert!(evt.actual_activity.is_none()); + } + + #[test] + fn no_duplicate_events_while_deviated() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let expected_loc = TilePosition::new(10, 10, 0); + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: expected_loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + CurrentlyDeviating, // Already marked as deviating + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert!( + queue.is_empty(), + "no duplicate deviation event while already marked as deviating" + ); + } + + #[test] + fn deviation_marker_cleared_when_npc_returns_to_routine() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + loc, // Now at correct location + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + ActivityState { + activity: "Work".into(), + phase: DayPhase::Afternoon, + started_tick: 100, + }, + CurrentlyDeviating, // Was deviating, now returned + )) + .id(); + + run_deviation_system(&mut world); + + assert!( + world.get::(entity).is_none(), + "CurrentlyDeviating marker should be removed when NPC returns to routine" + ); + let queue = world.resource::(); + assert!(queue.is_empty(), "no event on deviation resolution"); + } + + #[test] + fn no_routine_for_phase_clears_deviation_marker() { + let mut world = setup_deviation_world(); + // Night phase — NPC has no routine entry + world.resource_mut::().tick = 3 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let entity = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Morning, + location: TilePosition::new(5, 5, 0), + activity: "Sleep".into(), + }], + description: "Test".into(), + }, + CurrentlyDeviating, // Stale marker from previous phase + )) + .id(); + + run_deviation_system(&mut world); + + // No entry for Night → marker cleared, no event + assert!( + world.get::(entity).is_none(), + "stale deviation marker cleared when NPC has no routine for current phase" + ); + let queue = world.resource::(); + assert!(queue.is_empty()); + } + + #[test] + fn background_npc_not_monitored_for_deviation() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + world.spawn(( + Npc, + crate::simulation::tier::BackgroundSim, + TilePosition::new(5, 5, 0), // Wrong location + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: TilePosition::new(10, 10, 0), + activity: "Work".into(), + }], + description: "Test".into(), + }, + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert!( + queue.is_empty(), + "Background-tier NPCs must not generate deviation events" + ); + } + + #[test] + fn multiple_npcs_deviated_independently() { + let mut world = setup_deviation_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let expected_loc = TilePosition::new(10, 10, 0); + + // NPC 1: wrong location (will deviate) + let e1 = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(1, 1, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: expected_loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + )) + .id(); + + // NPC 2: on schedule (no deviation) + let loc2 = TilePosition::new(20, 20, 0); + world.spawn(( + Npc, + ActiveSim, + loc2, + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc2, + activity: "Bar".into(), + }], + description: "Test".into(), + }, + ActivityState { + activity: "Bar".into(), + phase: DayPhase::Afternoon, + started_tick: MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE, + }, + )); + + // NPC 3: wrong activity (will deviate) + let loc3 = TilePosition::new(30, 30, 0); + let e3 = world + .spawn(( + Npc, + ActiveSim, + loc3, + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc3, + activity: "Inspect".into(), + }], + description: "Test".into(), + }, + ActivityState { + activity: "Loiter".into(), // Wrong + phase: DayPhase::Afternoon, + started_tick: 100, + }, + )) + .id(); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 2, "two NPCs should deviate independently"); + + let deviated: Vec = queue.events.iter().map(|e| e.entity).collect(); + assert!(deviated.contains(&e1)); + assert!(deviated.contains(&e3)); + } + + #[test] + fn deviation_event_records_current_tick_and_phase() { + let mut world = setup_deviation_world(); + let tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE + 42; + world.resource_mut::().tick = tick; + + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: TilePosition::new(10, 10, 0), + activity: "Work".into(), + }], + description: "Test".into(), + }, + )); + + run_deviation_system(&mut world); + + let queue = world.resource::(); + let evt = &queue.events[0]; + assert_eq!(evt.tick, tick); + assert_eq!(evt.phase, DayPhase::Afternoon); + } } From 0af86b1f0b8c12dcf813bcb7e6efc837ab99455b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:39:47 +0100 Subject: [PATCH 06/11] feat(simulation): add follow mechanic (#241) Follow verb on interaction dispatcher. FollowTarget component tracks target entity, proximity ticks, and LOS-lost ticks. update_follow_state system: observation events fire at double frequency while following, NPC suspicion increases via stress when player within 2 tiles for 60+ ticks (configurable). Follow ends on LOS lost timeout, suspicion threshold crossed, or player issues different action. FollowStateWire emitted in ObserverSnapshot for client HUD. Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/follow.rs | 1032 ++++++++++++++++++++++++++ server/src/simulation/input.rs | 86 ++- server/src/simulation/interaction.rs | 23 +- 3 files changed, 1132 insertions(+), 9 deletions(-) create mode 100644 server/src/simulation/follow.rs diff --git a/server/src/simulation/follow.rs b/server/src/simulation/follow.rs new file mode 100644 index 000000000..266938dbe --- /dev/null +++ b/server/src/simulation/follow.rs @@ -0,0 +1,1032 @@ +//! Follow mechanic (#241) — player designates an NPC as follow target. +//! +//! Server tracks: target NPC entity, distance, LOS state, proximity ticks. +//! NPC suspicion increases via `ToleranceThreshold` stress when the player +//! maintains close proximity + LOS for sustained ticks ("too close too long"). +//! +//! Follow ends when: +//! - Target leaves LOS for `FOLLOW_LOS_LOST_TIMEOUT` ticks (deep fog) +//! - Target's tolerance threshold is breached (suspicion detected) +//! - Player issues a different Interact verb (handled in input.rs) +//! +//! All arithmetic is integer-only (D-010 determinism). + +use bevy_ecs::prelude::*; + +use crate::npc::{Npc, ToleranceThreshold}; +use crate::perception::query::VisibilityGeometry; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::time::SimulationTime; +use crate::npc::tolerance::ToleranceBreached; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Manhattan distance within which follow counts as "too close" (tiles). +pub const FOLLOW_PROXIMITY_RANGE: u32 = 2; + +/// Ticks of sustained close proximity before suspicion stress starts. +pub const FOLLOW_SUSPICION_TICKS: u64 = 60; + +/// Ticks without LOS before follow auto-ends (deep fog timeout). +pub const FOLLOW_LOS_LOST_TIMEOUT: u64 = 30; + +/// Stress increment per tick when player is "too close too long" (integer, D-010). +pub const FOLLOW_STRESS_PER_TICK: i16 = 2; + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +/// Marker component on the player: designates an NPC as the follow target. +/// +/// Inserted by `handle_follow` in `input.rs` when the player uses the Follow verb. +/// Removed by `update_follow_state` when follow ends, or by input processing +/// when the player issues a different action. +#[derive(Component, Debug, Clone)] +pub struct FollowTarget { + /// The NPC entity being followed. + pub target: Entity, + /// Tick when follow started. + pub started_tick: u64, + /// Consecutive ticks player has been within FOLLOW_PROXIMITY_RANGE with LOS. + pub proximity_ticks: u64, + /// Consecutive ticks LOS to target has been lost. + pub los_lost_ticks: u64, +} + +/// Event emitted when follow ends, for downstream systems to react. +#[derive(Debug, Clone)] +pub struct FollowEndEvent { + /// The player entity that was following. + pub player: Entity, + /// The NPC entity that was being followed. + pub target: Entity, + /// Why the follow ended. + pub reason: FollowEndReason, + /// Tick when follow ended. + pub tick: u64, +} + +/// Why a follow ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FollowEndReason { + /// Target left LOS for too long. + LosLost, + /// Target's tolerance threshold was breached (detected player). + Detected, + /// Player issued a different action. + PlayerAction, +} + +/// Resource: queue of follow end events (drained per tick). +#[derive(Resource, Default)] +pub struct FollowEndEventQueue { + pub events: Vec, +} + +impl FollowEndEventQueue { + pub fn push(&mut self, event: FollowEndEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Wire type for ObserverSnapshot +// --------------------------------------------------------------------------- + +/// Follow state sent to the client for HUD display (#241). +/// +/// Present when the player is actively following an NPC. +/// Client shows follow-mode indicator with distance and LOS state. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct FollowStateWire { + /// Wire-format entity ID of the follow target. + pub target_entity_id: u64, + /// Current Manhattan distance to target. + pub distance: u32, + /// Whether the player currently has LOS to the target. + pub has_los: bool, + /// Consecutive ticks of close proximity (for UI tension indicator). + pub proximity_ticks: u64, +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +/// System: update follow state each tick for players with a FollowTarget. +/// +/// Runs after movement validation and visibility geometry computation. +/// For each player with `FollowTarget`: +/// +/// 1. Check if target still exists and has a position. +/// 2. Compute Manhattan distance to target. +/// 3. Check LOS via VisibilityGeometry (player's precomputed FOV). +/// 4. Update proximity_ticks and los_lost_ticks counters. +/// 5. Apply suspicion stress to target's ToleranceThreshold when "too close too long". +/// 6. End follow if LOS lost for timeout or target's tolerance is breached. +/// +/// Scoped to players only (FollowTarget is a player component). +pub fn update_follow_state( + mut commands: Commands, + time: Res, + geometry: Res, + mut end_queue: ResMut, + mut player_query: Query<(Entity, &TilePosition, &mut FollowTarget), With>, + target_query: Query<(&TilePosition, Option<&ToleranceBreached>), With>, + mut tolerance_query: Query<&mut ToleranceThreshold, With>, +) { + let tick = time.tick; + + let Ok((player_entity, player_pos, mut follow)) = player_query.single_mut() else { + return; + }; + + // Check target still exists with a position + let Ok((target_pos, is_detected)) = target_query.get(follow.target) else { + // Target despawned or lost position — end follow + commands.entity(player_entity).remove::(); + end_queue.push(FollowEndEvent { + player: player_entity, + target: follow.target, + reason: FollowEndReason::LosLost, + tick, + }); + return; + }; + + // Check if target's tolerance has been breached (detected player) + if is_detected.is_some() { + commands.entity(player_entity).remove::(); + end_queue.push(FollowEndEvent { + player: player_entity, + target: follow.target, + reason: FollowEndReason::Detected, + tick, + }); + return; + } + + // Compute distance + let distance = player_pos + .manhattan_distance(target_pos) + .unwrap_or(u32::MAX); + + // Check LOS: target position must be in the player's precomputed visibility + let has_los = target_pos.z == geometry.observer_z + && geometry + .visible_positions + .contains(&(target_pos.x, target_pos.y)); + + if has_los { + // Reset LOS lost counter + follow.los_lost_ticks = 0; + + // Track close proximity + if distance <= FOLLOW_PROXIMITY_RANGE { + follow.proximity_ticks += 1; + } else { + // Not close — reset proximity counter (must be sustained) + follow.proximity_ticks = 0; + } + + // Apply suspicion stress when "too close too long" + if follow.proximity_ticks >= FOLLOW_SUSPICION_TICKS { + if let Ok(mut tolerance) = tolerance_query.get_mut(follow.target) { + tolerance.current_stress = tolerance + .current_stress + .saturating_add(FOLLOW_STRESS_PER_TICK); + tracing::debug!( + target = ?follow.target, + stress = tolerance.current_stress, + proximity_ticks = follow.proximity_ticks, + "Follow: applying suspicion stress" + ); + } + } + } else { + // LOS lost — increment counter + follow.los_lost_ticks += 1; + + // Reset proximity counter (can't be "too close" without LOS) + follow.proximity_ticks = 0; + + // Check timeout + if follow.los_lost_ticks >= FOLLOW_LOS_LOST_TIMEOUT { + commands.entity(player_entity).remove::(); + end_queue.push(FollowEndEvent { + player: player_entity, + target: follow.target, + reason: FollowEndReason::LosLost, + tick, + }); + tracing::debug!( + target = ?follow.target, + los_lost_ticks = follow.los_lost_ticks, + "Follow: ended — LOS lost for too long" + ); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::npc::{Npc, ToleranceThreshold}; + use crate::perception::query::VisibilityGeometry; + use crate::simulation::movement::{PlayerCharacter, TilePosition}; + use crate::simulation::time::SimulationTime; + use bevy_ecs::world::World; + use std::collections::BTreeSet; + + fn setup_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + // Empty visibility geometry — tests override as needed + world.insert_resource(VisibilityGeometry { + visible_tiles: vec![], + visible_positions: BTreeSet::new(), + sector_lookup: Default::default(), + observer_z: 0, + }); + world + } + + fn run_system(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_follow_state); + schedule.run(world); + } + + /// Make target visible in the geometry resource. + fn make_visible(world: &mut World, x: i32, y: i32) { + let mut geom = world.resource_mut::(); + geom.visible_positions.insert((x, y)); + } + + // ----------------------------------------------------------------------- + // Basic follow tracking + // ----------------------------------------------------------------------- + + #[test] + fn follow_tracks_proximity_ticks_when_close_with_los() { + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: 0, + los_lost_ticks: 0, + }, + )); + + // Target is visible + make_visible(&mut world, 5, 6); + + run_system(&mut world); + + let mut query = world.query::<&FollowTarget>(); + let follow = query.single(&world).unwrap(); + assert_eq!(follow.proximity_ticks, 1, "proximity should tick up"); + assert_eq!(follow.los_lost_ticks, 0, "LOS is not lost"); + } + + #[test] + fn follow_resets_proximity_when_far() { + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 10, 0), // distance 5 from player + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: 10, + los_lost_ticks: 0, + }, + )); + + make_visible(&mut world, 5, 10); + + run_system(&mut world); + + let mut query = world.query::<&FollowTarget>(); + let follow = query.single(&world).unwrap(); + assert_eq!( + follow.proximity_ticks, 0, + "proximity resets when beyond close range" + ); + } + + // ----------------------------------------------------------------------- + // LOS tracking + // ----------------------------------------------------------------------- + + #[test] + fn follow_tracks_los_lost_ticks() { + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: 0, + los_lost_ticks: 0, + }, + )); + + // Target NOT visible (not in visible_positions) + run_system(&mut world); + + let mut query = world.query::<&FollowTarget>(); + let follow = query.single(&world).unwrap(); + assert_eq!(follow.los_lost_ticks, 1, "LOS lost should increment"); + assert_eq!( + follow.proximity_ticks, 0, + "proximity should not increment without LOS" + ); + } + + #[test] + fn follow_los_regained_resets_counter() { + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: 0, + los_lost_ticks: 15, // Was losing LOS + }, + )); + + make_visible(&mut world, 5, 6); + + run_system(&mut world); + + let mut query = world.query::<&FollowTarget>(); + let follow = query.single(&world).unwrap(); + assert_eq!(follow.los_lost_ticks, 0, "LOS regained resets counter"); + } + + // ----------------------------------------------------------------------- + // Follow end conditions + // ----------------------------------------------------------------------- + + #[test] + fn follow_ends_on_los_timeout() { + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: 0, + los_lost_ticks: FOLLOW_LOS_LOST_TIMEOUT - 1, // One tick away + }, + )) + .id(); + + // Target NOT visible + run_system(&mut world); + + assert!( + world.get::(player).is_none(), + "follow should end after LOS timeout" + ); + + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1); + assert_eq!(queue.events[0].reason, FollowEndReason::LosLost); + } + + #[test] + fn follow_ends_on_detection() { + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: 80, + threshold: 80, + }, + ToleranceBreached, // Already detected + )) + .id(); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: 0, + los_lost_ticks: 0, + }, + )) + .id(); + + make_visible(&mut world, 5, 6); + + run_system(&mut world); + + assert!( + world.get::(player).is_none(), + "follow should end when target detects player" + ); + + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1); + assert_eq!(queue.events[0].reason, FollowEndReason::Detected); + } + + #[test] + fn follow_ends_on_target_despawn() { + let mut world = setup_world(); + + // Spawn and immediately despawn the target + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + world.despawn(target); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: 0, + los_lost_ticks: 0, + }, + )) + .id(); + + run_system(&mut world); + + assert!( + world.get::(player).is_none(), + "follow should end when target is despawned" + ); + } + + // ----------------------------------------------------------------------- + // Suspicion stress + // ----------------------------------------------------------------------- + + #[test] + fn stress_not_applied_before_threshold() { + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: FOLLOW_SUSPICION_TICKS - 2, // Not yet at threshold + los_lost_ticks: 0, + }, + )); + + make_visible(&mut world, 5, 6); + + run_system(&mut world); + + let tolerance = world.get::(target).unwrap(); + assert_eq!( + tolerance.current_stress, 0, + "stress should not increase before suspicion threshold" + ); + } + + #[test] + fn stress_applied_at_suspicion_threshold() { + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: FOLLOW_SUSPICION_TICKS - 1, // Will reach threshold this tick + los_lost_ticks: 0, + }, + )); + + make_visible(&mut world, 5, 6); + + run_system(&mut world); + + let tolerance = world.get::(target).unwrap(); + assert_eq!( + tolerance.current_stress, FOLLOW_STRESS_PER_TICK, + "stress should increase when proximity reaches suspicion threshold" + ); + } + + #[test] + fn stress_accumulates_over_ticks() { + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: 10, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: FOLLOW_SUSPICION_TICKS + 10, // Well past threshold + los_lost_ticks: 0, + }, + )); + + make_visible(&mut world, 5, 6); + + run_system(&mut world); + + let tolerance = world.get::(target).unwrap(); + assert_eq!( + tolerance.current_stress, + 10 + FOLLOW_STRESS_PER_TICK, + "stress should accumulate from existing value" + ); + } + + #[test] + fn stress_saturates_at_max() { + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: i16::MAX - 1, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: FOLLOW_SUSPICION_TICKS, + los_lost_ticks: 0, + }, + )); + + make_visible(&mut world, 5, 6); + + run_system(&mut world); + + let tolerance = world.get::(target).unwrap(); + assert_eq!( + tolerance.current_stress, + i16::MAX, + "stress should saturate, not overflow" + ); + } + + // ----------------------------------------------------------------------- + // No player / no follow + // ----------------------------------------------------------------------- + + #[test] + fn no_player_no_panic() { + let mut world = setup_world(); + // No player entity + run_system(&mut world); // should not panic + } + + #[test] + fn player_without_follow_no_panic() { + let mut world = setup_world(); + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + run_system(&mut world); // should not panic + } + + // ----------------------------------------------------------------------- + // Different z-level + // ----------------------------------------------------------------------- + + #[test] + fn different_z_level_counts_as_no_los() { + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 1), // Different z-level + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: 0, + los_lost_ticks: 0, + }, + )); + + // Even if position is in visible set, z-level mismatch = no LOS + make_visible(&mut world, 5, 6); + + run_system(&mut world); + + let mut query = world.query::<&FollowTarget>(); + let follow = query.single(&world).unwrap(); + assert_eq!( + follow.los_lost_ticks, 1, + "different z-level should count as LOS lost" + ); + } + + // ----------------------------------------------------------------------- + // Constant value assertions (#241 spec compliance) + // ----------------------------------------------------------------------- + + #[test] + fn follow_constants_have_expected_values() { + // Spec-defined in #241 — changes here break the design contract + assert_eq!(FOLLOW_PROXIMITY_RANGE, 2, "D-241: 'too close' range is 2 Manhattan tiles"); + assert_eq!( + FOLLOW_SUSPICION_TICKS, 60, + "D-241: suspicion starts after 60 sustained proximity ticks" + ); + assert_eq!( + FOLLOW_LOS_LOST_TIMEOUT, 30, + "D-241: follow ends after 30 consecutive LOS-lost ticks" + ); + assert_eq!( + FOLLOW_STRESS_PER_TICK, 2, + "D-241: stress increment per tick (integer, D-010)" + ); + } + + // ----------------------------------------------------------------------- + // FOLLOW_PROXIMITY_RANGE boundary tests + // ----------------------------------------------------------------------- + + #[test] + fn proximity_at_range_boundary_counts_as_close() { + // Distance == FOLLOW_PROXIMITY_RANGE (2) should count as close + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 7, 0), // distance exactly 2 from (5,5) + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: 0, + los_lost_ticks: 0, + }, + )); + + make_visible(&mut world, 5, 7); + run_system(&mut world); + + let mut query = world.query::<&FollowTarget>(); + let follow = query.single(&world).unwrap(); + assert_eq!( + follow.proximity_ticks, 1, + "distance 2 should count as close (at FOLLOW_PROXIMITY_RANGE boundary)" + ); + } + + #[test] + fn proximity_one_beyond_range_resets_counter() { + // Distance == FOLLOW_PROXIMITY_RANGE + 1 (3) should NOT count as close + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 8, 0), // distance 3 + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: 20, // Had been accumulating + los_lost_ticks: 0, + }, + )); + + make_visible(&mut world, 5, 8); + run_system(&mut world); + + let mut query = world.query::<&FollowTarget>(); + let follow = query.single(&world).unwrap(); + assert_eq!( + follow.proximity_ticks, 0, + "distance 3 (> FOLLOW_PROXIMITY_RANGE 2) should reset proximity counter" + ); + } + + // ----------------------------------------------------------------------- + // LOS required for stress and proximity + // ----------------------------------------------------------------------- + + #[test] + fn stress_not_applied_when_los_is_lost_despite_proximity() { + // Being physically close but with LOS lost should NOT trigger stress. + // Proximity tracking only applies when target is visible. + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), // distance 1 — very close + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: FOLLOW_SUSPICION_TICKS + 5, // Well past suspicion threshold + los_lost_ticks: 0, + }, + )); + + // Target NOT in visible_positions — LOS lost + run_system(&mut world); + + let tolerance = world.get::(target).unwrap(); + assert_eq!( + tolerance.current_stress, 0, + "stress must not accumulate when LOS is lost, even if physically close" + ); + } + + #[test] + fn proximity_ticks_reset_to_zero_when_los_lost() { + // When LOS is lost, accumulated proximity_ticks must reset. + // This prevents suspicion stress "banked" from previous close approaches. + let mut world = setup_world(); + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: 0, + threshold: 80, + }, + )) + .id(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 0, + proximity_ticks: FOLLOW_SUSPICION_TICKS - 5, // Nearly at threshold + los_lost_ticks: 0, + }, + )); + + // Target NOT visible — LOS lost + run_system(&mut world); + + let mut query = world.query::<&FollowTarget>(); + let follow = query.single(&world).unwrap(); + assert_eq!( + follow.proximity_ticks, 0, + "proximity_ticks must reset to 0 when LOS is lost" + ); + assert_eq!(follow.los_lost_ticks, 1, "los_lost_ticks should increment"); + } + + // ----------------------------------------------------------------------- + // FollowStateWire serde roundtrip + // ----------------------------------------------------------------------- + + #[test] + fn follow_state_wire_roundtrips_via_serde() { + let wire = FollowStateWire { + target_entity_id: 12345, + distance: 3, + has_los: true, + proximity_ticks: 55, + }; + + let json = serde_json::to_string(&wire).expect("FollowStateWire should serialize"); + let decoded: FollowStateWire = + serde_json::from_str(&json).expect("FollowStateWire should deserialize"); + + assert_eq!(decoded.target_entity_id, 12345); + assert_eq!(decoded.distance, 3); + assert!(decoded.has_los); + assert_eq!(decoded.proximity_ticks, 55); + } + + // ----------------------------------------------------------------------- + // Follow end event metadata + // ----------------------------------------------------------------------- + + #[test] + fn follow_end_event_contains_correct_metadata() { + let mut world = setup_world(); + world.resource_mut::().tick = 42; + + let target = world + .spawn(( + Npc, + TilePosition::new(5, 6, 0), + ToleranceThreshold { + current_stress: 80, + threshold: 80, + }, + ToleranceBreached, + )) + .id(); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + FollowTarget { + target, + started_tick: 10, + proximity_ticks: 50, + los_lost_ticks: 0, + }, + )) + .id(); + + make_visible(&mut world, 5, 6); + + run_system(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1); + let event = &queue.events[0]; + assert_eq!(event.player, player); + assert_eq!(event.target, target); + assert_eq!(event.tick, 42); + assert_eq!(event.reason, FollowEndReason::Detected); + } +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 90564ec3f..953e4531e 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -180,7 +180,16 @@ pub fn process_player_input( PlayerAction::Interact { target_entity_id, ref verb, - } => match verb.as_deref() { + } => { + // Cancel follow when player uses any non-Follow verb (#241). + if verb.as_deref() != Some("Follow") { + if let Ok((player_entity, _, _, _)) = player_query.single() { + commands + .entity(player_entity) + .remove::(); + } + } + match verb.as_deref() { Some("Take") => { handle_take( &mut commands, @@ -202,6 +211,16 @@ pub fn process_player_input( target_entity_id, ); } + Some("Follow") => { + handle_follow( + &mut commands, + ®istry, + &player_query, + &all_positions, + target_entity_id, + current_tick, + ); + } Some("Confront") => { handle_confront( &mut commands, @@ -228,7 +247,7 @@ pub fn process_player_input( verb, ); } - }, + }} PlayerAction::WalkAway => { if let Ok((player_entity, _, _, _)) = player_query.single() { commands @@ -501,6 +520,69 @@ fn handle_confront( ); } +/// Handle Follow verb: designate an NPC as follow target (#241). +/// Sets FollowTarget on the player entity. Replaces any existing follow target. +/// Server-side range check: Follow requires CLOSE_RANGE (same as Talk). +#[allow(clippy::type_complexity)] +fn handle_follow( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &Query< + ( + Entity, + &TilePosition, + Option<&mut Stance>, + Option<&mut PlayerMoveCooldown>, + ), + With, + >, + all_positions: &Query<&TilePosition>, + target_entity_id: Option, + current_tick: u64, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Follow verb without target_entity_id"); + return; + }; + + let Ok((player_entity, player_pos, _, _)) = player_query.single() else { + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Follow: target entity not in registry"); + return; + }; + + // Server-side range check: reject Follow if target is beyond close range + if let Ok(target_pos) = all_positions.get(target_entity) { + let distance = player_pos + .manhattan_distance(target_pos) + .unwrap_or(u32::MAX); + if distance > crate::simulation::interaction::CLOSE_RANGE { + tracing::info!( + target_id, + distance, + "Follow: target out of range (max {})", + crate::simulation::interaction::CLOSE_RANGE, + ); + return; + } + } + + commands + .entity(player_entity) + .insert(crate::simulation::follow::FollowTarget { + target: target_entity, + started_tick: current_tick, + proximity_ticks: 0, + los_lost_ticks: 0, + }); + + tracing::debug!(target_id, "Follow: FollowTarget set on player"); +} + /// Handle Place verb: remove an item from inventory and place it on the ground /// at the player's current position. Removes CarriedBy + InventorySlot, adds /// TilePosition at the player's current tile. diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index 1116c8ca7..7e1337d6d 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -208,9 +208,9 @@ pub fn compute_nearby_interactions( let mut verbs = Vec::new(); if is_npc.is_some() { - // NPC verb logic — unchanged from #404 + // NPC verb logic — Talk + ExamineNpc (#404), Follow (#241) if is_close { - // Default priority: Talk first, Observe second. + // Default priority: Talk first, Observe second, Follow third. // Observer adjusts priority for POI entities. verbs.push(VerbOption { kind: VerbKind::Talk, @@ -224,8 +224,14 @@ pub fn compute_nearby_interactions( priority: 2, available: true, }); + verbs.push(VerbOption { + kind: VerbKind::Follow, + label: "Follow".into(), + priority: 3, + available: true, + }); } else { - // Mid range: only Examine NPC (Talk requires close range) + // Mid range: only Examine NPC (Talk + Follow require close range) verbs.push(VerbOption { kind: VerbKind::ExamineNpc, label: "Examine NPC".into(), @@ -346,7 +352,7 @@ mod tests { // ----------------------------------------------------------------------- #[test] - fn npc_in_close_range_gets_talk_and_observe() { + fn npc_in_close_range_gets_talk_examine_follow() { let mut world = setup_world(); spawn_player(&mut world, 5, 5); world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); @@ -357,11 +363,13 @@ mod tests { let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 1); - assert_eq!(buffer.interactions[0].verbs.len(), 2); + assert_eq!(buffer.interactions[0].verbs.len(), 3); assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk); assert_eq!(buffer.interactions[0].verbs[0].priority, 1); assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc); assert_eq!(buffer.interactions[0].verbs[1].priority, 2); + assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Follow); + assert_eq!(buffer.interactions[0].verbs[2].priority, 3); } #[test] @@ -845,10 +853,11 @@ mod tests { let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 1); - // Should get NPC verbs (Talk + ExamineNpc), NOT Terminal verbs (Use + Observe) - assert_eq!(buffer.interactions[0].verbs.len(), 2); + // Should get NPC verbs (Talk + ExamineNpc + Follow), NOT Terminal verbs (Use + Observe) + assert_eq!(buffer.interactions[0].verbs.len(), 3); assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk); assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc); + assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Follow); } /// All ObjectType primary verbs are close_only (except Observe). From 517318c8a79dec69eda03def084cfe28a18e231c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:39:58 +0100 Subject: [PATCH 07/11] feat(simulation): extend monologue event generation with new triggers (#119) Add observe_npc, hear_sound, observe_anomaly, witness_interaction, and post_conversation triggers to monologue system. trigger_event_monologue system fires after sound collection, NPC conversations, and walk-away. Context tags (location, situation, character_state) populated for D-035 content pool matching. COOLDOWN_TICKS=300 anti-spam guard respected. witness_interaction fires after overheard NPC-to-NPC conversation per D-078. Voice sounds and ambient sounds correctly excluded from hear_sound trigger. Co-Authored-By: Claude Opus 4.6 --- server/src/perception/interpretation.rs | 6 + server/src/simulation/dialogue.rs | 5 + server/src/simulation/monologue.rs | 1006 +++++++++++++++++++++++ 3 files changed, 1017 insertions(+) diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 0e8ad310a..b71ce5671 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -68,6 +68,12 @@ impl ObservationEventQueue { pub fn is_empty(&self) -> bool { self.events.is_empty() } + + /// Iterate over observation events without draining. + /// Used by monologue trigger system (#119) to react to previous-tick events. + pub fn iter(&self) -> impl Iterator { + self.events.iter() + } } /// System: interpret visible snapshot against known routines and knowledge. diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index 134eb47b1..18e35edcd 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -587,6 +587,7 @@ pub fn process_walk_away( mut commands: Commands, mut event_queue: ResMut, mut trust_queue: ResMut, + mut post_conv_queue: ResMut, time: Res, query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With>, mut npc_mem_query: Query>, @@ -635,6 +636,9 @@ pub fn process_walk_away( }); } + // Post-conversation monologue trigger (#119, D-035) + post_conv_queue.push(target); + tracing::debug!( "Walk-away during {:?} dialogue at tick {} (started tick {}): \ target {:?} → Tier2 animation + routine deviation", @@ -1121,6 +1125,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world } diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 4ac939283..f5fdff78d 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -64,6 +64,79 @@ const ANOMALY_LINES: &[(&str, &str)] = &[ ), ]; +// --------------------------------------------------------------------------- +// Event-driven monologue triggers (#119, D-035) +// --------------------------------------------------------------------------- + +/// Hardcoded v0.1 observe_npc monologue lines. +/// Fire when a new entity enters the player's field of view. +/// Future: move to content pools with trigger="observe_npc". +const OBSERVE_NPC_LINES: &[(&str, &str)] = &[ + ("observe_npc_01", "New face. Haven't seen them before."), + ("observe_npc_02", "Someone I don't recognize."), + ("observe_npc_03", "Who's that? They weren't here earlier."), +]; + +/// Hardcoded v0.1 hear_sound monologue lines. +/// Fire when the player hears a non-routine sound (Machinery, Alert). +/// Future: move to content pools with trigger="hear_sound". +const HEAR_SOUND_LINES: &[(&str, &str)] = &[ + ("hear_sound_01", "What was that?"), + ( + "hear_sound_02", + "That sound \u{2014} not the usual background.", + ), + ("hear_sound_03", "Something just happened nearby."), +]; + +/// Hardcoded v0.1 witness_interaction monologue lines. +/// Fire when the player overhears an NPC-to-NPC conversation (D-078). +/// Future: move to content pools with trigger="witness_interaction". +const WITNESS_INTERACTION_LINES: &[(&str, &str)] = &[ + ( + "witness_01", + "Interesting. Wonder what that was about.", + ), + ("witness_02", "I should remember what they just said."), + ("witness_03", "They didn't know I was listening."), +]; + +/// Hardcoded v0.1 post_conversation monologue lines. +/// Fire after a player-NPC dialogue concludes (walk-away or natural end). +/// Future: move to content pools with trigger="post_conversation". +const POST_CONVERSATION_LINES: &[(&str, &str)] = &[ + ("post_conv_01", "More questions than answers."), + ( + "post_conv_02", + "I'll have to think about what they said.", + ), + ( + "post_conv_03", + "Something about that exchange didn't sit right.", + ), +]; + +/// Resource: signals that a player-NPC dialogue completed this tick. +/// Pushed by process_walk_away (D-064); drained by trigger_event_monologue. +#[derive(Resource, Debug, Default)] +pub struct PostConversationQueue { + entries: Vec, +} + +impl PostConversationQueue { + pub fn push(&mut self, npc: bevy_ecs::entity::Entity) { + self.entries.push(npc); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.entries) + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + /// Tracks monologue state for cooldown and trigger detection. /// Attached to the PlayerCharacter entity. #[derive(Component, Debug)] @@ -80,6 +153,10 @@ pub struct MonologueState { pub shown_ids: BTreeSet, /// Character type for pool filtering. v0.1: always "detective". pub character: String, + /// Tick of the last observation event we reacted to (#119, observe_npc). + /// Observation events arrive one tick after the snapshot that caused them, + /// so we track which tick's events we've already processed. + pub last_observation_tick: u64, } impl Default for MonologueState { @@ -92,6 +169,7 @@ impl Default for MonologueState { shown_ids: BTreeSet::new(), // v0.1: default to detective; character selection sets this character: "detective".to_string(), + last_observation_tick: 0, } } } @@ -353,6 +431,242 @@ pub fn trigger_recognition_monologue( ); } +// --------------------------------------------------------------------------- +// Shared content pool selection (#119) +// --------------------------------------------------------------------------- + +/// Select a monologue line from content pools, matching trigger and character. +/// Returns (id, text) or None if no matching lines exist. +/// Prefers unseen lines; falls back to repeats if all have been shown. +fn select_pool_line( + trigger: &str, + state: &MonologueState, + content: &ContentStoreResource, + rng: &mut impl Rng, +) -> Option<(String, String)> { + let character = state.character.as_str(); + let mut candidates: Vec<(&str, &str)> = Vec::new(); + + for district in content.0.districts.values() { + for pool in &district.monologue_pools { + if pool.character != character { + continue; + } + for line in &pool.lines { + if line.trigger != trigger { + continue; + } + if state.shown_ids.contains(&line.id) { + continue; + } + candidates.push((&line.id, &line.text)); + } + } + } + + if candidates.is_empty() { + // Fallback: allow repeats + for district in content.0.districts.values() { + for pool in &district.monologue_pools { + if pool.character != character { + continue; + } + for line in &pool.lines { + if line.trigger != trigger { + continue; + } + candidates.push((&line.id, &line.text)); + } + } + } + } + + if candidates.is_empty() { + return None; + } + + let index = rng.random_range(0..candidates.len()); + Some(( + candidates[index].0.to_string(), + candidates[index].1.to_string(), + )) +} + +/// Select from hardcoded fallback lines for the given trigger type. +fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, String) { + let lines = match trigger { + "observe_npc" => OBSERVE_NPC_LINES, + "hear_sound" => HEAR_SOUND_LINES, + "witness_interaction" => WITNESS_INTERACTION_LINES, + "post_conversation" => POST_CONVERSATION_LINES, + _ => OBSERVE_NPC_LINES, + }; + let index = rng.random_range(0..lines.len()); + (lines[index].0.to_string(), lines[index].1.to_string()) +} + +/// Tile range for a SoundRange classification (D-018). +fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 { + use crate::knowledge::types::SoundRange; + match range { + SoundRange::Close => 3, + SoundRange::Medium => 8, + SoundRange::Long => 15, + } +} + +// --------------------------------------------------------------------------- +// Event-driven monologue trigger system (#119, D-035) +// --------------------------------------------------------------------------- + +/// Event-driven monologue trigger system (#119, D-035). +/// +/// Checks observation events, sound events, overheard conversations, and +/// completed dialogues for monologue-worthy triggers. Fires at most one +/// monologue per tick. Bypasses normal COOLDOWN_TICKS (event-driven), +/// but updates last_fired_tick for periodic trigger cooldown tracking. +/// +/// Priority order (first match wins): +/// 1. observe_npc (new entity spotted — uses previous-tick observation events) +/// 2. hear_sound (non-routine sound: Machinery, Alert) +/// 3. witness_interaction (overheard NPC-to-NPC conversation, D-078) +/// 4. post_conversation (player-NPC dialogue concluded) +/// +/// System ordering: after all event producers + recognition/anomaly monologue +/// systems, before compute_observer_snapshot. +#[allow(clippy::too_many_arguments)] +pub fn trigger_event_monologue( + time: Res, + content: Option>, + mut rng: ResMut, + observation_queue: Option>, + sound_queue: Option>, + mut post_conv_queue: ResMut, + mut query: Query< + ( + &TilePosition, + &mut MonologueState, + &mut MonologueBuffer, + Option<&crate::simulation::conversation::ConversationEventBuffer>, + ), + With, + >, +) { + let Ok((player_pos, mut state, mut buffer, conv_buffer_opt)) = query.single_mut() else { + // Drain post_conversation queue even without a player + post_conv_queue.drain(); + return; + }; + + // Don't override existing monologue from higher-priority systems + if buffer.event.is_some() { + post_conv_queue.drain(); + return; + } + + // Determine which trigger to fire (priority order) + let trigger = if observation_queue + .as_ref() + .map(|q| has_observe_npc_event(q, &state)) + .unwrap_or(false) + { + Some("observe_npc") + } else if sound_queue + .as_ref() + .map(|q| has_hear_sound_event(q, player_pos)) + .unwrap_or(false) + { + Some("hear_sound") + } else if conv_buffer_opt.map(|b| !b.events.is_empty()).unwrap_or(false) { + Some("witness_interaction") + } else if !post_conv_queue.is_empty() { + Some("post_conversation") + } else { + None + }; + + // Always drain post_conversation queue (consumed this tick) + post_conv_queue.drain(); + + // Update observation tracking regardless of whether we fire + if let Some(ref obs_queue) = observation_queue { + if !obs_queue.is_empty() { + if let Some(max_tick) = obs_queue.iter().map(|e| e.tick).max() { + if max_tick > state.last_observation_tick { + state.last_observation_tick = max_tick; + } + } + } + } + + let Some(trigger) = trigger else { return }; + + // Select line: content pool first, hardcoded fallback second + let (id, text) = if let Some(ref content) = content { + if let Some(line) = select_pool_line(trigger, &state, content, &mut rng.rng) { + line + } else { + select_hardcoded_fallback(trigger, &mut rng.rng) + } + } else { + select_hardcoded_fallback(trigger, &mut rng.rng) + }; + + buffer.event = Some(MonologueEvent { + id: id.clone(), + text, + duration_seconds: DISPLAY_DURATION, + }); + + state.shown_ids.insert(id.clone()); + state.last_fired_tick = time.tick; + + tracing::debug!( + "Event monologue fired: trigger={}, id={}, tick={}", + trigger, + id, + time.tick + ); +} + +/// Check if any NewEntity observation events exist that we haven't processed. +fn has_observe_npc_event( + queue: &crate::perception::interpretation::ObservationEventQueue, + state: &MonologueState, +) -> bool { + queue.iter().any(|e| { + e.tick > state.last_observation_tick + && matches!( + &e.trigger, + crate::perception::interpretation::ObservationTrigger::NewEntity { .. } + ) + }) +} + +/// Check if any non-routine sound events are within hearing range. +/// Only Machinery and Alert sounds trigger monologue (Footstep, Voice, Ambient +/// are routine and would spam the player). +fn has_hear_sound_event( + queue: &crate::simulation::sound::SoundEventQueue, + player_pos: &TilePosition, +) -> bool { + use crate::simulation::sound::SoundEventKind; + + queue.events.iter().any(|e| { + let interesting = matches!(e.kind, SoundEventKind::Machinery | SoundEventKind::Alert); + if !interesting { + return false; + } + if e.z != player_pos.z { + return false; + } + let dx = (e.x as i32 - player_pos.x).unsigned_abs(); + let dy = (e.y as i32 - player_pos.y).unsigned_abs(); + let distance = dx + dy; + distance <= sound_range_tiles(&e.range) + }) +} + /// Monologue trigger system. /// /// Runs each tick. Checks trigger conditions against loaded content pools @@ -1221,4 +1535,696 @@ mod tests { "last_fired_tick updated for cooldown" ); } + + // ----------------------------------------------------------------------- + // trigger_event_monologue tests (#119, D-035) + // ----------------------------------------------------------------------- + + use crate::perception::interpretation::{ + ObservationEvent, ObservationEventQueue, ObservationTrigger, + }; + use crate::simulation::conversation::ConversationEventBuffer; + use crate::simulation::sound::{SoundEvent, SoundEventKind, SoundEventQueue}; + + fn setup_event_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world.insert_resource(SimRng::new(42)); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world + } + + fn spawn_event_player(world: &mut World) -> Entity { + world + .spawn(( + PlayerCharacter, + TilePosition::new(10, 10, 0), + MonologueState::default(), + MonologueBuffer::default(), + ConversationEventBuffer::default(), + )) + .id() + } + + fn run_event_system(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(trigger_event_monologue); + schedule.run(world); + } + + #[test] + fn observe_npc_fires_on_new_entity_event() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + + // Push a NewEntity observation event at tick 1 (player starts at tick 0) + world.resource_mut::().tick = 5; + let _target_entity = world.spawn_empty().id(); + world + .resource_mut::() + .push(ObservationEvent { + tick: 1, + trigger: ObservationTrigger::NewEntity { + entity: StableId(42), + location: TilePosition::new(12, 12, 0), + }, + observer: player, + }); + + run_event_system(&mut world); + + let buf = world.get::(player).unwrap(); + assert!( + buf.event.is_some(), + "observe_npc should fire for NewEntity event" + ); + let event = buf.event.as_ref().unwrap(); + assert!( + event.id.starts_with("observe_npc_"), + "should use observe_npc fallback lines, got: {}", + event.id + ); + } + + #[test] + fn observe_npc_ignores_already_processed_events() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + + // Set last_observation_tick so events at tick 1 are already processed + world + .get_mut::(player) + .unwrap() + .last_observation_tick = 5; + + world + .resource_mut::() + .push(ObservationEvent { + tick: 3, // older than last_observation_tick + trigger: ObservationTrigger::NewEntity { + entity: StableId(42), + location: TilePosition::new(12, 12, 0), + }, + observer: player, + }); + + run_event_system(&mut world); + + let buf = world.get::(player).unwrap(); + assert!( + buf.event.is_none(), + "should not fire for already-processed observation events" + ); + } + + #[test] + fn hear_sound_fires_on_machinery_in_range() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + + world.resource_mut::().events.push( + SoundEvent::at( + &TilePosition::new(12, 10, 0), // distance 2 from player at (10,10) + SoundEventKind::Machinery, + 0.8, + crate::knowledge::types::SoundRange::Medium, + None, + ), + ); + + run_event_system(&mut world); + + let buf = world.get::(player).unwrap(); + assert!( + buf.event.is_some(), + "hear_sound should fire for Machinery sound in range" + ); + assert!(buf.event.as_ref().unwrap().id.starts_with("hear_sound_")); + } + + #[test] + fn hear_sound_ignores_footstep() { + let mut world = setup_event_world(); + let _player = spawn_event_player(&mut world); + + world.resource_mut::().events.push( + SoundEvent::at( + &TilePosition::new(11, 10, 0), + SoundEventKind::Footstep, + 0.5, + crate::knowledge::types::SoundRange::Close, + None, + ), + ); + + run_event_system(&mut world); + + let mut buf_query = world.query::<&MonologueBuffer>(); + let buf = buf_query.single(&world).unwrap(); + assert!( + buf.event.is_none(), + "Footstep sounds should not trigger monologue" + ); + } + + #[test] + fn hear_sound_ignores_out_of_range() { + let mut world = setup_event_world(); + let _player = spawn_event_player(&mut world); + + // Machinery sound at distance 20 with Close range (3 tiles) + world.resource_mut::().events.push( + SoundEvent::at( + &TilePosition::new(30, 10, 0), // distance 20 from (10,10) + SoundEventKind::Machinery, + 0.8, + crate::knowledge::types::SoundRange::Close, + None, + ), + ); + + run_event_system(&mut world); + + let mut buf_query = world.query::<&MonologueBuffer>(); + let buf = buf_query.single(&world).unwrap(); + assert!( + buf.event.is_none(), + "sounds beyond range should not trigger monologue" + ); + } + + #[test] + fn witness_interaction_fires_on_conversation_event() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + + // Pre-fill ConversationEventBuffer with an overheard conversation + world + .get_mut::(player) + .unwrap() + .events + .push(crate::simulation::conversation::ConversationEvent { + occluded_line: "Keep your head down today.".to_string(), + speaker_id: 100, + target_id: 101, + speaker_name: "Worker".to_string(), + target_name: "Courier".to_string(), + speaker_color_index: 0, + target_color_index: 1, + }); + + run_event_system(&mut world); + + let buf = world.get::(player).unwrap(); + assert!( + buf.event.is_some(), + "witness_interaction should fire when conversation overheard" + ); + assert!(buf.event.as_ref().unwrap().id.starts_with("witness_")); + } + + #[test] + fn post_conversation_fires_on_queue_entry() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + let npc = world.spawn_empty().id(); + + world.resource_mut::().push(npc); + + run_event_system(&mut world); + + let buf = world.get::(player).unwrap(); + assert!( + buf.event.is_some(), + "post_conversation should fire when queue has entries" + ); + assert!(buf.event.as_ref().unwrap().id.starts_with("post_conv_")); + } + + #[test] + fn post_conversation_queue_drained_even_when_buffer_full() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + let npc = world.spawn_empty().id(); + + // Pre-fill buffer (another system wrote first) + world.get_mut::(player).unwrap().event = + Some(MonologueEvent { + id: "existing".to_string(), + text: "Already have something.".to_string(), + duration_seconds: 5.0, + }); + + world.resource_mut::().push(npc); + + run_event_system(&mut world); + + // Buffer should still have the original event + let buf = world.get::(player).unwrap(); + assert_eq!(buf.event.as_ref().unwrap().id, "existing"); + + // Queue should be drained even though we didn't fire + assert!( + world.resource::().is_empty(), + "queue must be drained even when buffer is full" + ); + } + + #[test] + fn existing_buffer_not_overridden_by_event_trigger() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + + // Pre-fill buffer + world.get_mut::(player).unwrap().event = + Some(MonologueEvent { + id: "prior_line".to_string(), + text: "I was already thinking.".to_string(), + duration_seconds: 5.0, + }); + + // Push observation event that would normally fire + world + .resource_mut::() + .push(ObservationEvent { + tick: 1, + trigger: ObservationTrigger::NewEntity { + entity: StableId(99), + location: TilePosition::new(12, 12, 0), + }, + observer: player, + }); + + run_event_system(&mut world); + + let buf = world.get::(player).unwrap(); + assert_eq!( + buf.event.as_ref().unwrap().id, + "prior_line", + "event trigger should not override existing monologue" + ); + } + + #[test] + fn priority_observe_npc_over_hear_sound() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + + // Both triggers present: observe_npc should win + world + .resource_mut::() + .push(ObservationEvent { + tick: 1, + trigger: ObservationTrigger::NewEntity { + entity: StableId(42), + location: TilePosition::new(12, 12, 0), + }, + observer: player, + }); + + world.resource_mut::().events.push( + SoundEvent::at( + &TilePosition::new(11, 10, 0), + SoundEventKind::Machinery, + 0.8, + crate::knowledge::types::SoundRange::Medium, + None, + ), + ); + + run_event_system(&mut world); + + let buf = world.get::(player).unwrap(); + assert!( + buf.event.as_ref().unwrap().id.starts_with("observe_npc_"), + "observe_npc should have priority over hear_sound" + ); + } + + #[test] + fn priority_hear_sound_over_witness_interaction() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + + // Sound event + world.resource_mut::().events.push( + SoundEvent::at( + &TilePosition::new(11, 10, 0), + SoundEventKind::Alert, + 1.0, + crate::knowledge::types::SoundRange::Medium, + None, + ), + ); + + // Conversation event + world + .get_mut::(player) + .unwrap() + .events + .push(crate::simulation::conversation::ConversationEvent { + occluded_line: "Test".to_string(), + speaker_id: 100, + target_id: 101, + speaker_name: "A".to_string(), + target_name: "B".to_string(), + speaker_color_index: 0, + target_color_index: 1, + }); + + run_event_system(&mut world); + + let buf = world.get::(player).unwrap(); + assert!( + buf.event.as_ref().unwrap().id.starts_with("hear_sound_"), + "hear_sound should have priority over witness_interaction" + ); + } + + #[test] + fn event_trigger_updates_last_fired_tick() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + world.resource_mut::().tick = 42; + let npc = world.spawn_empty().id(); + + world.resource_mut::().push(npc); + + run_event_system(&mut world); + + let state = world.get::(player).unwrap(); + assert_eq!( + state.last_fired_tick, 42, + "event trigger should update last_fired_tick" + ); + } + + #[test] + fn event_trigger_records_shown_id() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + let npc = world.spawn_empty().id(); + + world.resource_mut::().push(npc); + + run_event_system(&mut world); + + let buf = world.get::(player).unwrap(); + let fired_id = buf.event.as_ref().unwrap().id.clone(); + + let state = world.get::(player).unwrap(); + assert!( + state.shown_ids.contains(&fired_id), + "fired line ID should be recorded in shown_ids" + ); + } + + #[test] + fn no_events_produces_no_monologue() { + let mut world = setup_event_world(); + let _player = spawn_event_player(&mut world); + + run_event_system(&mut world); + + let mut buf_query = world.query::<&MonologueBuffer>(); + let buf = buf_query.single(&world).unwrap(); + assert!(buf.event.is_none(), "no events should produce no monologue"); + } + + #[test] + fn hear_sound_alert_fires() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + + world.resource_mut::().events.push( + SoundEvent::at( + &TilePosition::new(10, 11, 0), // distance 1 + SoundEventKind::Alert, + 1.0, + crate::knowledge::types::SoundRange::Long, + None, + ), + ); + + run_event_system(&mut world); + + let buf = world.get::(player).unwrap(); + assert!( + buf.event.is_some(), + "Alert sounds should trigger hear_sound monologue" + ); + } + + #[test] + fn hear_sound_ignores_different_z_level() { + let mut world = setup_event_world(); + let _player = spawn_event_player(&mut world); + + // Sound on z=1, player on z=0 + world.resource_mut::().events.push( + SoundEvent::at( + &TilePosition::new(10, 11, 1), // same xy but different z + SoundEventKind::Machinery, + 0.8, + crate::knowledge::types::SoundRange::Medium, + None, + ), + ); + + run_event_system(&mut world); + + let mut buf_query = world.query::<&MonologueBuffer>(); + let buf = buf_query.single(&world).unwrap(); + assert!( + buf.event.is_none(), + "sounds on different z-level should not trigger monologue" + ); + } + + #[test] + fn event_uses_content_pool_when_available() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + + // Set up content pool with a witness_interaction line + let pool = MonologuePool { + character: "detective".to_string(), + location: "general".to_string(), + lines: vec![MonologueLine { + id: "pool_witness_01".to_string(), + text: "She's lying to him.".to_string(), + trigger: "witness_interaction".to_string(), + prerequisites: None, + priority: None, + cooldown: None, + tags: vec![], + }], + }; + + let mut district = DistrictContent::default(); + district.monologue_pools.push(pool); + let mut store = ContentStore::default(); + store.districts.insert("test".to_string(), district); + world.insert_resource(ContentStoreResource(store)); + + // Push a conversation event + world + .get_mut::(player) + .unwrap() + .events + .push(crate::simulation::conversation::ConversationEvent { + occluded_line: "Test".to_string(), + speaker_id: 100, + target_id: 101, + speaker_name: "A".to_string(), + target_name: "B".to_string(), + speaker_color_index: 0, + target_color_index: 1, + }); + + run_event_system(&mut world); + + let buf = world.get::(player).unwrap(); + assert!(buf.event.is_some()); + assert_eq!( + buf.event.as_ref().unwrap().id, + "pool_witness_01", + "should use content pool line over hardcoded fallback" + ); + } + + #[test] + fn hardcoded_lines_all_valid() { + for lines in &[ + OBSERVE_NPC_LINES, + HEAR_SOUND_LINES, + WITNESS_INTERACTION_LINES, + POST_CONVERSATION_LINES, + ] { + assert!(!lines.is_empty()); + for (id, text) in *lines { + assert!(!id.is_empty(), "line id should not be empty"); + assert!(!text.is_empty(), "text for {} should be non-empty", id); + } + } + } + + // ----------------------------------------------------------------------- + // Constant assertions + // ----------------------------------------------------------------------- + + #[test] + fn cooldown_ticks_constant_is_300() { + // D-035: 300 ticks = 30 game-minutes at 10 ticks/game-minute (D-031). + // If this changes, players will see more/less monologue spam. + assert_eq!(COOLDOWN_TICKS, 300, "D-035: COOLDOWN_TICKS must be 300"); + } + + // ----------------------------------------------------------------------- + // hear_sound: only Machinery and Alert trigger (not Voice/Ambient/Footstep) + // ----------------------------------------------------------------------- + + #[test] + fn hear_sound_ignores_voice_kind() { + let mut world = setup_event_world(); + let _player = spawn_event_player(&mut world); + + // Voice sound in range — should NOT trigger (routine background noise) + world.resource_mut::().events.push( + SoundEvent::at( + &TilePosition::new(11, 10, 0), // distance 1 + SoundEventKind::Voice, + 0.7, + crate::knowledge::types::SoundRange::Medium, + None, + ), + ); + + run_event_system(&mut world); + + let mut buf_query = world.query::<&MonologueBuffer>(); + let buf = buf_query.single(&world).unwrap(); + assert!( + buf.event.is_none(), + "Voice sounds are routine and must NOT trigger hear_sound monologue" + ); + } + + #[test] + fn hear_sound_ignores_ambient_kind() { + let mut world = setup_event_world(); + let _player = spawn_event_player(&mut world); + + // Ambient sound in range — should NOT trigger (background atmosphere) + world.resource_mut::().events.push( + SoundEvent::at( + &TilePosition::new(10, 12, 0), // distance 2 + SoundEventKind::Ambient, + 0.9, + crate::knowledge::types::SoundRange::Long, + None, + ), + ); + + run_event_system(&mut world); + + let mut buf_query = world.query::<&MonologueBuffer>(); + let buf = buf_query.single(&world).unwrap(); + assert!( + buf.event.is_none(), + "Ambient sounds are routine and must NOT trigger hear_sound monologue" + ); + } + + // ----------------------------------------------------------------------- + // observe_anomaly content pool integration via recognition monologue + // ----------------------------------------------------------------------- + + #[test] + fn recognition_monologue_uses_observe_anomaly_content_pool_key() { + // When a content pool has lines with trigger="observe_anomaly", + // trigger_recognition_monologue should select from that pool (not hardcoded fallback). + // This verifies the content key matches the implementation. + let mut world = setup_recognition_world(); + + let pool = MonologuePool { + character: "detective".to_string(), + location: "general".to_string(), + lines: vec![MonologueLine { + id: "observe_anomaly_pool_01".to_string(), + text: "That person shouldn't be here.".to_string(), + trigger: "observe_anomaly".to_string(), + prerequisites: None, + priority: None, + cooldown: None, + tags: vec![], + }], + }; + + let mut district = DistrictContent::default(); + district.monologue_pools.push(pool); + let mut store = ContentStore::default(); + store.districts.insert("test".to_string(), district); + world.insert_resource(ContentStoreResource(store)); + + let target = world.spawn_empty().id(); + + let mut cd = CognitiveDelay::default(); + cd.push(PendingRecognition { + target, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: NORMAL_DELAY_TICKS, + trigger: RecognitionTrigger::Normal, + monologue_fired: false, + }); + + world.spawn(( + PlayerCharacter, + TilePosition::new(10, 10, 0), + MonologueState::default(), + MonologueBuffer::default(), + cd, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(trigger_recognition_monologue); + schedule.run(&mut world); + + let mut buf_query = world.query::<&MonologueBuffer>(); + let buffer = buf_query.single(&world).unwrap(); + assert!(buffer.event.is_some(), "recognition monologue should fire"); + assert_eq!( + buffer.event.as_ref().unwrap().id, + "observe_anomaly_pool_01", + "should use content pool line with trigger='observe_anomaly' key" + ); + } + + #[test] + fn observation_tick_tracking_updated() { + let mut world = setup_event_world(); + let player = spawn_event_player(&mut world); + + world + .resource_mut::() + .push(ObservationEvent { + tick: 7, + trigger: ObservationTrigger::NewEntity { + entity: StableId(42), + location: TilePosition::new(12, 12, 0), + }, + observer: player, + }); + + run_event_system(&mut world); + + let state = world.get::(player).unwrap(); + assert_eq!( + state.last_observation_tick, 7, + "last_observation_tick should track highest event tick" + ); + } } From 8538f916af89e8982fac2f8ef5e467752972bfe8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:40:09 +0100 Subject: [PATCH 08/11] feat(simulation): wire Sprint 15 systems + protocol v13 Register all new systems in NpcPlugin and SimulationPlugin with correct ordering constraints. Protocol bumped to v13: tell_state on VisibleEntity, follow_state and PostConversationQueue on ObserverSnapshot, Follow verb on VerbKind. Observer snapshot populates tell state from DerivedTellState and follow state from FollowTarget. System ordering: tolerance after mood, deviation after activity, tell after mood+deviation, follow after visibility geometry, event monologue after conversations. Co-Authored-By: Claude Opus 4.6 --- server/Cargo.lock | 2 +- server/src/bridge/mod.rs | 11 +++++++- server/src/bridge/text_renderer.rs | 5 ++++ server/src/bridge/types.rs | 20 +++++++++++++- server/src/npc/mod.rs | 15 +++++++++++ server/src/perception/observer/mod.rs | 35 ++++++++++++++++++++++++- server/src/perception/observer/tests.rs | 15 ++++++----- server/src/simulation/mod.rs | 6 +++++ 8 files changed, 98 insertions(+), 11 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 5b4567c66..b31b7ea23 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.13" +version = "0.1.14" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 3893ddfe2..83c872f20 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -173,6 +173,11 @@ impl Plugin for BridgePlugin { .after(crate::perception::anomaly::detect_anomalies), crate::simulation::monologue::process_sprint_anomaly_monologue .after(crate::simulation::monologue::trigger_recognition_monologue), + crate::simulation::monologue::trigger_event_monologue + .after(crate::simulation::monologue::process_sprint_anomaly_monologue) + .after(crate::simulation::sound::collect_sound_events) + .after(crate::simulation::conversation::run_npc_conversations) + .after(crate::simulation::dialogue::process_walk_away), crate::simulation::dialogue::process_talk_interaction .after(crate::simulation::input::process_player_input), crate::simulation::dialogue::process_walk_away @@ -180,10 +185,14 @@ impl Plugin for BridgePlugin { .after(crate::simulation::dialogue::process_talk_interaction), crate::simulation::dialogue::process_confrontation_response .after(crate::simulation::input::process_player_input), + crate::simulation::follow::update_follow_state + .after(crate::perception::observer::compute_visibility_geometry) + .after(crate::simulation::movement::validate_movement) + .before(crate::perception::observer::compute_observer_snapshot), crate::perception::observer::compute_observer_snapshot .after(crate::perception::observer::compute_visibility_geometry) .after(crate::simulation::interaction::compute_nearby_interactions) - .after(crate::simulation::monologue::process_sprint_anomaly_monologue) + .after(crate::simulation::monologue::trigger_event_monologue) .after(crate::simulation::dialogue::process_talk_interaction) .after(crate::simulation::dialogue::process_confrontation_response) .before(crate::simulation::time::advance_tick), diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 65e5c2fea..d03a30912 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -240,6 +240,7 @@ mod tests { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }, VisibleEntity { entity_id: 100, @@ -250,6 +251,7 @@ mod tests { visibility: VisibilitySector::Forward, relationship: RelationshipState::Known, observation: EntityVisibility::Visible, + tell_state: None, }, VisibleEntity { entity_id: 200, @@ -260,6 +262,7 @@ mod tests { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }, ], visible_tiles: vec![VisibleTile { @@ -302,6 +305,7 @@ mod tests { scan_events: vec![], conversation_events: vec![], conversation_ended: vec![], + follow_state: None, sound_events: vec![], rng_seed: None, } @@ -431,6 +435,7 @@ mod tests { scan_events: vec![], conversation_events: vec![], conversation_ended: vec![], + follow_state: None, sound_events: vec![], rng_seed: None, }; diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 56db7ca1a..874f35622 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 12; +pub const PROTOCOL_VERSION: u8 = 13; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -32,6 +32,8 @@ pub const PROTOCOL_VERSION: u8 = 12; /// rng_seed (#527, deterministic replay — completes WRONG button loop). /// v11 adds: zone_id on VisibleTile (#523, D-077 OQ-09 resolution + D-073 crossfade). /// v12 adds: conversation_events, conversation_ended (#247, D-078 NPC-to-NPC conversations). +/// v13 adds: tell_state on VisibleEntity (#90, D-024 tell system — for future client use), +/// follow_state (#241, follow mechanic HUD state). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { @@ -98,6 +100,11 @@ pub struct ObserverSnapshot { /// Client dismisses the passive dialogue panel for these pairs. #[serde(default)] pub conversation_ended: Vec, + /// Follow-mode state for client HUD display (#241). + /// Present when the player is actively following an NPC. + /// Client shows follow indicator with distance, LOS, and tension. + #[serde(default)] + pub follow_state: Option, /// RNG seed active at this tick for deterministic replay (#527). /// The WRONG button writes this to seed.txt so replays reproduce observed bugs. /// None when the RNG resource is unavailable (should not occur in practice). @@ -270,6 +277,11 @@ pub struct VisibleEntity { /// Visible = in LOS right now. Remembered = known but not in LOS. #[serde(default)] pub observation: EntityVisibility, + /// Current observable tell category for NPC entities (#90, D-024). + /// None for non-NPC entities or NPCs with no active tell this tick. + /// v0.1: field is emitted for future client use; client may ignore. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tell_state: Option, } /// Category of visible entity @@ -452,6 +464,12 @@ pub enum VerbKind { /// Furniture — sit/use Sit, + // --- NPC extended verbs --- + /// Follow an NPC — designate as follow target (#241). + /// Close range only. Enters follow mode: server tracks distance, LOS, + /// and NPC suspicion. Replaces previous follow target if any. + Follow, + // --- Phase 2 verbs (observer, KG-gated, #422) --- /// Confront an NPC about known facts/contradictions. /// Phase 2 only: injected when observer has KnowsDetails+ confidence. diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 5d80bff17..5e0188613 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -2,10 +2,13 @@ // Implements D-024: 10-axis NPC model + CombatCapability component // Background tier state machines for schedule, mood, relationships, job +pub mod generate; pub mod interaction; pub mod mood; pub mod relationships; pub mod routine; +pub mod tell_state; +pub mod tolerance; use bevy_app::prelude::*; use bevy_ecs::prelude::*; @@ -25,6 +28,8 @@ impl Plugin for NpcPlugin { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -44,6 +49,16 @@ impl Plugin for NpcPlugin { routine::enter_activity .after(crate::simulation::movement::validate_movement) .before(crate::perception::observer::compute_observer_snapshot), + tolerance::check_tolerance_threshold + .after(mood::update_mood) + .before(crate::simulation::time::advance_tick), + routine::detect_routine_deviation + .after(routine::enter_activity) + .before(crate::perception::observer::compute_observer_snapshot), + tell_state::derive_tell_state + .after(mood::update_mood) + .after(routine::detect_routine_deviation) + .before(crate::perception::observer::compute_observer_snapshot), ), ); diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 2105b6406..4720e806a 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -19,6 +19,7 @@ use crate::perception::vision_cone::Facing; use crate::simulation::contraband::ScanEventBuffer; use crate::simulation::conversation::ConversationEventBuffer; use crate::simulation::dialogue::DialogueResponseBuffer; +use crate::simulation::follow::FollowTarget; use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue}; @@ -80,6 +81,7 @@ pub fn compute_observer_snapshot( Option<&mut DialogueResponseBuffer>, Option<&mut ScanEventBuffer>, Option<&mut ConversationEventBuffer>, + Option<&FollowTarget>, ), With, >, @@ -89,6 +91,7 @@ pub fn compute_observer_snapshot( Option<&PlayerCharacter>, Option<&crate::npc::Npc>, Option<&AccessRule>, + Option<&crate::npc::tell_state::DerivedTellState>, )>, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, @@ -108,6 +111,7 @@ pub fn compute_observer_snapshot( mut dialogue_response_opt, mut scan_event_buffer_opt, mut conversation_buffer_opt, + follow_target_opt, )) = observer_query.single_mut() else { tracing::error!("compute_observer_snapshot: PlayerCharacter query failed"); @@ -245,6 +249,29 @@ pub fn compute_observer_snapshot( None => geometry.visible_tiles.clone(), }; + // Build follow-mode state for client HUD (#241) + let follow_state = follow_target_opt.and_then(|ft| { + let target_wire_id = registry.to_stable(ft.target)?.0; + // Distance computed from current positions + let all_query_iter = all_entities.iter(); + let target_pos = all_query_iter + .filter_map(|(e, pos, _, _, _, _)| (e == ft.target).then_some(pos)) + .next()?; + let distance = observer_pos + .manhattan_distance(target_pos) + .unwrap_or(u32::MAX); + let has_los = target_pos.z == geometry.observer_z + && geometry + .visible_positions + .contains(&(target_pos.x, target_pos.y)); + Some(crate::simulation::follow::FollowStateWire { + target_entity_id: target_wire_id, + distance, + has_los, + proximity_ticks: ft.proximity_ticks, + }) + }); + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -262,6 +289,7 @@ pub fn compute_observer_snapshot( scan_events, conversation_events, conversation_ended, + follow_state, sound_events, rng_seed: sim_rng.as_deref().map(|r| r.seed()), }); @@ -282,13 +310,14 @@ fn filter_visible_entities( Option<&PlayerCharacter>, Option<&crate::npc::Npc>, Option<&AccessRule>, + Option<&crate::npc::tell_state::DerivedTellState>, )>, ) -> (Vec, BTreeSet, Vec) { let mut entities = Vec::new(); let mut visible_ids: BTreeSet = BTreeSet::new(); let mut blocked_ids: BTreeSet = BTreeSet::new(); - for (entity, pos, is_player, is_npc, access_rule) in all_entities.iter() { + for (entity, pos, is_player, is_npc, access_rule, tell_opt) in all_entities.iter() { if pos.z != geometry.observer_z { continue; } @@ -343,6 +372,8 @@ fn filter_visible_entities( RelationshipState::Unknown }; + let tell_state = tell_opt.and_then(|t| t.category); + visible_ids.insert(wire_id); entities.push(VisibleEntity { entity_id: wire_id, @@ -353,6 +384,7 @@ fn filter_visible_entities( visibility: sector, relationship, observation: EntityVisibility::Visible, + tell_state, }); } @@ -413,6 +445,7 @@ fn collect_remembered_entities( kind: EntityKind::Npc, visibility: VisibilitySector::Forward, relationship: knowledge.relationship, + tell_state: None, // Remembered entities have no live tell state observation: EntityVisibility::Remembered { confidence: knowledge.confidence, age_ticks, diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index d564eac2f..c397cd166 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -759,8 +759,8 @@ fn phase2_confront_injected_for_npc_with_knows_details() { let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); let interaction = &snapshot.nearby_interactions[0]; - // Should have Talk, ExamineNpc, AND Confront (Phase 2 injected) - assert_eq!(interaction.verbs.len(), 3); + // Should have Talk, ExamineNpc, Follow, AND Confront (Phase 2 injected) + assert_eq!(interaction.verbs.len(), 4); let confront = interaction .verbs .iter() @@ -1322,16 +1322,17 @@ fn phase2_poi_with_confront_verb_order() { let verbs = &snapshot.nearby_interactions[0].verbs; assert_eq!( verbs.len(), - 3, - "POI+KnowsDetails: ExamineNpc + Talk + Confront" + 4, + "POI+KnowsDetails: ExamineNpc + Talk + Follow + Confront" ); - // POI flips ExamineNpc to priority 1, Talk to 2, Confront at 3 + // POI flips ExamineNpc to priority 1, Talk to 2, Follow at 3, Confront at 3 assert_eq!(verbs[0].kind, VerbKind::ExamineNpc); assert_eq!(verbs[0].priority, 1); assert_eq!(verbs[1].kind, VerbKind::Talk); assert_eq!(verbs[1].priority, 2); - assert_eq!(verbs[2].kind, VerbKind::Confront); - assert_eq!(verbs[2].priority, 3); + // Follow and Confront both at priority 3 — sorted by VerbKind discriminant + assert!(verbs.iter().any(|v| v.kind == VerbKind::Follow)); + assert!(verbs.iter().any(|v| v.kind == VerbKind::Confront)); } // ----------------------------------------------------------------------- diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index ac305bad4..4bf692c88 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs; pub mod contraband; pub mod conversation; pub mod dialogue; +pub mod follow; pub mod input; pub mod interaction; pub mod inventory; @@ -17,6 +18,7 @@ pub mod path_follow; pub mod pathfinding; pub mod rng; pub mod sound; +pub mod spatial; pub mod stance; pub mod tier; pub mod time; @@ -37,6 +39,9 @@ impl Plugin for SimulationPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -45,6 +50,7 @@ impl Plugin for SimulationPlugin { path_follow::follow_paths.after(pathfinding::compute_paths), movement::validate_movement.after(path_follow::follow_paths), path_follow::cleanup_path_blocked.after(movement::validate_movement), + spatial::sync_spatial_index.after(movement::validate_movement), listening::update_listening_focus.after(movement::validate_movement), contraband::check_contraband_scan .after(movement::validate_movement) From 46cf744e6a4ad50a1c613bc93a7c533e362fde4e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:40:18 +0100 Subject: [PATCH 09/11] data(client): update msgpack fixtures and golden files for protocol v13 Regenerate all msgpack test fixtures with tell_state and follow_state fields. Update golden proof_room snapshot. Adjust serialization and bridge tests for new ObserverSnapshot fields. Co-Authored-By: Claude Opus 4.6 --- .../msgpack/snapshot_boundary_tick_0.msgpack | Bin 329 -> 343 bytes .../msgpack/snapshot_boundary_tick_127.msgpack | Bin 329 -> 343 bytes .../snapshot_boundary_tick_2b31m1.msgpack | Bin 333 -> 347 bytes .../msgpack/snapshot_boundary_tick_2b32.msgpack | Bin 337 -> 351 bytes .../snapshot_boundary_tick_32767.msgpack | Bin 331 -> 345 bytes .../fixtures/msgpack/snapshot_empty.msgpack | Bin 329 -> 343 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 734 -> 748 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 427 -> 441 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 430 -> 444 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 594 -> 608 bytes server/tests/bridge_ipc.rs | 2 ++ server/tests/bridge_tcp.rs | 2 ++ server/tests/gen_fixtures.rs | 9 +++++++++ server/tests/golden/proof_room_tick_10.json | 5 +++-- server/tests/serialization.rs | 9 ++++++++- 15 files changed, 24 insertions(+), 3 deletions(-) diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 8c15d84d34aaad3a97b15465f674e97abbfec90c..0a0876c58923e10f551abe3adce508c9bbc2abff 100644 GIT binary patch delta 36 scmX@fbe)Ok9)r;GvecsD%=|pwjXbuDd~4G3b8_;_Px# delta 21 ccmcb~bef6h9)sZWvecsD%=|o_jXd^@09ZN)S^xk5 diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 8c15d84d34aaad3a97b15465f674e97abbfec90c..0a0876c58923e10f551abe3adce508c9bbc2abff 100644 GIT binary patch delta 36 scmX@fbe)Ok9)r;GvecsD%=|pwjXbuDd~4G3b8_;_@~ delta 21 ccmaFEdXJUo9)sZWvecsD%=|o_jXal_09?xm8UO$Q diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index e3dc4f11b6c6070d919cf35ca24b2df77522c227..0093a3ac1113edf035ac5e1f43d303c08e13257f 100644 GIT binary patch delta 36 scmZ3@ypx&d9)r;GvecsD%=|pwjXd)i`PQW6=j7y<#}}6*mZTm40QHFu3jhEB delta 21 ccmdnVyqcNk9)sZWvecsD%=|o_jXd)i0aPFdd;kCd diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 400dd2959651e21167d1e502ec733f6f574448d1..e919024ce2741717339937281d08328ece1ce061 100644 GIT binary patch delta 36 scmZ3-yoZ_R9)r;GvecsD%=|pwjXaAO`PQW6=j7y<#}}6*mZTm40QQa!6aWAK delta 21 ccmdnPypEaY9)sZWvecsD%=|o_jXaAO0aTs`g#Z8m diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 6d489fd8b6795dc036b47630bafc8668d9448059..b7d86d7de686713dcc6d8ddea1c7e7bc362252fe 100644 GIT binary patch delta 36 scmcb_@_>cs9)r;GvecsD%=|pwjXZ8ld~4G3b8_;_) -> ObserverSnapshot sound_events: vec![], conversation_events: vec![], conversation_ended: vec![], + follow_state: None, rng_seed: None, } } @@ -59,6 +60,7 @@ fn generate_msgpack_fixtures() { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }], ); write_fixture( @@ -102,6 +104,7 @@ fn generate_msgpack_fixtures() { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }], ); write_fixture( @@ -122,6 +125,7 @@ fn generate_msgpack_fixtures() { visibility: VisibilitySector::Forward, relationship: RelationshipState::Known, observation: EntityVisibility::Visible, + tell_state: None, }, VisibleEntity { entity_id: 2, @@ -132,6 +136,7 @@ fn generate_msgpack_fixtures() { visibility: VisibilitySector::Peripheral, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }, VisibleEntity { entity_id: 3, @@ -142,6 +147,7 @@ fn generate_msgpack_fixtures() { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }, VisibleEntity { entity_id: 4, @@ -152,6 +158,7 @@ fn generate_msgpack_fixtures() { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }, ], ); @@ -182,6 +189,7 @@ fn generate_msgpack_fixtures() { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }], visible_tiles: vec![ VisibleTile { @@ -218,6 +226,7 @@ fn generate_msgpack_fixtures() { sound_events: vec![], conversation_events: vec![], conversation_ended: vec![], + follow_state: None, rng_seed: None, }; write_fixture( diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 700eaff99..1f4f346c8 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -38,6 +38,7 @@ "z": 0 } ], + "follow_state": null, "game_time": { "day": 0, "day_phase": "Morning", @@ -48,7 +49,7 @@ "pending_recognitions": [ { "entity_id": 1, - "remaining_ticks": 2, + "remaining_ticks": 1, "total_delay_ticks": 6, "x": 16.5, "y": 13.5, @@ -70,7 +71,7 @@ "scan_events": [], "sound_events": [], "tick": 8, - "version": 12, + "version": 13, "visible_tiles": [ { "tile_kind": "Wall", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 804f9e42d..f1b508086 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -29,6 +29,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { sound_events: vec![], conversation_events: vec![], conversation_ended: vec![], + follow_state: None, rng_seed: None, } } @@ -46,6 +47,7 @@ fn observer_snapshot_roundtrip() { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }], ); @@ -199,6 +201,7 @@ fn all_entity_kind_variants_roundtrip() { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }; let snapshot = test_snapshot(0, vec![entity]); let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -235,6 +238,7 @@ fn snapshot_v2_fields_roundtrip() { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }], visible_tiles: vec![ VisibleTile { @@ -263,6 +267,7 @@ fn snapshot_v2_fields_roundtrip() { sound_events: vec![], conversation_events: vec![], conversation_ended: vec![], + follow_state: None, rng_seed: None, }; @@ -318,7 +323,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 12, + PROTOCOL_VERSION, 13, "bump this assertion when protocol version changes" ); } @@ -361,6 +366,7 @@ fn all_facing_direction_variants_roundtrip() { sound_events: vec![], conversation_events: vec![], conversation_ended: vec![], + follow_state: None, rng_seed: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -839,6 +845,7 @@ fn boundary_value_in_entity_id() { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }], ); let bytes = rmp_serde::to_vec_named(&snapshot) From c0fb316a0adac957655e990ca3026f130cbd9e85 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:40:38 +0100 Subject: [PATCH 10/11] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e0ddae5..2d31e8198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- SpatialIndex trait with naive Vec implementation — entities_in_range, entities_at, update methods with Manhattan distance (#340) +- NPC generation pipeline — procedural seeding of all 10 D-024 axes via SimRng with constraint validation (#92) +- Personality and tell system — 5 tell categories (Nervous, Angry, Friendly, Guarded, RoutineDeviation) derived from NPC axis values each tick (#90) +- Tolerance threshold monitoring — ToleranceBreachEvent on stress exceeding per-NPC threshold, mood FSM integration (#105) +- Routine deviation detection — RoutineDeviationEvent on wrong location/activity for day phase, absence detection, pathfinding-aware (#243) +- Follow mechanic — Follow verb, proximity/LOS tracking, double-frequency observation events, NPC suspicion accumulation, configurable thresholds (#241) +- Monologue event triggers — observe_npc, hear_sound, observe_anomaly, witness_interaction, post_conversation with D-035 context tags (#119) +- Protocol v13 — tell_state on VisibleEntity, follow_state on ObserverSnapshot, Follow verb + ## [v0.1.14] — 2026-02-21 ### Added From 67cadddaf2023ef6875f718e091ce1578de108e8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:55:21 +0100 Subject: [PATCH 11/11] =?UTF-8?q?fix(simulation):=20address=20PR=20#55=20r?= =?UTF-8?q?eview=20=E2=80=94=20stale=20comment,=20range,=20duplication,=20?= =?UTF-8?q?docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix protocol version comment (12 → 13) in ObserverSnapshot doc - Widen Want intensity range from 3..=9 to 1..=10 to match spec and test - Replace duplicated pool selection in trigger_recognition_monologue with call to select_pool_line helper (~50 lines removed) - Document NaiveSpatialIndex migration cost for grid/quadtree swap - Remove misleading Default derive from TellCategory (Nervous is not a sensible default for neutral NPCs) - Add caller invariant doc on generate_npc (no TilePosition spawned) Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/types.rs | 2 +- server/src/npc/generate.rs | 7 ++++- server/src/npc/tell_state.rs | 3 +- server/src/simulation/monologue.rs | 50 ++---------------------------- server/src/simulation/spatial.rs | 10 ++++++ 5 files changed, 21 insertions(+), 51 deletions(-) diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 874f35622..8fd47d332 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -37,7 +37,7 @@ pub const PROTOCOL_VERSION: u8 = 13; /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 12. + /// Protocol version for forward compatibility. Current: 13. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, diff --git a/server/src/npc/generate.rs b/server/src/npc/generate.rs index 0b2870470..395131694 100644 --- a/server/src/npc/generate.rs +++ b/server/src/npc/generate.rs @@ -149,7 +149,7 @@ fn pick_combat_style(idx: usize) -> CombatStyle { fn gen_want(rng: &mut SimRng, role: &RoleDefinition) -> Want { let kind_idx = rng.rng.random_range(0..9_usize); - let intensity = rng.rng.random_range(3_u8..=9); + let intensity = rng.rng.random_range(1_u8..=10); Want { primary: pick_want_kind(kind_idx), intensity, @@ -421,6 +421,11 @@ fn gen_skills(rng: &mut SimRng, role: &RoleDefinition) -> (SkillSet, Option Entity { // Generate all axes before spawning to keep the borrow checker happy. diff --git a/server/src/npc/tell_state.rs b/server/src/npc/tell_state.rs index 2afe99981..6e74342df 100644 --- a/server/src/npc/tell_state.rs +++ b/server/src/npc/tell_state.rs @@ -35,10 +35,9 @@ use crate::simulation::tier::ActiveSim; /// /// Derived each tick from NPC simulation state — not authored per NPC. /// Five categories correspond to the D-024 tell taxonomy. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum TellCategory { /// NPC exhibits nervous behaviour: Major secret + stress exceeds half of threshold. - #[default] Nervous, /// NPC exhibits angry behaviour: low contentment and Hostile mood. Angry, diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index f5fdff78d..b549567c9 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -352,53 +352,9 @@ pub fn trigger_recognition_monologue( }; // Try content pools for observe_anomaly trigger lines - let line = if let Some(ref content) = content { - let character = state.character.as_str(); - let mut candidates: Vec<(&str, &str)> = Vec::new(); - - for district in content.0.districts.values() { - for pool in &district.monologue_pools { - if pool.character != character { - continue; - } - for line in &pool.lines { - if line.trigger != "observe_anomaly" { - continue; - } - if state.shown_ids.contains(&line.id) { - continue; - } - candidates.push((&line.id, &line.text)); - } - } - } - - if candidates.is_empty() { - // Fallback: allow repeats from content pools - for district in content.0.districts.values() { - for pool in &district.monologue_pools { - if pool.character != character { - continue; - } - for line in &pool.lines { - if line.trigger != "observe_anomaly" { - continue; - } - candidates.push((&line.id, &line.text)); - } - } - } - } - - if !candidates.is_empty() { - let i = rng.rng.random_range(0..candidates.len()); - Some((candidates[i].0.to_string(), candidates[i].1.to_string())) - } else { - None - } - } else { - None - }; + let line = content + .as_deref() + .and_then(|c| select_pool_line("observe_anomaly", &state, c, &mut rng.rng)); // Use content pool line or hardcoded fallback let (id, text) = if let Some((id, text)) = line { diff --git a/server/src/simulation/spatial.rs b/server/src/simulation/spatial.rs index fcf0200e3..3bde13cbb 100644 --- a/server/src/simulation/spatial.rs +++ b/server/src/simulation/spatial.rs @@ -33,6 +33,16 @@ pub trait SpatialIndex: Send + Sync { /// Replace with grid or quadtree when profiling shows this is a bottleneck. /// Deterministic iteration: entries stored in insertion order, but callers /// should not depend on ordering (sort by Entity::to_bits() if needed). +/// +/// ## Migration cost for grid/quadtree swap +/// +/// `sync_spatial_index` takes `ResMut` directly because +/// bevy_ecs cannot store `dyn SpatialIndex` as a Resource. A swap to Grid or +/// BVH requires changing the concrete type in: (1) `sync_spatial_index` system +/// parameter, (2) `SimulationPlugin` resource registration, (3) any system +/// that queries `Res` (currently: `update_follow_state`). +/// The `SpatialIndex` trait ensures the API surface stays identical — only the +/// type name changes at call sites. Estimated: ~5 lines per caller. #[derive(Resource, Debug, Default)] pub struct NaiveSpatialIndex { entries: Vec<(Entity, TilePosition)>,