Files
settled-reach/server/src/knowledge/events.rs
T
jpmschweitzerandClaude Opus 4.6 9943684f2c refactor(server): address PR #16 review feedback
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>
2026-02-12 20:14:35 +01:00

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"
);
}
}