Implement per-entity KnowledgeGraph component with BTreeMap storage for deterministic iteration (D-010). StableEntityId + EntityRegistry for entity identity across save/load. KnowledgeEventQueue + processing system for decoupled knowledge updates. Decay system runs once per game-minute. All types conform to D-041 canonical structs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
//! 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<KnowledgeEvent>,
|
||||
}
|
||||
|
||||
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<KnowledgeEvent> {
|
||||
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<KnowledgeEventQueue>,
|
||||
registry: Res<EntityRegistry>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
KnowledgeEventType::LeftLOS { target } => {
|
||||
if let Some(stable_id) = registry.to_stable(target) {
|
||||
observer_kg.observe_entity_leaving_los(&stable_id, event.tick);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System: run knowledge decay once per game-minute (every 10 ticks per D-031).
|
||||
pub fn decay_knowledge(
|
||||
time: Res<crate::simulation::time::SimulationTime>,
|
||||
thresholds: Res<DecayThresholds>,
|
||||
mut knowledge_query: Query<&mut KnowledgeGraph>,
|
||||
) {
|
||||
// Decay runs every 10 ticks (1 game-minute per D-031)
|
||||
if time.tick % 10 != 0 {
|
||||
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::<KnowledgeGraph>().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::<KnowledgeGraph>().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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user