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,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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user