Protocol handshake (#555): HandshakeMessage as first IPC frame, HandshakeState resource, forward-compatible input handling. State serialization (#96): serialize_npc_to_frozen/deserialize with full D-024 axis coverage (10 new optional fields on NpcSaveState). Scope tags (#98): ScopeTagKind enum, ScopePinned marker, automatic assignment from KnowledgeGraph and RelationshipGraph. Timestamp eviction (#97): LastInteractionTick, SimSpacePressure, BinaryHeap LRU eviction respecting ScopePinned entities. Save/load (#553): save_to_file/load_from_file via MessagePack, SaveGame/LoadGame IPC commands, SaveLoadResultWire on snapshot. Test infrastructure (#200): Layer 3 integration test entry point, three-layer architecture documented per D-030. Information boundary tests (#272): 4 negative tests proving no passive KG leakage, LOS fog holds, tier boundary holds, save isolation per NPC. 1063 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
320 lines
9.9 KiB
Rust
320 lines
9.9 KiB
Rust
//! 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);
|
|
}
|
|
}
|
|
|
|
/// Advance the next StableId counter to `target`.
|
|
/// Used to reserve StableId ranges for entities not yet spawned
|
|
/// (e.g., Gauntlet rooms built in later sprints).
|
|
/// Panics if `target` is less than the current next_id.
|
|
pub fn reserve_up_to(&mut self, target: u64) {
|
|
assert!(
|
|
target >= self.next_id,
|
|
"cannot reserve backwards: next_id={}, target={}",
|
|
self.next_id,
|
|
target
|
|
);
|
|
self.next_id = target;
|
|
}
|
|
|
|
/// Register an entity with a specific pre-existing StableId (used during save/load).
|
|
///
|
|
/// Unlike `register`, this does NOT advance `next_id`. After bulk-registering
|
|
/// all restored entities, call `advance_past(max_stable_id)` so future `register()`
|
|
/// calls produce IDs that don't conflict with the restored set.
|
|
///
|
|
/// No-op if the entity is already mapped to the same `stable_id`.
|
|
/// Panics in debug builds if `stable_id` is already mapped to a different entity.
|
|
pub fn register_existing(&mut self, entity: Entity, stable_id: StableId) {
|
|
if let Some(&existing) = self.by_stable_id.get(&stable_id) {
|
|
debug_assert_eq!(
|
|
existing, entity,
|
|
"register_existing: StableId {:?} already mapped to a different entity",
|
|
stable_id
|
|
);
|
|
return;
|
|
}
|
|
self.by_stable_id.insert(stable_id, entity);
|
|
self.by_entity.insert(entity, stable_id);
|
|
}
|
|
|
|
/// Advance `next_id` past `id` so future `register()` calls don't conflict.
|
|
///
|
|
/// Unlike `reserve_up_to`, this never panics: if the counter is already past `id`,
|
|
/// this is a no-op. Use after `register_existing` bulk-load to position the counter.
|
|
pub fn advance_past(&mut self, id: u64) {
|
|
let target = id.saturating_add(1);
|
|
if target > self.next_id {
|
|
self.next_id = target;
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
// === EntityRegistry lifecycle edge cases (#469) ===
|
|
|
|
#[test]
|
|
fn stale_mapping_after_despawn() {
|
|
// #469: Registry returns stale Entity after world despawn.
|
|
// This documents the expected behavior — caller must unregister after despawn.
|
|
let mut world = World::new();
|
|
let e1 = world.spawn_empty().id();
|
|
|
|
let mut registry = EntityRegistry::new(0);
|
|
let id = registry.register(e1);
|
|
|
|
// Despawn from world — registry doesn't know
|
|
world.despawn(e1);
|
|
|
|
// Registry still maps the StableId to the (now stale) Entity
|
|
let stale_entity = registry.to_entity(&id);
|
|
assert!(
|
|
stale_entity.is_some(),
|
|
"Registry still holds mapping after world despawn"
|
|
);
|
|
|
|
// But the world no longer recognizes the entity
|
|
assert!(
|
|
world.get_entity(stale_entity.unwrap()).is_err(),
|
|
"World rejects stale entity — caller must call unregister()"
|
|
);
|
|
|
|
// After proper cleanup, mapping is gone
|
|
registry.unregister(e1);
|
|
assert_eq!(
|
|
registry.to_entity(&id),
|
|
None,
|
|
"Mapping gone after unregister"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn register_after_unregister_assigns_new_id() {
|
|
// #469: Re-registering the same entity after unregister gets a new StableId.
|
|
// StableId counter is monotonic — never recycles.
|
|
let mut world = World::new();
|
|
let e1 = world.spawn_empty().id();
|
|
|
|
let mut registry = EntityRegistry::new(0);
|
|
let id_first = registry.register(e1);
|
|
assert_eq!(id_first, StableId(0));
|
|
|
|
registry.unregister(e1);
|
|
|
|
let id_second = registry.register(e1);
|
|
assert_ne!(
|
|
id_first, id_second,
|
|
"Re-registration must assign a new StableId"
|
|
);
|
|
assert_eq!(id_second, StableId(1), "Counter advances monotonically");
|
|
assert_eq!(registry.len(), 1);
|
|
|
|
// New mapping is bidirectionally correct
|
|
assert_eq!(registry.to_entity(&id_second), Some(e1));
|
|
assert_eq!(registry.to_stable(e1), Some(id_second));
|
|
|
|
// Old StableId no longer resolves
|
|
assert_eq!(
|
|
registry.to_entity(&id_first),
|
|
None,
|
|
"Old StableId must not resolve"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reserve_up_to_advances_counter() {
|
|
let mut world = World::new();
|
|
let mut registry = EntityRegistry::new(0);
|
|
|
|
let e1 = world.spawn_empty().id();
|
|
let id1 = registry.register(e1);
|
|
assert_eq!(id1, StableId(0));
|
|
|
|
// Reserve through 5 (skip IDs 1-4)
|
|
registry.reserve_up_to(5);
|
|
|
|
let e2 = world.spawn_empty().id();
|
|
let id2 = registry.register(e2);
|
|
assert_eq!(id2, StableId(5), "next ID after reserve should be 5");
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "cannot reserve backwards")]
|
|
fn reserve_up_to_panics_on_backwards() {
|
|
let mut registry = EntityRegistry::new(10);
|
|
registry.reserve_up_to(5);
|
|
}
|
|
|
|
#[test]
|
|
fn unregister_unknown_entity_is_noop() {
|
|
// #469: Unregistering an entity that was never registered must not panic.
|
|
let mut world = World::new();
|
|
let e1 = world.spawn_empty().id();
|
|
let e2 = world.spawn_empty().id();
|
|
|
|
let mut registry = EntityRegistry::new(0);
|
|
registry.register(e1);
|
|
|
|
// Unregister e2 which was never registered — should be a no-op
|
|
registry.unregister(e2);
|
|
|
|
// e1's registration is unaffected
|
|
assert_eq!(registry.len(), 1);
|
|
assert_eq!(registry.to_stable(e1), Some(StableId(0)));
|
|
}
|
|
}
|