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
+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);
}
}