From e733a4c22314341220867501bae63ba23aaeb115 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 00:48:43 +0100 Subject: [PATCH] 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;