//! Direct observation knowledge flow (#364). //! //! Emits knowledge events when entities enter or leave the observer's LOS. //! Runs after compute_observer_snapshot, before process_knowledge_events. use bevy_ecs::prelude::*; use crate::bridge::types::*; use crate::knowledge::{ EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph, }; use crate::perception::cognitive_delay::{CognitiveDelay, PendingRecognition, RecognitionTrigger}; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; /// Emit knowledge events for entities entering/leaving the observer's LOS. /// /// Compares the current snapshot's visible entities against the observer's /// knowledge graph. Emits DirectObservation for newly visible entities and /// LeftLOS for entities that were Direct but are no longer in the snapshot. pub fn emit_observation_events( time: Res, buffer: Res, registry: Res, mut observer_query: Query< (Entity, &KnowledgeGraph, Option<&mut CognitiveDelay>), With, >, mut event_queue: ResMut, entity_positions: Query<&TilePosition>, anomaly_markers: Query<(), With>, ) { let Some(snapshot) = &buffer.snapshot else { return; }; let Ok((observer_entity, observer_kg, mut cognitive_delay)) = observer_query.single_mut() else { return; }; // Collect visible stable IDs (wire entity_id is now StableId, not Entity::to_bits()) let visible_stable_ids: Vec = snapshot .entities .iter() .filter(|e| !matches!(e.kind, EntityKind::Player)) .map(|e| e.entity_id) .collect(); // Emit DirectObservation for each visible non-player entity. // NEW entities (not in observer's KG) are routed through cognitive delay // when the CognitiveDelay component is present (#423, D-060). for visible in &snapshot.entities { if matches!(visible.kind, EntityKind::Player) { continue; } // Convert wire StableId back to bevy Entity via registry let stable_id = crate::knowledge::types::StableId(visible.entity_id); let Some(entity) = registry.to_entity(&stable_id) else { continue; }; // Get tile position for knowledge tracking let Ok(pos) = entity_positions.get(entity) else { continue; }; if observer_kg.knows_entity(&stable_id) { // Known entity: immediate DirectObservation (position update) event_queue.push(KnowledgeEvent { observer: observer_entity, tick: time.tick, event_type: KnowledgeEventType::DirectObservation { target: entity, position: *pos, }, }); } else if let Some(ref mut delay) = cognitive_delay { // New entity + cognitive delay available: buffer recognition if !delay.is_pending(&stable_id) { // #450: Urgent trigger for anomalous entities (D-060) let trigger = if anomaly_markers.get(entity).is_ok() { RecognitionTrigger::Urgent } else { RecognitionTrigger::Normal }; let delay_until = time.tick + trigger.delay_ticks(); delay.push(PendingRecognition { target: entity, stable_id, position: *pos, delay_until_tick: delay_until, trigger, monologue_fired: false, }); tracing::debug!( "Cognitive delay queued: stable_id={}, position=({},{},{}), trigger={:?}, delay_until={}", stable_id.0, pos.x, pos.y, pos.z, trigger, delay_until, ); } } else { // No CognitiveDelay component: immediate (backward compat) event_queue.push(KnowledgeEvent { observer: observer_entity, tick: time.tick, event_type: KnowledgeEventType::DirectObservation { target: entity, position: *pos, }, }); } } // Emit LeftLOS for entities that were Direct but are no longer visible for (stable_id, knowledge) in observer_kg.known_entities_iter() { if knowledge.confidence != crate::knowledge::KnowledgeConfidence::Direct { continue; } // Check if this entity's StableId is still visible in the current snapshot if !visible_stable_ids.contains(&stable_id.0) { if let Some(entity) = registry.to_entity(stable_id) { event_queue.push(KnowledgeEvent { observer: observer_entity, tick: time.tick, event_type: KnowledgeEventType::LeftLOS { target: entity }, }); } } } // Cancel pending cognitive delays for entities that left perception range. // Without this, a delay could expire and emit a DirectObservation for an // entity that is no longer visible (stale position, 1-tick flash). if let Some(ref mut delay) = cognitive_delay { let pending_ids: Vec = delay.pending().iter().map(|p| p.stable_id).collect(); for sid in pending_ids { if !visible_stable_ids.contains(&sid.0) && delay.cancel(&sid).is_some() { tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0); } } } } #[cfg(test)] mod tests { use super::*; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::npc::Npc; 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; fn setup_world(width: i32, height: i32) -> World { let mut world = World::new(); world.insert_resource(SimulationTime::default()); world.insert_resource(WalkabilityMap::new(width, height, 1)); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world } #[test] fn visible_npc_emits_direct_observation() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); 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); let npc = world.spawn((Npc, TilePosition::new(16, 14, 0))).id(); registry.register(npc); world.insert_resource(registry); // First: compute snapshot so NPC is visible let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(( compute_visibility_geometry, compute_observer_snapshot.after(compute_visibility_geometry), emit_observation_events.after(compute_observer_snapshot), )); schedule.run(&mut world); let queue = world.resource::(); assert!( !queue.is_empty(), "should emit DirectObservation for visible NPC" ); } #[test] fn npc_leaving_los_emits_left_los() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let npc = world.spawn((Npc, TilePosition::new(16, 14, 0))).id(); let npc_sid = registry.register(npc); // Player with pre-existing Direct knowledge of NPC let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); 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); // Now block the NPC behind a wall so it's NOT visible let mut walkability = world.resource_mut::(); walkability.set_walkable(&TilePosition::new(16, 15, 0), false); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(( compute_visibility_geometry, compute_observer_snapshot.after(compute_visibility_geometry), emit_observation_events.after(compute_observer_snapshot), )); schedule.run(&mut world); let queue = world.resource::(); let events = &queue.events; let has_left_los = events.iter().any(|e| { matches!( e.event_type, KnowledgeEventType::LeftLOS { target } if target == npc ) }); assert!( has_left_los, "should emit LeftLOS when NPC is no longer visible" ); } #[test] fn no_events_for_player_entity() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), crate::simulation::interaction::NearbyInteractionBuffer::default(), crate::simulation::monologue::MonologueBuffer::default(), )) .id(); registry.register(player); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(( compute_visibility_geometry, compute_observer_snapshot.after(compute_visibility_geometry), emit_observation_events.after(compute_observer_snapshot), )); schedule.run(&mut world); let queue = world.resource::(); assert!( queue.is_empty(), "should not emit observation events for the player entity itself" ); } }