diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index c115f68f3..dc6f398ee 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -177,6 +177,8 @@ impl Plugin for BridgePlugin { crate::perception::observer::compute_observer_snapshot .after(crate::simulation::movement::validate_movement) .before(crate::simulation::time::advance_tick), + crate::perception::observation::emit_observation_events + .after(crate::perception::observer::compute_observer_snapshot), send_bridge_snapshot .after(crate::perception::observer::compute_observer_snapshot), ), diff --git a/server/src/perception/mod.rs b/server/src/perception/mod.rs index 70dd3fb6d..0f1d09523 100644 --- a/server/src/perception/mod.rs +++ b/server/src/perception/mod.rs @@ -4,6 +4,7 @@ use bevy_app::prelude::*; +pub mod observation; pub mod observer; pub mod shadowcast; pub mod vision_cone; diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs new file mode 100644 index 000000000..a9eeed9d4 --- /dev/null +++ b/server/src/perception/observation.rs @@ -0,0 +1,215 @@ +//! 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 std::collections::HashSet; + +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: Option>, + observer_query: Query<(Entity, &KnowledgeGraph), With>, + event_queue: Option>, + entity_positions: Query<&TilePosition>, +) { + let (Some(registry), Some(ref mut event_queue)) = (registry, event_queue) else { + return; // Knowledge system not wired up + }; + + let Some(snapshot) = &buffer.snapshot else { + return; + }; + + let Ok((observer_entity, observer_kg)) = observer_query.single() else { + return; + }; + + // Build set of currently visible entity IDs from snapshot (excluding player) + let visible_entity_ids: HashSet = 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 + } + + #[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" + ); + } +}