//! Knowledge event queue and processing system (#363). //! //! Event-driven knowledge updates. Perception and other systems push //! KnowledgeEvents; the processing system drains them per tick. use bevy_ecs::prelude::*; use crate::simulation::movement::TilePosition; use super::graph::KnowledgeGraph; use super::registry::EntityRegistry; use super::types::*; /// Events that modify knowledge graphs. Produced by perception and /// other systems. Consumed by the knowledge update system. #[derive(Debug, Clone)] pub struct KnowledgeEvent { pub observer: Entity, pub tick: u64, pub event_type: KnowledgeEventType, } #[derive(Debug, Clone)] pub enum KnowledgeEventType { /// Observer saw entity at position (sets Direct confidence). DirectObservation { target: Entity, position: TilePosition, }, /// Entity left observer's LOS (downgrades from Direct). LeftLOS { target: Entity }, } /// Resource: queue of pending knowledge events. /// Drained once per tick by the knowledge update system. #[derive(Resource, Default)] pub struct KnowledgeEventQueue { pub(crate) events: Vec, } impl KnowledgeEventQueue { /// Push a knowledge event into the queue. pub fn push(&mut self, event: KnowledgeEvent) { self.events.push(event); } /// Drain all pending events. pub fn drain(&mut self) -> Vec { std::mem::take(&mut self.events) } /// Number of pending events. pub fn len(&self) -> usize { self.events.len() } /// Whether the queue is empty. pub fn is_empty(&self) -> bool { self.events.is_empty() } } /// System: process pending knowledge events. /// Runs once per tick, drains KnowledgeEventQueue and applies updates /// to the relevant KnowledgeGraph components. pub fn process_knowledge_events( mut queue: ResMut, registry: Res, mut knowledge_query: Query<&mut KnowledgeGraph>, ) { let events = queue.drain(); for event in events { let Ok(mut observer_kg) = knowledge_query.get_mut(event.observer) else { continue; }; match event.event_type { KnowledgeEventType::DirectObservation { target, position } => { if let Some(stable_id) = registry.to_stable(target) { observer_kg.observe_entity(stable_id, position, event.tick); } else { debug_assert!(false, "DirectObservation target {:?} not in EntityRegistry", target); tracing::error!("DirectObservation target {:?} not in EntityRegistry", target); } } KnowledgeEventType::LeftLOS { target } => { if let Some(stable_id) = registry.to_stable(target) { observer_kg.observe_entity_leaving_los(&stable_id, event.tick); } else { debug_assert!(false, "LeftLOS target {:?} not in EntityRegistry", target); tracing::error!("LeftLOS target {:?} not in EntityRegistry", target); } } } } } /// System: run knowledge decay once per game-minute (every 10 ticks per D-031). pub fn decay_knowledge( time: Res, thresholds: Res, mut knowledge_query: Query<&mut KnowledgeGraph>, ) { // Decay runs every 10 ticks (1 game-minute per D-031) if !time.tick.is_multiple_of(10) { return; } for mut kg in knowledge_query.iter_mut() { kg.decay(time.tick, &thresholds); } } #[cfg(test)] mod tests { use super::*; use bevy_ecs::world::World; #[test] fn queue_push_and_drain() { let mut queue = KnowledgeEventQueue::default(); assert!(queue.is_empty()); let mut world = World::new(); let e1 = world.spawn_empty().id(); let e2 = world.spawn_empty().id(); queue.push(KnowledgeEvent { observer: e1, tick: 100, event_type: KnowledgeEventType::DirectObservation { target: e2, position: TilePosition::new(5, 5, 0), }, }); assert_eq!(queue.len(), 1); let events = queue.drain(); assert_eq!(events.len(), 1); assert!(queue.is_empty()); } #[test] fn process_direct_observation_event() { let mut world = World::new(); let mut registry = EntityRegistry::new(0); let observer = world.spawn(KnowledgeGraph::new()).id(); let target = world.spawn_empty().id(); let observer_sid = registry.register(observer); let target_sid = registry.register(target); let _ = observer_sid; // registered for completeness world.insert_resource(registry); let mut queue = KnowledgeEventQueue::default(); queue.push(KnowledgeEvent { observer, tick: 50, event_type: KnowledgeEventType::DirectObservation { target, position: TilePosition::new(10, 10, 0), }, }); world.insert_resource(queue); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_knowledge_events); schedule.run(&mut world); let kg = world.entity(observer).get::().unwrap(); assert!(kg.knows_entity(&target_sid)); assert_eq!( kg.confidence_of(&target_sid), Some(KnowledgeConfidence::Direct) ); } #[test] fn process_left_los_event() { let mut world = World::new(); let mut registry = EntityRegistry::new(0); let target = world.spawn_empty().id(); let target_sid = registry.register(target); // Pre-populate observer with Direct knowledge let mut kg = KnowledgeGraph::new(); kg.observe_entity(target_sid, TilePosition::new(10, 10, 0), 50); let observer = world.spawn(kg).id(); registry.register(observer); world.insert_resource(registry); let mut queue = KnowledgeEventQueue::default(); queue.push(KnowledgeEvent { observer, tick: 60, event_type: KnowledgeEventType::LeftLOS { target }, }); world.insert_resource(queue); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_knowledge_events); schedule.run(&mut world); let kg = world.entity(observer).get::().unwrap(); assert_eq!( kg.confidence_of(&target_sid), Some(KnowledgeConfidence::KnowsDetails) ); } #[test] fn process_event_for_missing_observer_is_no_op() { let mut world = World::new(); let registry = EntityRegistry::new(0); world.insert_resource(registry); let fake_observer = world.spawn_empty().id(); // no KnowledgeGraph let fake_target = world.spawn_empty().id(); let mut queue = KnowledgeEventQueue::default(); queue.push(KnowledgeEvent { observer: fake_observer, tick: 100, event_type: KnowledgeEventType::DirectObservation { target: fake_target, position: TilePosition::new(5, 5, 0), }, }); world.insert_resource(queue); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_knowledge_events); schedule.run(&mut world); // Should not panic } #[test] fn decay_skips_non_minute_ticks() { use crate::simulation::time::SimulationTime; let mut world = World::new(); let mut registry = EntityRegistry::new(0); let target_entity = world.spawn_empty().id(); let target_sid = registry.register(target_entity); let mut kg = KnowledgeGraph::new(); kg.observe_entity(target_sid, TilePosition::new(5, 5, 0), 0); kg.observe_entity_leaving_los(&target_sid, 1); // KnowsDetails let observer = world.spawn(kg).id(); registry.register(observer); world.insert_resource(registry); let thresholds = DecayThresholds { decay_after: 5, stale_after: 100, }; world.insert_resource(thresholds); // Tick 7: not a multiple of 10, decay should NOT run world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 7; t }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(decay_knowledge); schedule.run(&mut world); let kg = world.entity(observer).get::().unwrap(); assert_eq!( kg.confidence_of(&target_sid), Some(KnowledgeConfidence::KnowsDetails), "decay should not run on tick 7 (not a game-minute boundary)" ); // Tick 10: multiple of 10, decay SHOULD run (age = 10 > decay_after = 5) world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 10; t }); let mut schedule2 = bevy_ecs::schedule::Schedule::default(); schedule2.add_systems(decay_knowledge); schedule2.run(&mut world); let kg = world.entity(observer).get::().unwrap(); assert_eq!( kg.confidence_of(&target_sid), Some(KnowledgeConfidence::KnowsOf), "decay should run on tick 10 and downgrade confidence" ); } }