//! 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::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, observer_query: Query<(Entity, &KnowledgeGraph), With>, mut event_queue: ResMut, entity_positions: Query<&TilePosition>, ) { let Some(snapshot) = &buffer.snapshot else { return; }; let Ok((observer_entity, observer_kg)) = observer_query.single() else { return; }; // Collect visible entity IDs (Vec — linear search is faster at 5-20 entities) let visible_entity_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 for visible in &snapshot.entities { if matches!(visible.kind, EntityKind::Player) { continue; } // Look up the bevy Entity from the entity_id (which is Entity::to_bits()) let entity = Entity::from_bits(visible.entity_id); // Get tile position for knowledge tracking if let Ok(pos) = entity_positions.get(entity) { 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 is still visible in the current snapshot if let Some(entity) = registry.to_entity(stable_id) { let entity_bits = entity.to_bits(); if !visible_entity_ids.contains(&entity_bits) { event_queue.push(KnowledgeEvent { observer: observer_entity, tick: time.tick, event_type: KnowledgeEventType::LeftLOS { target: entity }, }); } } } } #[cfg(test)] mod tests { use super::*; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::npc::Npc; use crate::perception::observer::compute_observer_snapshot; 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 } #[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(), )) .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_observer_snapshot, emit_observation_events).chain()); 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, )) .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_observer_snapshot, emit_observation_events).chain()); 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(), )) .id(); registry.register(player); world.insert_resource(registry); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems((compute_observer_snapshot, emit_observation_events).chain()); schedule.run(&mut world); let queue = world.resource::(); assert!( queue.is_empty(), "should not emit observation events for the player entity itself" ); } }