From 9c3c32e854e559a9181432bb1d8b21b0708ed54f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 17:52:02 +0100 Subject: [PATCH] feat(perception): add observation event generator Implements ticket #239 for Sprint 3: - ObservationTrigger enum: RoutineDeviation (NPC not at expected routine location), Absence (expected location visible but NPC missing), NewEntity (unknown entity in LOS) - ObservationEventQueue resource with push/drain/len/is_empty - generate_observation_events system runs after emit_observation_events but before process_knowledge_events so it can compare current snapshot against previous-tick knowledge state - Populate PerceptionPlugin with ObservationEventQueue resource and system registration with correct ordering constraints Co-Authored-By: Claude Opus 4.6 --- server/src/perception/interpretation.rs | 452 ++++++++++++++++++++++++ server/src/perception/mod.rs | 12 +- 2 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 server/src/perception/interpretation.rs diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs new file mode 100644 index 000000000..8d2eb9c2a --- /dev/null +++ b/server/src/perception/interpretation.rs @@ -0,0 +1,452 @@ +//! Observation event generator (#239). +//! +//! Interprets what the observer sees (and doesn't see) against known NPC +//! routines and knowledge graph state. Produces high-level observation events +//! that drive monologue and investigation triggers. + +use bevy_ecs::prelude::*; + +use crate::bridge::types::*; +use crate::knowledge::types::StableId; +use crate::knowledge::{EntityRegistry, KnowledgeGraph}; +use crate::npc::{DailyRoutine, Npc}; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::time::SimulationTime; + +/// What triggered an observation event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ObservationTrigger { + /// NPC is visible but not at their expected routine location. + RoutineDeviation { + npc: StableId, + expected: TilePosition, + actual: TilePosition, + }, + /// Known NPC's expected routine location is visible, but the NPC is not there. + Absence { + npc: StableId, + expected: TilePosition, + }, + /// An entity visible in LOS that the observer has no prior knowledge of. + NewEntity { + entity: StableId, + location: TilePosition, + }, +} + +/// A single observation event produced by the interpretation system. +#[derive(Debug, Clone)] +pub struct ObservationEvent { + pub tick: u64, + pub trigger: ObservationTrigger, + pub observer: Entity, +} + +/// Resource: queue of observation events for downstream systems (monologue, UI). +#[derive(Resource, Debug, Default)] +pub struct ObservationEventQueue { + events: Vec, +} + +impl ObservationEventQueue { + pub fn push(&mut self, event: ObservationEvent) { + 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() + } +} + +/// System: interpret visible snapshot against known routines and knowledge. +/// +/// Runs after knowledge events are processed so the knowledge graph is up-to-date. +/// Produces observation events for: routine deviations, absences, new entities. +pub fn generate_observation_events( + time: Res, + buffer: Res, + registry: Res, + observer_query: Query<(Entity, &KnowledgeGraph), With>, + npc_query: Query<(&TilePosition, &DailyRoutine), With>, + mut event_queue: ResMut, +) { + let Some(snapshot) = &buffer.snapshot else { + return; + }; + + let Ok((observer_entity, observer_kg)) = observer_query.single() else { + return; + }; + + let current_phase = time.day_phase(); + + // Build set of visible tile positions for absence checks + let visible_tile_set: std::collections::HashSet<(i32, i32, i32)> = snapshot + .visible_tiles + .iter() + .map(|t| (t.x, t.y, t.z)) + .collect(); + + // Collect visible NPC entity bits for absence checks + let visible_npc_bits: std::collections::HashSet = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .map(|e| e.entity_id) + .collect(); + + // --- Routine deviation + New entity detection --- + for visible in &snapshot.entities { + if matches!(visible.kind, EntityKind::Player) { + continue; + } + + let entity = Entity::from_bits(visible.entity_id); + + // Check if this is a new entity (not in observer's knowledge graph) + if let Some(stable_id) = registry.to_stable(entity) { + if !observer_kg.knows_entity(&stable_id) { + // Reconstruct tile position from render coords + let tile_pos = TilePosition::from_render_coords(visible.x, visible.y, visible.z); + event_queue.push(ObservationEvent { + tick: time.tick, + trigger: ObservationTrigger::NewEntity { + entity: stable_id, + location: tile_pos, + }, + observer: observer_entity, + }); + } + } + + // Check routine deviation: visible NPC not at expected location + if let Ok((actual_pos, routine)) = npc_query.get(entity) { + if let Some(expected_pos) = routine.expected_location(current_phase) { + if *actual_pos != expected_pos { + if let Some(stable_id) = registry.to_stable(entity) { + event_queue.push(ObservationEvent { + tick: time.tick, + trigger: ObservationTrigger::RoutineDeviation { + npc: stable_id, + expected: expected_pos, + actual: *actual_pos, + }, + observer: observer_entity, + }); + } + } + } + } + } + + // --- Absence detection --- + // For each known NPC not in visible set, check if their expected routine + // location IS in our visible tiles (meaning we can see the spot but + // the NPC isn't there). + for (stable_id, _knowledge) in observer_kg.known_entities_iter() { + let Some(entity) = registry.to_entity(stable_id) else { + continue; + }; + + // Skip if currently visible + if visible_npc_bits.contains(&entity.to_bits()) { + continue; + } + + // Check if this NPC has a routine with an expected location + if let Ok((_pos, routine)) = npc_query.get(entity) { + if let Some(expected_pos) = routine.expected_location(current_phase) { + // If we can see the expected location but the NPC isn't there + if visible_tile_set.contains(&(expected_pos.x, expected_pos.y, expected_pos.z)) { + event_queue.push(ObservationEvent { + tick: time.tick, + trigger: ObservationTrigger::Absence { + npc: *stable_id, + expected: expected_pos, + }, + observer: observer_entity, + }); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::registry::EntityRegistry; + use crate::npc::RoutineEntry; + use crate::perception::observer::compute_observer_snapshot; + use crate::perception::vision_cone::Facing; + use crate::simulation::movement::WalkabilityMap; + use crate::simulation::time::{DayPhase, MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE}; + + fn setup_world() -> World { + let mut world = World::new(); + world.insert_resource(SimulationTime::default()); + world.insert_resource(WalkabilityMap::new(32, 32, 1)); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world + } + + /// Run the observation pipeline: snapshot -> emit -> interpret -> knowledge update. + /// Interpretation runs BEFORE knowledge updates so it can detect new entities + /// and compare against the PREVIOUS tick's knowledge state. + fn run_pipeline(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(( + compute_observer_snapshot, + crate::perception::observation::emit_observation_events + .after(compute_observer_snapshot), + generate_observation_events + .after(crate::perception::observation::emit_observation_events), + crate::knowledge::events::process_knowledge_events + .after(generate_observation_events), + )); + schedule.run(world); + } + + #[test] + fn routine_deviation_detected() { + let mut world = setup_world(); + let mut registry = EntityRegistry::new(0); + + // Set time to Afternoon + world.resource_mut::().tick = + MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + )) + .id(); + registry.register(player); + + // NPC at (16,14) but routine says they should be at (20,10) in Afternoon + let npc = world + .spawn(( + Npc, + TilePosition::new(16, 14, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: TilePosition::new(20, 10, 0), + activity: "Work".into(), + }], + description: "Test".into(), + }, + )) + .id(); + let npc_sid = registry.register(npc); + + world.insert_resource(registry); + run_pipeline(&mut world); + + let queue = world.resource::(); + let deviations: Vec<_> = queue + .events + .iter() + .filter(|e| { + matches!( + &e.trigger, + ObservationTrigger::RoutineDeviation { npc, .. } if *npc == npc_sid + ) + }) + .collect(); + assert_eq!(deviations.len(), 1, "should detect routine deviation"); + } + + #[test] + fn no_deviation_at_correct_location() { + let mut world = setup_world(); + let mut registry = EntityRegistry::new(0); + + // Time at Morning (tick 0, default) + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + )) + .id(); + registry.register(player); + + // NPC at (16,14) and routine says Morning at (16,14) + let npc = world + .spawn(( + Npc, + TilePosition::new(16, 14, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Morning, + location: TilePosition::new(16, 14, 0), + activity: "Work".into(), + }], + description: "Test".into(), + }, + )) + .id(); + registry.register(npc); + + world.insert_resource(registry); + run_pipeline(&mut world); + + let queue = world.resource::(); + let deviations: Vec<_> = queue + .events + .iter() + .filter(|e| matches!(&e.trigger, ObservationTrigger::RoutineDeviation { .. })) + .collect(); + assert!( + deviations.is_empty(), + "no deviation when NPC is at expected location" + ); + } + + #[test] + fn absence_when_location_visible() { + let mut world = setup_world(); + let mut registry = EntityRegistry::new(0); + + // NPC behind player (blind spot, not visible) but routine says + // Morning at (16,15) which IS in the player's forward view + let npc = world + .spawn(( + Npc, + TilePosition::new(16, 30, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Morning, + location: TilePosition::new(16, 15, 0), + activity: "Work".into(), + }], + description: "Test".into(), + }, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player knows about the NPC (has observed before) + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 0); + kg.observe_entity_leaving_los(&npc_sid, 1); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + )) + .id(); + registry.register(player); + + world.insert_resource(registry); + run_pipeline(&mut world); + + let queue = world.resource::(); + let absences: Vec<_> = queue + .events + .iter() + .filter(|e| { + matches!( + &e.trigger, + ObservationTrigger::Absence { npc, .. } if *npc == npc_sid + ) + }) + .collect(); + assert_eq!(absences.len(), 1, "should detect absence at visible location"); + } + + #[test] + fn new_entity_detected() { + let mut world = setup_world(); + let mut registry = EntityRegistry::new(0); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), // Empty — never seen anyone + )) + .id(); + registry.register(player); + + let npc = world + .spawn((Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + world.insert_resource(registry); + run_pipeline(&mut world); + + let queue = world.resource::(); + let new_entities: Vec<_> = queue + .events + .iter() + .filter(|e| { + matches!( + &e.trigger, + ObservationTrigger::NewEntity { entity, .. } if *entity == npc_sid + ) + }) + .collect(); + assert_eq!(new_entities.len(), 1, "should detect new entity"); + } + + #[test] + fn known_entity_no_new_event() { + let mut world = setup_world(); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player already knows about the NPC + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 0); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + )) + .id(); + registry.register(player); + + world.insert_resource(registry); + run_pipeline(&mut world); + + let queue = world.resource::(); + let new_entities: Vec<_> = queue + .events + .iter() + .filter(|e| matches!(&e.trigger, ObservationTrigger::NewEntity { .. })) + .collect(); + assert!( + new_entities.is_empty(), + "should not emit NewEntity for known entity" + ); + } +} diff --git a/server/src/perception/mod.rs b/server/src/perception/mod.rs index 0f1d09523..38ae3839d 100644 --- a/server/src/perception/mod.rs +++ b/server/src/perception/mod.rs @@ -3,7 +3,9 @@ // Generates ObserverSnapshot for client rendering use bevy_app::prelude::*; +use bevy_ecs::schedule::IntoScheduleConfigs; +pub mod interpretation; pub mod observation; pub mod observer; pub mod shadowcast; @@ -14,8 +16,14 @@ pub mod vision_cone; pub struct PerceptionPlugin; impl Plugin for PerceptionPlugin { - fn build(&self, _app: &mut App) { - // Stub implementation - will be populated in phase 2 + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems( + Update, + interpretation::generate_observation_events + .after(observation::emit_observation_events) + .before(crate::knowledge::events::process_knowledge_events), + ); tracing::debug!("PerceptionPlugin initialized"); } }