//! 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. //! //! Note: HashSet is used as a per-frame lookup table (visible tiles/NPCs). //! Only membership checks — iteration order is irrelevant. Not simulation state. #![allow(clippy::disallowed_types)] 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() } /// 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. /// /// Runs BEFORE knowledge events are processed so it can detect new entities /// by comparing visible NPCs against the previous tick's knowledge state. /// 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; } // Convert wire StableId back to bevy Entity via registry let stable_id = StableId(visible.entity_id); let Some(entity) = registry.to_entity(&stable_id) else { continue; }; // Check if this is a new entity (not in observer's knowledge graph) 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 { 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 (visible_npc_bits contains wire StableId values) if visible_npc_bits.contains(&stable_id.0) { 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, compute_visibility_geometry}; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; 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.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world } /// Run the observation pipeline: geometry -> snapshot -> emit -> interpret -> knowledge. fn run_pipeline(world: &mut World) { let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(( compute_visibility_geometry, compute_observer_snapshot.after(compute_visibility_geometry), 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(), crate::simulation::interaction::NearbyInteractionBuffer::default(), crate::simulation::monologue::MonologueBuffer::default(), )) .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(), crate::simulation::interaction::NearbyInteractionBuffer::default(), crate::simulation::monologue::MonologueBuffer::default(), )) .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, crate::simulation::interaction::NearbyInteractionBuffer::default(), crate::simulation::monologue::MonologueBuffer::default(), )) .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 crate::simulation::interaction::NearbyInteractionBuffer::default(), crate::simulation::monologue::MonologueBuffer::default(), )) .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, crate::simulation::interaction::NearbyInteractionBuffer::default(), crate::simulation::monologue::MonologueBuffer::default(), )) .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" ); } }