Hoshe + Tyre review items: - Use StableId consistently for wire entity_id (H4) across observer, observation, interpretation, and interaction systems - Make NearbyInteractionBuffer.interactions private with take() (H1/H20) - Add system ordering constraint for compute_nearby_interactions (H5) - Panic on missing PlayerCharacter in input processing (H2) - Remove redundant paused field from GameTime (Tyre8) - Remove #[serde(default)] from nearby_interactions (H3) - Change NearbyInteraction.distance from f32 to u32 (H8) - Add sort stability for equal verb priorities (H6) - Scope constants to pub(crate) (H7) - Add debug_assert for last_observed_tick ordering (H10) - Strengthen unregistered entity handling to debug_assert + error (H11) - Document fractional tick accumulation (Tyre9) - Extract collect_remembered_entities helper (Tyre2/H17) - Add half_rate_no_drift_over_10000_frames test (H14) - Add mid-range and deterministic sort tests (H15) - Add fixture version assertion (H16) - Regenerate msgpack fixtures for wire format changes 146 unit + 19 integration tests pass, zero clippy warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
291 lines
9.4 KiB
Rust
291 lines
9.4 KiB
Rust
//! 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);
|
|
} 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<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.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::<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
|
|
}
|
|
|
|
#[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::<KnowledgeGraph>().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::<KnowledgeGraph>().unwrap();
|
|
assert_eq!(
|
|
kg.confidence_of(&target_sid),
|
|
Some(KnowledgeConfidence::KnowsOf),
|
|
"decay should run on tick 10 and downgrade confidence"
|
|
);
|
|
}
|
|
}
|