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 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 00:48:43 +01:00
co-authored by Claude Opus 4.6
parent a9f1cbd865
commit e733a4c223
6 changed files with 1041 additions and 0 deletions
+233
View File
@@ -0,0 +1,233 @@
//! Knowledge event queue and processing system (#363).
//!
//! Event-driven knowledge updates. Perception and other systems push
//! KnowledgeEvents; the processing system drains them per tick.
use bevy_ecs::prelude::*;
use crate::simulation::movement::TilePosition;
use super::graph::KnowledgeGraph;
use super::registry::EntityRegistry;
use super::types::*;
/// Events that modify knowledge graphs. Produced by perception and
/// other systems. Consumed by the knowledge update system.
#[derive(Debug, Clone)]
pub struct KnowledgeEvent {
pub observer: Entity,
pub tick: u64,
pub event_type: KnowledgeEventType,
}
#[derive(Debug, Clone)]
pub enum KnowledgeEventType {
/// Observer saw entity at position (sets Direct confidence).
DirectObservation {
target: Entity,
position: TilePosition,
},
/// Entity left observer's LOS (downgrades from Direct).
LeftLOS { target: Entity },
}
/// Resource: queue of pending knowledge events.
/// Drained once per tick by the knowledge update system.
#[derive(Resource, Default)]
pub struct KnowledgeEventQueue {
pub(crate) events: Vec<KnowledgeEvent>,
}
impl KnowledgeEventQueue {
/// Push a knowledge event into the queue.
pub fn push(&mut self, event: KnowledgeEvent) {
self.events.push(event);
}
/// Drain all pending events.
pub fn drain(&mut self) -> Vec<KnowledgeEvent> {
std::mem::take(&mut self.events)
}
/// Number of pending events.
pub fn len(&self) -> usize {
self.events.len()
}
/// Whether the queue is empty.
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
/// System: process pending knowledge events.
/// Runs once per tick, drains KnowledgeEventQueue and applies updates
/// to the relevant KnowledgeGraph components.
pub fn process_knowledge_events(
mut queue: ResMut<KnowledgeEventQueue>,
registry: Res<EntityRegistry>,
mut knowledge_query: Query<&mut KnowledgeGraph>,
) {
let events = queue.drain();
for event in events {
let Ok(mut observer_kg) = knowledge_query.get_mut(event.observer) else {
continue;
};
match event.event_type {
KnowledgeEventType::DirectObservation { target, position } => {
if let Some(stable_id) = registry.to_stable(target) {
observer_kg.observe_entity(stable_id, position, event.tick);
}
}
KnowledgeEventType::LeftLOS { target } => {
if let Some(stable_id) = registry.to_stable(target) {
observer_kg.observe_entity_leaving_los(&stable_id, event.tick);
}
}
}
}
}
/// System: run knowledge decay once per game-minute (every 10 ticks per D-031).
pub fn decay_knowledge(
time: Res<crate::simulation::time::SimulationTime>,
thresholds: Res<DecayThresholds>,
mut knowledge_query: Query<&mut KnowledgeGraph>,
) {
// Decay runs every 10 ticks (1 game-minute per D-031)
if time.tick % 10 != 0 {
return;
}
for mut kg in knowledge_query.iter_mut() {
kg.decay(time.tick, &thresholds);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy_ecs::world::World;
#[test]
fn queue_push_and_drain() {
let mut queue = KnowledgeEventQueue::default();
assert!(queue.is_empty());
let mut world = World::new();
let e1 = world.spawn_empty().id();
let e2 = world.spawn_empty().id();
queue.push(KnowledgeEvent {
observer: e1,
tick: 100,
event_type: KnowledgeEventType::DirectObservation {
target: e2,
position: TilePosition::new(5, 5, 0),
},
});
assert_eq!(queue.len(), 1);
let events = queue.drain();
assert_eq!(events.len(), 1);
assert!(queue.is_empty());
}
#[test]
fn process_direct_observation_event() {
let mut world = World::new();
let mut registry = EntityRegistry::new(0);
let observer = world.spawn(KnowledgeGraph::new()).id();
let target = world.spawn_empty().id();
let observer_sid = registry.register(observer);
let target_sid = registry.register(target);
let _ = observer_sid; // registered for completeness
world.insert_resource(registry);
let mut queue = KnowledgeEventQueue::default();
queue.push(KnowledgeEvent {
observer,
tick: 50,
event_type: KnowledgeEventType::DirectObservation {
target,
position: TilePosition::new(10, 10, 0),
},
});
world.insert_resource(queue);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_knowledge_events);
schedule.run(&mut world);
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
assert!(kg.knows_entity(&target_sid));
assert_eq!(
kg.confidence_of(&target_sid),
Some(KnowledgeConfidence::Direct)
);
}
#[test]
fn process_left_los_event() {
let mut world = World::new();
let mut registry = EntityRegistry::new(0);
let target = world.spawn_empty().id();
let target_sid = registry.register(target);
// Pre-populate observer with Direct knowledge
let mut kg = KnowledgeGraph::new();
kg.observe_entity(target_sid, TilePosition::new(10, 10, 0), 50);
let observer = world.spawn(kg).id();
registry.register(observer);
world.insert_resource(registry);
let mut queue = KnowledgeEventQueue::default();
queue.push(KnowledgeEvent {
observer,
tick: 60,
event_type: KnowledgeEventType::LeftLOS { target },
});
world.insert_resource(queue);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_knowledge_events);
schedule.run(&mut world);
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
assert_eq!(
kg.confidence_of(&target_sid),
Some(KnowledgeConfidence::KnowsDetails)
);
}
#[test]
fn process_event_for_missing_observer_is_no_op() {
let mut world = World::new();
let registry = EntityRegistry::new(0);
world.insert_resource(registry);
let fake_observer = world.spawn_empty().id(); // no KnowledgeGraph
let fake_target = world.spawn_empty().id();
let mut queue = KnowledgeEventQueue::default();
queue.push(KnowledgeEvent {
observer: fake_observer,
tick: 100,
event_type: KnowledgeEventType::DirectObservation {
target: fake_target,
position: TilePosition::new(5, 5, 0),
},
});
world.insert_resource(queue);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_knowledge_events);
schedule.run(&mut world); // Should not panic
}
}
+395
View File
@@ -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<StableId, EntityKnowledge>,
/// Non-entity facts this entity knows.
/// Key: FactId in "category.topic" format.
pub facts: BTreeMap<FactId, FactKnowledge>,
}
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<KnowledgeConfidence> {
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<Item = (&StableId, &EntityKnowledge)> {
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<u64> = g.known_entities_iter().map(|(id, _)| id.0).collect();
assert_eq!(ids, vec![0, 1, 2, 3, 4]);
}
}
+42
View File
@@ -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::<KnowledgeEventQueue>()
.init_resource::<EntityRegistry>()
.init_resource::<DecayThresholds>()
.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");
}
}
+161
View File
@@ -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<StableId, Entity>,
by_entity: BTreeMap<Entity, StableId>,
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<Entity> {
self.by_stable_id.get(id).copied()
}
/// Lookup: Entity -> StableId (for knowledge graph keys).
pub fn to_stable(&self, entity: Entity) -> Option<StableId> {
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);
}
}
+209
View File
@@ -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<FactId> },
/// 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<TilePosition>,
/// 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<String, String>,
}
/// 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,
},
}
+1
View File
@@ -3,6 +3,7 @@
pub mod bridge;
pub mod cause_chain;
pub mod knowledge;
pub mod npc;
pub mod perception;
pub mod simulation;