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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod bridge;
|
||||
pub mod cause_chain;
|
||||
pub mod knowledge;
|
||||
pub mod npc;
|
||||
pub mod perception;
|
||||
pub mod simulation;
|
||||
|
||||
Reference in New Issue
Block a user