From e733a4c22314341220867501bae63ba23aaeb115 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 00:48:43 +0100 Subject: [PATCH 1/4] feat(simulation): add knowledge graph system per D-041 (#361, #362, #363, #365) 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 --- server/src/knowledge/events.rs | 233 ++++++++++++++++++ server/src/knowledge/graph.rs | 395 +++++++++++++++++++++++++++++++ server/src/knowledge/mod.rs | 42 ++++ server/src/knowledge/registry.rs | 161 +++++++++++++ server/src/knowledge/types.rs | 209 ++++++++++++++++ server/src/lib.rs | 1 + 6 files changed, 1041 insertions(+) create mode 100644 server/src/knowledge/events.rs create mode 100644 server/src/knowledge/graph.rs create mode 100644 server/src/knowledge/mod.rs create mode 100644 server/src/knowledge/registry.rs create mode 100644 server/src/knowledge/types.rs diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs new file mode 100644 index 000000000..a02757c75 --- /dev/null +++ b/server/src/knowledge/events.rs @@ -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, +} + +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); + } + } + 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, + thresholds: Res, + 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::().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 + } +} diff --git a/server/src/knowledge/graph.rs b/server/src/knowledge/graph.rs new file mode 100644 index 000000000..d4f7fd096 --- /dev/null +++ b/server/src/knowledge/graph.rs @@ -0,0 +1,395 @@ +//! KnowledgeGraph ECS component per D-041. +//! +//! Per-entity knowledge component. Attached to every entity that has knowledge +//! (player character, Active-tier NPCs, Background-tier NPCs). + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::simulation::movement::TilePosition; + +use super::types::*; + +/// Per-entity knowledge component. THE core data structure. +/// BTreeMap for deterministic iteration (D-010 principle 4). +#[derive(Component, Debug, Clone, Serialize, Deserialize)] +pub struct KnowledgeGraph { + /// What this entity knows about other entities. + /// Key: StableId of the known entity. + pub entities: BTreeMap, + + /// Non-entity facts this entity knows. + /// Key: FactId in "category.topic" format. + pub facts: BTreeMap, +} + +impl KnowledgeGraph { + pub fn new() -> Self { + Self { + entities: BTreeMap::new(), + facts: BTreeMap::new(), + } + } + + /// Construct with starting facts (character background per D-013). + pub fn with_background(facts: Vec<(FactId, FactKnowledge)>) -> Self { + Self { + entities: BTreeMap::new(), + facts: facts.into_iter().collect(), + } + } + + // --- Read Queries --- + + /// Does this entity know about another entity at all? + pub fn knows_entity(&self, id: &StableId) -> bool { + self.entities.contains_key(id) + } + + /// What confidence level for a known entity? + pub fn confidence_of(&self, id: &StableId) -> Option { + self.entities.get(id).map(|k| k.confidence) + } + + /// What is the relationship state with a known entity? + /// Returns Unknown for entities not in the graph. + pub fn relationship_with(&self, id: &StableId) -> RelationshipState { + self.entities + .get(id) + .map(|k| k.relationship) + .unwrap_or(RelationshipState::Unknown) + } + + /// Does this entity know a specific fact? + pub fn knows_fact(&self, id: &FactId) -> bool { + self.facts.contains_key(id) + } + + /// Is fact confidence at or above a threshold? + /// This is the monologue prerequisite check (D-035 `prerequisite` tag). + pub fn fact_at_least(&self, id: &FactId, min: KnowledgeConfidence) -> bool { + self.facts + .get(id) + .map(|f| f.confidence >= min) + .unwrap_or(false) + } + + /// Get entity knowledge entry (read-only). + pub fn entity_knowledge(&self, id: &StableId) -> Option<&EntityKnowledge> { + self.entities.get(id) + } + + /// Iterate all known entities (deterministic order via BTreeMap). + pub fn known_entities_iter(&self) -> impl Iterator { + self.entities.iter() + } + + /// Number of known entities. + pub fn entity_count(&self) -> usize { + self.entities.len() + } + + /// Number of known facts. + pub fn fact_count(&self) -> usize { + self.facts.len() + } + + // --- Write Operations --- + + /// Record a direct observation of another entity (entity is in LOS). + pub fn observe_entity( + &mut self, + target: StableId, + position: TilePosition, + tick: u64, + ) { + let entry = self.entities.entry(target).or_insert_with(|| EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: 0, + confidence: KnowledgeConfidence::Direct, + source: KnowledgeSource::DirectObservation { tick }, + state: KnowledgeState::Active, + relationship: RelationshipState::Unknown, + known_attributes: BTreeMap::new(), + }); + entry.last_known_position = Some(position); + entry.last_observed_tick = tick; + entry.last_updated_tick = tick; + entry.confidence = KnowledgeConfidence::Direct; + entry.source = KnowledgeSource::DirectObservation { tick }; + // Do NOT reset state here -- a Contradicted entry stays Contradicted + // even if you're looking at the entity right now. + } + + /// Entity has left the observer's LOS. Downgrade from Direct. + pub fn observe_entity_leaving_los(&mut self, target: &StableId, tick: u64) { + if let Some(entry) = self.entities.get_mut(target) { + if entry.confidence == KnowledgeConfidence::Direct { + entry.confidence = KnowledgeConfidence::KnowsDetails; + entry.last_updated_tick = tick; + } + } + } + + /// Set relationship state for an entity. + pub fn set_relationship(&mut self, target: &StableId, state: RelationshipState) { + if let Some(entry) = self.entities.get_mut(target) { + entry.relationship = state; + } + } + + /// Run knowledge decay pass. Called periodically (once per game-minute). + pub fn decay(&mut self, current_tick: u64, thresholds: &DecayThresholds) { + for (_id, knowledge) in self.entities.iter_mut() { + // Direct confidence is managed by perception system, not decay. + if knowledge.confidence == KnowledgeConfidence::Direct { + continue; + } + let age = current_tick.saturating_sub(knowledge.last_observed_tick); + if age > thresholds.stale_after { + knowledge.state = KnowledgeState::Stale; + } else if age > thresholds.decay_after { + knowledge.confidence = knowledge.confidence.decayed(); + knowledge.last_updated_tick = current_tick; + } + } + } +} + +impl Default for KnowledgeGraph { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_position(x: i32, y: i32) -> TilePosition { + TilePosition::new(x, y, 0) + } + + #[test] + fn new_graph_is_empty() { + let g = KnowledgeGraph::new(); + assert_eq!(g.entity_count(), 0); + assert_eq!(g.fact_count(), 0); + } + + #[test] + fn observe_entity_creates_direct_entry() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + g.observe_entity(target, make_position(5, 10), 100); + + assert!(g.knows_entity(&target)); + assert_eq!(g.confidence_of(&target), Some(KnowledgeConfidence::Direct)); + let entry = g.entity_knowledge(&target).unwrap(); + assert_eq!(entry.last_known_position, Some(make_position(5, 10))); + assert_eq!(entry.last_observed_tick, 100); + } + + #[test] + fn leaving_los_downgrades_to_knows_details() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + g.observe_entity(target, make_position(5, 10), 100); + g.observe_entity_leaving_los(&target, 110); + + assert_eq!( + g.confidence_of(&target), + Some(KnowledgeConfidence::KnowsDetails) + ); + let entry = g.entity_knowledge(&target).unwrap(); + assert_eq!(entry.last_updated_tick, 110); + } + + #[test] + fn leaving_los_no_op_if_not_direct() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + g.observe_entity(target, make_position(5, 10), 100); + g.observe_entity_leaving_los(&target, 110); + // Now at KnowsDetails — leaving LOS again should not downgrade further + g.observe_entity_leaving_los(&target, 120); + assert_eq!( + g.confidence_of(&target), + Some(KnowledgeConfidence::KnowsDetails) + ); + } + + #[test] + fn relationship_default_is_unknown() { + let g = KnowledgeGraph::new(); + assert_eq!( + g.relationship_with(&StableId(999)), + RelationshipState::Unknown + ); + } + + #[test] + fn set_relationship_updates_entry() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + g.observe_entity(target, make_position(5, 10), 100); + g.set_relationship(&target, RelationshipState::Hostile); + + assert_eq!( + g.relationship_with(&target), + RelationshipState::Hostile + ); + } + + #[test] + fn fact_operations() { + let fact_id = FactId("contraband.ring_exists".to_string()); + let fact = FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + }; + let g = KnowledgeGraph::with_background(vec![(fact_id.clone(), fact)]); + + assert!(g.knows_fact(&fact_id)); + assert!(g.fact_at_least(&fact_id, KnowledgeConfidence::Suspects)); + assert!(g.fact_at_least(&fact_id, KnowledgeConfidence::KnowsOf)); + assert!(!g.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails)); + } + + #[test] + fn confidence_ordering() { + assert!(KnowledgeConfidence::Suspects < KnowledgeConfidence::KnowsOf); + assert!(KnowledgeConfidence::KnowsOf < KnowledgeConfidence::KnowsDetails); + assert!(KnowledgeConfidence::KnowsDetails < KnowledgeConfidence::Direct); + } + + #[test] + fn confidence_decay_chain() { + assert_eq!( + KnowledgeConfidence::Direct.decayed(), + KnowledgeConfidence::KnowsDetails + ); + assert_eq!( + KnowledgeConfidence::KnowsDetails.decayed(), + KnowledgeConfidence::KnowsOf + ); + assert_eq!( + KnowledgeConfidence::KnowsOf.decayed(), + KnowledgeConfidence::Suspects + ); + assert_eq!( + KnowledgeConfidence::Suspects.decayed(), + KnowledgeConfidence::Suspects + ); + } + + #[test] + fn decay_skips_direct_confidence() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + g.observe_entity(target, make_position(5, 10), 100); + + let thresholds = DecayThresholds { + decay_after: 10, + stale_after: 100, + }; + g.decay(200, &thresholds); + + // Direct entries are managed by perception, not decay + assert_eq!(g.confidence_of(&target), Some(KnowledgeConfidence::Direct)); + } + + #[test] + fn decay_downgrades_non_direct() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + g.observe_entity(target, make_position(5, 10), 100); + g.observe_entity_leaving_los(&target, 110); // Now KnowsDetails + + let thresholds = DecayThresholds { + decay_after: 10, + stale_after: 1000, + }; + // Age = 200 - 100 = 100, which is > decay_after (10) + g.decay(200, &thresholds); + assert_eq!( + g.confidence_of(&target), + Some(KnowledgeConfidence::KnowsOf) + ); + } + + #[test] + fn decay_marks_stale_when_very_old() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + g.observe_entity(target, make_position(5, 10), 100); + g.observe_entity_leaving_los(&target, 110); + + let thresholds = DecayThresholds { + decay_after: 10, + stale_after: 50, + }; + // Age = 200 - 100 = 100, which is > stale_after (50) + g.decay(200, &thresholds); + let entry = g.entity_knowledge(&target).unwrap(); + assert_eq!(entry.state, KnowledgeState::Stale); + } + + #[test] + fn observe_preserves_contradicted_state() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + g.observe_entity(target, make_position(5, 10), 100); + // Manually set contradicted (would be done by contradiction detector in Sprint 3) + g.entities.get_mut(&target).unwrap().state = KnowledgeState::Contradicted; + + // Re-observe — state should remain Contradicted + g.observe_entity(target, make_position(6, 10), 200); + let entry = g.entity_knowledge(&target).unwrap(); + assert_eq!(entry.state, KnowledgeState::Contradicted); + assert_eq!(entry.confidence, KnowledgeConfidence::Direct); + assert_eq!(entry.last_known_position, Some(make_position(6, 10))); + } + + #[test] + fn with_background_creates_facts() { + let facts = vec![ + ( + FactId("location.restricted".to_string()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsDetails, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + }, + ), + ( + FactId("contraband.exists".to_string()), + FactKnowledge { + confidence: KnowledgeConfidence::Suspects, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + }, + ), + ]; + let g = KnowledgeGraph::with_background(facts); + assert_eq!(g.fact_count(), 2); + assert_eq!(g.entity_count(), 0); + } + + #[test] + fn known_entities_iter_deterministic() { + let mut g = KnowledgeGraph::new(); + // Insert in reverse order — BTreeMap should iterate in sorted order + for id in (0..5).rev() { + g.observe_entity(StableId(id), make_position(id as i32, 0), 100); + } + let ids: Vec = g.known_entities_iter().map(|(id, _)| id.0).collect(); + assert_eq!(ids, vec![0, 1, 2, 3, 4]); + } +} diff --git a/server/src/knowledge/mod.rs b/server/src/knowledge/mod.rs new file mode 100644 index 000000000..209350220 --- /dev/null +++ b/server/src/knowledge/mod.rs @@ -0,0 +1,42 @@ +//! Knowledge graph module (D-041). +//! +//! Implements information boundaries (D-010 principle 2): every piece of +//! state is tagged with who knows it. Per-entity KnowledgeGraph component +//! tracks what each entity knows about others and the world. + +use bevy_app::prelude::*; +use bevy_ecs::prelude::*; + +pub mod events; +pub mod graph; +pub mod registry; +pub mod types; + +pub use events::{ + KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, +}; +pub use graph::KnowledgeGraph; +pub use registry::{EntityRegistry, StableEntityId}; +pub use types::*; + +/// Knowledge system plugin. +/// Registers resources and systems for knowledge graph processing. +pub struct KnowledgePlugin; + +impl Plugin for KnowledgePlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .init_resource::() + .add_systems( + Update, + ( + events::process_knowledge_events + .after(crate::perception::observer::compute_observer_snapshot), + events::decay_knowledge + .after(events::process_knowledge_events), + ), + ); + tracing::debug!("KnowledgePlugin initialized"); + } +} diff --git a/server/src/knowledge/registry.rs b/server/src/knowledge/registry.rs new file mode 100644 index 000000000..28e150945 --- /dev/null +++ b/server/src/knowledge/registry.rs @@ -0,0 +1,161 @@ +//! Entity ID infrastructure (Q-019, D-041). +//! +//! StableEntityId component and EntityRegistry resource for +//! bidirectional StableId <-> Entity mapping. + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use super::types::StableId; + +/// Stable entity ID component. Assigned once at spawn, never changes. +/// Serialized with entity for save/load. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct StableEntityId(pub StableId); + +/// Bidirectional mapping between StableId and bevy Entity. +/// Resource, updated on spawn/despawn. +#[derive(Resource, Debug)] +pub struct EntityRegistry { + by_stable_id: BTreeMap, + by_entity: BTreeMap, + next_id: u64, +} + +impl Default for EntityRegistry { + fn default() -> Self { + Self::new(0) + } +} + +impl EntityRegistry { + pub fn new(seed_offset: u64) -> Self { + Self { + by_stable_id: BTreeMap::new(), + by_entity: BTreeMap::new(), + next_id: seed_offset, + } + } + + /// Register a new entity and assign a StableId. + /// Returns the existing StableId if already registered. + pub fn register(&mut self, entity: Entity) -> StableId { + if let Some(id) = self.by_entity.get(&entity) { + return *id; + } + let id = StableId(self.next_id); + self.next_id += 1; + self.by_stable_id.insert(id, entity); + self.by_entity.insert(entity, id); + id + } + + /// Lookup: StableId -> Entity (for ECS queries). + pub fn to_entity(&self, id: &StableId) -> Option { + self.by_stable_id.get(id).copied() + } + + /// Lookup: Entity -> StableId (for knowledge graph keys). + pub fn to_stable(&self, entity: Entity) -> Option { + self.by_entity.get(&entity).copied() + } + + /// Remove a despawned entity from the registry. + /// Called AFTER knowledge cleanup (tombstone pattern). + pub fn unregister(&mut self, entity: Entity) { + if let Some(id) = self.by_entity.remove(&entity) { + self.by_stable_id.remove(&id); + } + } + + /// Number of registered entities. + pub fn len(&self) -> usize { + self.by_entity.len() + } + + /// Whether the registry is empty. + pub fn is_empty(&self) -> bool { + self.by_entity.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_ecs::world::World; + + #[test] + fn register_assigns_sequential_ids() { + let mut world = World::new(); + let e1 = world.spawn_empty().id(); + let e2 = world.spawn_empty().id(); + + let mut registry = EntityRegistry::new(0); + let id1 = registry.register(e1); + let id2 = registry.register(e2); + + assert_eq!(id1, StableId(0)); + assert_eq!(id2, StableId(1)); + } + + #[test] + fn register_idempotent() { + let mut world = World::new(); + let e1 = world.spawn_empty().id(); + + let mut registry = EntityRegistry::new(0); + let id_first = registry.register(e1); + let id_second = registry.register(e1); + + assert_eq!(id_first, id_second); + assert_eq!(registry.len(), 1); + } + + #[test] + fn bidirectional_lookup() { + let mut world = World::new(); + let e1 = world.spawn_empty().id(); + + let mut registry = EntityRegistry::new(0); + let id = registry.register(e1); + + assert_eq!(registry.to_entity(&id), Some(e1)); + assert_eq!(registry.to_stable(e1), Some(id)); + } + + #[test] + fn unregister_removes_both_directions() { + let mut world = World::new(); + let e1 = world.spawn_empty().id(); + + let mut registry = EntityRegistry::new(0); + let id = registry.register(e1); + registry.unregister(e1); + + assert_eq!(registry.to_entity(&id), None); + assert_eq!(registry.to_stable(e1), None); + assert!(registry.is_empty()); + } + + #[test] + fn seed_offset_starts_ids_higher() { + let mut world = World::new(); + let e1 = world.spawn_empty().id(); + + let mut registry = EntityRegistry::new(1000); + let id = registry.register(e1); + + assert_eq!(id, StableId(1000)); + } + + #[test] + fn lookup_unknown_entity_returns_none() { + let mut world = World::new(); + let e1 = world.spawn_empty().id(); + + let registry = EntityRegistry::new(0); + assert_eq!(registry.to_entity(&StableId(999)), None); + assert_eq!(registry.to_stable(e1), None); + } +} diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs new file mode 100644 index 000000000..d66871231 --- /dev/null +++ b/server/src/knowledge/types.rs @@ -0,0 +1,209 @@ +//! Knowledge graph types per D-041 canonical reference. +//! +//! All types conform to the structs defined in: +//! docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::simulation::movement::TilePosition; + +// --- Identity Types --- + +/// Stable entity identifier that survives save/load cycles. +/// NOT a bevy_ecs Entity (which is a generational index). +/// Assigned once at entity creation, never changes. +/// Resolves Q-019 for knowledge graph and snapshot purposes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct StableId(pub u64); + +/// Typed fact identifier for non-entity knowledge. +/// Format: "category.topic" (e.g., "contraband.ring_exists"). +/// Lexicographic ordering in BTreeMap provides deterministic iteration. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct FactId(pub String); + +// --- Knowledge Confidence (Q-016 Resolution) --- + +/// Knowledge confidence hierarchy. Discrete enum, NOT a continuous float. +/// Derives Ord: Suspects < KnowsOf < KnowsDetails < Direct. +/// This ordering is load-bearing -- do not reorder variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum KnowledgeConfidence { + /// "Something seems off about X" / "I've heard the name" + /// Gates: initial investigation, vague monologue, open-ended questions. + Suspects = 0, + /// "X is involved in Y" / "X has a connection to Z" + /// Gates: topic-specific dialogue, specific monologue, peer-tier access. + KnowsOf = 1, + /// "X did Y at Z because W" -- actionable detail. + /// Gates: confrontation, secret-tier dialogue, detailed monologue. + KnowsDetails = 2, + /// "I'm looking at X right now" -- currently in observer's LOS. + /// Gates: live position data, current activity visible, maximum rendering fidelity. + /// Automatically set by perception system, downgraded when entity leaves LOS. + Direct = 3, +} + +impl KnowledgeConfidence { + /// Step down one confidence level (used by decay system). + pub fn decayed(self) -> Self { + match self { + Self::Direct => Self::KnowsDetails, + Self::KnowsDetails => Self::KnowsOf, + Self::KnowsOf => Self::Suspects, + Self::Suspects => Self::Suspects, // Floor -- does not decay below Suspects + } + } +} + +// --- Knowledge State --- + +/// Temporal/logical state of a knowledge entry. +/// Orthogonal to confidence: a KnowsDetails entry can be Active or Contradicted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum KnowledgeState { + /// Currently believed true. Default state. + Active, + /// Conflicting information exists. Both conflicting entries receive this state. + /// Triggers monologue event when set. THE FRIEND arc detector. + Contradicted, + /// Decay system has aged this entry beyond stale threshold. + /// Entry remains in graph but is treated as unreliable. + Stale, +} + +impl Default for KnowledgeState { + fn default() -> Self { + Self::Active + } +} + +// --- Knowledge Source --- + +/// How knowledge was acquired. Tracked per-entry for provenance. +/// CauseChain (D-030) can reference this for monologue trigger explanations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum KnowledgeSource { + /// Directly seen by this entity's LOS (perception system). + DirectObservation { tick: u64 }, + /// Heard via D-018 sound model (medium/long range). + Heard { tick: u64, range: SoundRange }, + /// Told by another entity during conversation (dialogue, gossip). + ToldBy { source_id: StableId, tick: u64 }, + /// Inferred from combining other knowledge entries. + Inferred { basis: Vec }, + /// Starting knowledge from character background (D-013 insert data). + Background, +} + +/// Sound range classification from D-018. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SoundRange { + Close, + Medium, + Long, +} + +// --- Relationship State (D-033) --- + +/// Relationship state drives D-033 entity color rendering. +/// Derived from knowledge + NPC relationship axes (D-024). +/// Client maps this to color palette defined in D-033. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum RelationshipState { + /// No prior knowledge. Teal #4a9ebb. + Unknown, + /// Recognized, neutral-to-positive. Soft green #6bc9a6. + Known, + /// Positive relationship, trusted. Soft green #6bc9a6. + Friendly, + /// Flagged by monologue/investigation. Amber #e8c547. + PersonOfInterest, + /// Character KNOWS there is danger. Red #d45d5d. + Hostile, +} + +impl Default for RelationshipState { + fn default() -> Self { + Self::Unknown + } +} + +// --- Entity Knowledge --- + +/// What entity A knows about entity B. +/// One entry per known entity in the BTreeMap. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntityKnowledge { + /// Last position this entity was observed at. None if never directly seen. + pub last_known_position: Option, + /// Tick when this entity was last directly observed by LOS. + pub last_observed_tick: u64, + /// Tick when this knowledge entry was last updated (by any source). + pub last_updated_tick: u64, + /// How confident is this knowledge? (Q-016 hierarchy) + pub confidence: KnowledgeConfidence, + /// How was this knowledge acquired? + pub source: KnowledgeSource, + /// Logical state (active, contradicted, stale). + pub state: KnowledgeState, + /// Relationship assessment (drives D-033 entity color). + pub relationship: RelationshipState, + /// Known attributes of the target entity. + /// Keys are structured (name, role, faction, etc.) + pub known_attributes: BTreeMap, +} + +/// Non-entity fact knowledge (locations, events, abstract knowledge). +/// Used for monologue prerequisites and dialogue gating. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FactKnowledge { + /// Confidence level for this fact. + pub confidence: KnowledgeConfidence, + /// How was this fact acquired? + pub source: KnowledgeSource, + /// Logical state. + pub state: KnowledgeState, + /// Tick when this fact was learned. + pub acquired_tick: u64, +} + +// --- Decay Configuration --- + +/// Configuration resource for knowledge decay rates. +/// D-011: "fog returns when you leave." +#[derive(Resource, Debug, Clone)] +pub struct DecayThresholds { + /// Ticks before knowledge begins decaying. + /// Default: 600 ticks = 1 game-hour (at 10 tps per D-031). + pub decay_after: u64, + /// Ticks before knowledge becomes Stale. + /// Default: 3600 ticks = 6 game-hours. + pub stale_after: u64, +} + +impl Default for DecayThresholds { + fn default() -> Self { + Self { + decay_after: 600, // 1 game-hour + stale_after: 3600, // 6 game-hours + } + } +} + +// --- Observer Snapshot Integration --- + +/// How an entity appears in the observer snapshot. +/// Extends VisibleEntity for knowledge-based rendering. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum EntityVisibility { + /// Currently in line of sight. + Visible, + /// Not in LOS but remembered from knowledge graph. + Remembered { + confidence: KnowledgeConfidence, + age_ticks: u64, + }, +} diff --git a/server/src/lib.rs b/server/src/lib.rs index aa7b2f9ee..631979cfe 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -3,6 +3,7 @@ pub mod bridge; pub mod cause_chain; +pub mod knowledge; pub mod npc; pub mod perception; pub mod simulation; From 804379dee2ce53ac6c13dd910255bd35c95c6d5d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 00:48:51 +0100 Subject: [PATCH 2/4] feat(simulation): wire direct observation to knowledge graph (#364, #367) Perception system emits DirectObservation/LeftLOS knowledge events based on snapshot visibility. Entities entering LOS get Direct confidence; leaving LOS downgrades to KnowsDetails. System params are optional for backward compatibility when KnowledgePlugin is not registered. 28 knowledge graph unit tests included. Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/mod.rs | 2 + server/src/perception/mod.rs | 1 + server/src/perception/observation.rs | 215 +++++++++++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 server/src/perception/observation.rs 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" + ); + } +} From a68a3ef42931ef9686301c72d250f6306b7e34e5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 00:49:08 +0100 Subject: [PATCH 3/4] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8afae084..ff56c2a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- Knowledge graph system (#361, #362, #363, #365) — per-entity KnowledgeGraph component (D-041), StableEntityId + EntityRegistry, KnowledgeEventQueue, decay system, 4-level confidence hierarchy +- Direct observation knowledge flow (#364) — perception emits DirectObservation/LeftLOS events to knowledge graph, entities entering/leaving LOS tracked - Observer visibility query (#112) — replaces unfiltered generate_snapshot with LOS-filtered compute_observer_snapshot combining shadowcasting + vision cone - Vision cone system (#111) — forward/peripheral/blind sectors per D-015, Facing component updated on movement - Symmetric shadowcasting (#110, #359) — Albert Ford algorithm with rational fraction slopes, benchmarked 1.2-10.5x faster than recursive, symmetry guaranteed (D-035) From 3115c7a84c5cf967a991932d4ab00d90af04e4bc Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 01:06:33 +0100 Subject: [PATCH 4/4] =?UTF-8?q?fix(simulation):=20address=20PR=20#10=20rev?= =?UTF-8?q?iew=20=E2=80=94=20plugin=20wiring,=20stale=20reset,=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register KnowledgePlugin in main.rs and game_loop test (Tyre critical) - Add KnowledgeGraph component to player spawn (Tyre critical) - Reset Stale -> Active on fresh direct observation (Hoshe warning) - Make registry/queue non-optional in emit_observation_events (Tyre/Hoshe) - Add tracing::warn for missing EntityRegistry entries (Hoshe warning) - Replace HashSet with Vec for small entity ID lookups (Hoshe suggestion) - Add const static assertion for KnowledgeConfidence ordering (Hoshe) - Add is_empty() and known_facts_iter() to KnowledgeGraph (Tyre) - Add decay_skips_non_minute_ticks and observe_resets_stale tests (Hoshe) Co-Authored-By: Claude Opus 4.6 --- server/src/knowledge/events.rs | 55 ++++++++++++++++++++++++++++ server/src/knowledge/graph.rs | 33 ++++++++++++++++- server/src/knowledge/types.rs | 8 ++++ server/src/main.rs | 10 ++++- server/src/perception/observation.rs | 13 ++----- server/tests/game_loop.rs | 9 ++++- 6 files changed, 113 insertions(+), 15 deletions(-) diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index a02757c75..6dcb85c7f 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -78,11 +78,15 @@ pub fn process_knowledge_events( KnowledgeEventType::DirectObservation { target, position } => { if let Some(stable_id) = registry.to_stable(target) { observer_kg.observe_entity(stable_id, position, event.tick); + } else { + tracing::warn!("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 { + tracing::warn!("LeftLOS target {:?} not in EntityRegistry", target); } } } @@ -230,4 +234,55 @@ mod tests { 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(SimulationTime { tick: 7, paused: false }); + 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(SimulationTime { tick: 10, paused: false }); + 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" + ); + } } diff --git a/server/src/knowledge/graph.rs b/server/src/knowledge/graph.rs index d4f7fd096..b65a21f64 100644 --- a/server/src/knowledge/graph.rs +++ b/server/src/knowledge/graph.rs @@ -95,6 +95,16 @@ impl KnowledgeGraph { self.facts.len() } + /// Whether the knowledge graph has no entries at all. + pub fn is_empty(&self) -> bool { + self.entities.is_empty() && self.facts.is_empty() + } + + /// Iterate all known facts (deterministic order via BTreeMap). + pub fn known_facts_iter(&self) -> impl Iterator { + self.facts.iter() + } + // --- Write Operations --- /// Record a direct observation of another entity (entity is in LOS). @@ -119,8 +129,12 @@ impl KnowledgeGraph { entry.last_updated_tick = tick; entry.confidence = KnowledgeConfidence::Direct; entry.source = KnowledgeSource::DirectObservation { tick }; - // Do NOT reset state here -- a Contradicted entry stays Contradicted - // even if you're looking at the entity right now. + // Stale entries become Active again on fresh observation. + // Contradicted entries stay Contradicted even if you're looking + // at the entity right now — the contradiction is still unresolved. + if entry.state == KnowledgeState::Stale { + entry.state = KnowledgeState::Active; + } } /// Entity has left the observer's LOS. Downgrade from Direct. @@ -355,6 +369,21 @@ mod tests { assert_eq!(entry.last_known_position, Some(make_position(6, 10))); } + #[test] + fn observe_resets_stale_to_active() { + let mut g = KnowledgeGraph::new(); + let target = StableId(1); + g.observe_entity(target, make_position(5, 10), 100); + // Mark as stale (would be done by decay system) + g.entities.get_mut(&target).unwrap().state = KnowledgeState::Stale; + + // Re-observe — stale should reset to Active + g.observe_entity(target, make_position(6, 10), 200); + let entry = g.entity_knowledge(&target).unwrap(); + assert_eq!(entry.state, KnowledgeState::Active); + assert_eq!(entry.confidence, KnowledgeConfidence::Direct); + } + #[test] fn with_background_creates_facts() { let facts = vec![ diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index d66871231..892a4df1a 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -46,6 +46,14 @@ pub enum KnowledgeConfidence { Direct = 3, } +// Load-bearing ordering assertion — do not reorder variants. +const _: () = { + assert!(KnowledgeConfidence::Suspects as u8 == 0); + assert!(KnowledgeConfidence::KnowsOf as u8 == 1); + assert!(KnowledgeConfidence::KnowsDetails as u8 == 2); + assert!(KnowledgeConfidence::Direct as u8 == 3); +}; + impl KnowledgeConfidence { /// Step down one confidence level (used by decay system). pub fn decayed(self) -> Self { diff --git a/server/src/main.rs b/server/src/main.rs index bb94311b9..96918aaf8 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -6,6 +6,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use settled_reach_server::bridge::tcp::TcpBridge; use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning}; +use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin}; use settled_reach_server::perception::vision_cone::Facing; use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use settled_reach_server::simulation::SimulationPlugin; @@ -38,10 +39,15 @@ fn main() { let mut app = App::new(); app.add_plugins(SimulationPlugin); app.add_plugins(BridgePlugin); + app.add_plugins(KnowledgePlugin); app.insert_resource(BridgeResource::new(bridge)); app.insert_resource(WalkabilityMap::new(32, 32, 1)); - app.world_mut() - .spawn((PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default())); + app.world_mut().spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + )); tracing::info!("Simulation initialized, entering game loop"); diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index a9eeed9d4..31e91ff1f 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -4,7 +4,6 @@ //! Runs after compute_observer_snapshot, before process_knowledge_events. use bevy_ecs::prelude::*; -use std::collections::HashSet; use crate::bridge::types::*; use crate::knowledge::{ @@ -21,15 +20,11 @@ use crate::simulation::time::SimulationTime; pub fn emit_observation_events( time: Res, buffer: Res, - registry: Option>, + registry: Res, observer_query: Query<(Entity, &KnowledgeGraph), With>, - event_queue: Option>, + mut event_queue: ResMut, 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; }; @@ -38,8 +33,8 @@ pub fn emit_observation_events( return; }; - // Build set of currently visible entity IDs from snapshot (excluding player) - let visible_entity_ids: HashSet = snapshot + // Collect visible entity IDs (Vec — linear search is faster at 5-20 entities) + let visible_entity_ids: Vec = snapshot .entities .iter() .filter(|e| !matches!(e.kind, EntityKind::Player)) diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 7927d1211..500ab4096 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -6,6 +6,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed}; use settled_reach_server::bridge::tcp::TcpBridge; use settled_reach_server::bridge::types::*; use settled_reach_server::bridge::{BridgePlugin, BridgeResource}; +use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin}; use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use settled_reach_server::simulation::SimulationPlugin; use std::io::{BufReader, BufWriter}; @@ -26,10 +27,14 @@ fn player_moves_north_through_full_pipeline() { let mut app = App::new(); app.add_plugins(SimulationPlugin); app.add_plugins(BridgePlugin); + app.add_plugins(KnowledgePlugin); app.insert_resource(BridgeResource::new(bridge)); app.insert_resource(WalkabilityMap::new(32, 32, 1)); - app.world_mut() - .spawn((PlayerCharacter, TilePosition::new(16, 16, 0))); + app.world_mut().spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + KnowledgeGraph::new(), + )); // Run one tick: receive input, process, validate movement, generate snapshot, send app.update();