feat(simulation): integrate knowledge graph into observer snapshot (#366)

VisibleEntity now carries relationship state (D-033 entity color) and
observation type (Visible vs Remembered). compute_observer_snapshot
queries the player's KnowledgeGraph to overlay relationship data on
visible entities and include remembered (not-in-LOS) entities as fog
ghosts at their last known position. Regenerated msgpack fixtures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 01:28:53 +01:00
co-authored by Claude Opus 4.6
parent 12dc0bc0bf
commit 9c0190c7c2
12 changed files with 270 additions and 10 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -89,6 +89,8 @@ pub fn generate_snapshot(
z,
kind,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
});
}
+9
View File
@@ -5,6 +5,7 @@
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
pub use crate::knowledge::types::{EntityVisibility, KnowledgeConfidence, RelationshipState};
pub use crate::simulation::time::DayPhase;
/// The ONLY data structure crossing the client-server boundary (D-020)
@@ -95,6 +96,14 @@ pub struct VisibleEntity {
pub kind: EntityKind,
/// Which vision cone sector this entity falls in (D-015)
pub visibility: VisibilitySector,
/// Relationship state for D-033 entity color rendering.
/// Unknown for entities not yet in the knowledge graph.
#[serde(default)]
pub relationship: RelationshipState,
/// Whether this entity is currently visible or remembered from knowledge.
/// Visible = in LOS right now. Remembered = known but not in LOS.
#[serde(default)]
pub observation: EntityVisibility,
}
/// Category of visible entity
+7 -1
View File
@@ -205,7 +205,7 @@ impl Default for DecayThresholds {
/// How an entity appears in the observer snapshot.
/// Extends VisibleEntity for knowledge-based rendering.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntityVisibility {
/// Currently in line of sight.
Visible,
@@ -215,3 +215,9 @@ pub enum EntityVisibility {
age_ticks: u64,
},
}
impl Default for EntityVisibility {
fn default() -> Self {
Self::Visible
}
}
+228 -9
View File
@@ -8,6 +8,7 @@ use bevy_ecs::prelude::*;
use std::collections::HashSet;
use crate::bridge::types::*;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::perception::shadowcast::compute_fov;
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
@@ -20,7 +21,8 @@ use crate::simulation::time::SimulationTime;
pub fn compute_observer_snapshot(
time: Res<SimulationTime>,
walkability: Res<WalkabilityMap>,
observer_query: Query<(&TilePosition, Option<&Facing>), With<PlayerCharacter>>,
registry: Res<EntityRegistry>,
observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With<PlayerCharacter>>,
all_entities: Query<(
Entity,
&TilePosition,
@@ -29,7 +31,7 @@ pub fn compute_observer_snapshot(
)>,
mut buffer: ResMut<SnapshotBuffer>,
) {
let Ok((observer_pos, facing_opt)) = observer_query.single() else {
let Ok((observer_pos, facing_opt, observer_kg)) = observer_query.single() else {
return;
};
@@ -74,8 +76,9 @@ pub fn compute_observer_snapshot(
.map(|&(x, y, sector)| ((x, y), sector))
.collect();
// Step 5: Filter entities by visibility
// Step 5: Filter entities by visibility, overlay knowledge
let mut entities = Vec::new();
let mut visible_entity_bits: HashSet<u64> = HashSet::new();
for (entity, pos, is_player, is_npc) in all_entities.iter() {
// Different z-level: not visible
if pos.z != z {
@@ -101,17 +104,72 @@ pub fn compute_observer_snapshot(
.copied()
.unwrap_or(VisibilitySector::Peripheral);
// Look up relationship from knowledge graph (D-033 entity color)
let relationship = if is_player.is_some() {
RelationshipState::Known // Self
} else if let Some(stable_id) = registry.to_stable(entity) {
observer_kg.relationship_with(&stable_id)
} else {
RelationshipState::Unknown
};
visible_entity_bits.insert(entity.to_bits());
entities.push(VisibleEntity {
entity_id: entity.to_bits(), // Temporary: use entity.to_bits() until #362 StableEntityId
entity_id: entity.to_bits(),
x: rx,
y: ry,
z: rz,
kind,
visibility: sector,
relationship,
observation: EntityVisibility::Visible,
});
}
// Step 6: Build GameTime from SimulationTime
// Step 6: Add remembered entities from knowledge graph (#366)
// Entities the observer knows about but can't currently see.
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
// Skip if currently visible (already in the entity list)
if let Some(entity) = registry.to_entity(stable_id) {
if visible_entity_bits.contains(&entity.to_bits()) {
continue;
}
}
// Skip if no known position (never directly observed)
let Some(position) = knowledge.last_known_position else {
continue;
};
// Direct confidence means the entity should be in LOS — if it isn't,
// that's a transient data inconsistency. Skip rather than show a ghost.
if knowledge.confidence == KnowledgeConfidence::Direct {
continue;
}
let (rx, ry, rz) = position.to_render_coords();
let age_ticks = time.tick.saturating_sub(knowledge.last_observed_tick);
let entity_id = registry
.to_entity(stable_id)
.map(|e| e.to_bits())
.unwrap_or(stable_id.0);
entities.push(VisibleEntity {
entity_id,
x: rx,
y: ry,
z: rz,
kind: EntityKind::Npc, // Remembered entities are NPCs (only NPCs are tracked)
visibility: VisibilitySector::Forward, // Not meaningful for remembered entities
relationship: knowledge.relationship,
observation: EntityVisibility::Remembered {
confidence: knowledge.confidence,
age_ticks,
},
});
}
// Step 7: Build GameTime from SimulationTime
let game_time = GameTime {
day: time.day(),
time_of_day: time.time_of_day_minutes(),
@@ -120,13 +178,14 @@ pub fn compute_observer_snapshot(
};
tracing::trace!(
"compute_observer_snapshot: tick={}, entities={}, tiles={}",
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
time.tick,
entities.len(),
visible_entity_bits.len(),
entities.len() - visible_entity_bits.len(),
visible_tiles.len(),
);
// Step 7: Assemble v2 snapshot
// Step 8: Assemble snapshot
buffer.snapshot = Some(ObserverSnapshot {
version: 2,
tick: time.tick,
@@ -140,15 +199,17 @@ pub fn compute_observer_snapshot(
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::perception::vision_cone::Facing;
use bevy_ecs::world::World;
/// Helper: set up a test world with player and walkability map
/// Helper: set up a test world with player, walkability map, and knowledge resources
fn setup_world(width: i32, height: i32) -> World {
let mut world = World::new();
world.insert_resource(SimulationTime::default());
world.insert_resource(WalkabilityMap::new(width, height, 1));
world.init_resource::<SnapshotBuffer>();
world.init_resource::<EntityRegistry>();
world
}
@@ -159,6 +220,7 @@ mod tests {
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
@@ -170,6 +232,7 @@ mod tests {
assert_eq!(snapshot.version, 2);
assert_eq!(snapshot.entities.len(), 1);
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible);
}
#[test]
@@ -179,6 +242,7 @@ mod tests {
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
));
// NPC directly north of player (in forward cone)
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
@@ -196,6 +260,7 @@ mod tests {
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible");
assert_eq!(npc.visibility, VisibilitySector::Forward);
assert_eq!(npc.observation, EntityVisibility::Visible);
}
#[test]
@@ -205,6 +270,7 @@ mod tests {
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
));
// Wall between player and NPC
let mut walkability = world.resource_mut::<WalkabilityMap>();
@@ -234,6 +300,7 @@ mod tests {
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
));
// NPC far behind player (south, in blind spot)
world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)));
@@ -259,6 +326,7 @@ mod tests {
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
));
// NPC on different z-level
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1)));
@@ -288,6 +356,7 @@ mod tests {
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
@@ -311,6 +380,7 @@ mod tests {
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
@@ -330,4 +400,153 @@ mod tests {
.any(|t| t.x == 16 && t.y == 16 && t.z == 0);
assert!(has_observer_tile, "observer tile should be visible");
}
#[test]
fn visible_npc_has_relationship_from_knowledge() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_sid = registry.register(npc);
// Player knows NPC is hostile
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::Hostile);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
));
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let npc_entity = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible");
assert_eq!(npc_entity.relationship, RelationshipState::Hostile);
assert_eq!(npc_entity.observation, EntityVisibility::Visible);
}
#[test]
fn remembered_entity_appears_as_ghost() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC exists but is behind a wall (not visible)
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 10, 0)))
.id();
let npc_sid = registry.register(npc);
// Player previously saw NPC, now it left LOS (KnowsDetails)
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
kg.observe_entity_leaving_los(&npc_sid, 60);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
))
.id();
registry.register(player);
// Wall blocks LOS to NPC's actual position
let mut walkability = world.resource_mut::<WalkabilityMap>();
walkability.set_walkable(&TilePosition::new(16, 12, 0), false);
world.insert_resource(registry);
world.insert_resource(SimulationTime { tick: 100, paused: false });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// Should have player (visible) + NPC (remembered)
let remembered: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
.collect();
assert_eq!(remembered.len(), 1, "should have one remembered entity");
assert_eq!(remembered[0].relationship, RelationshipState::PersonOfInterest);
// Remembered entity should be at last_known_position (16, 14) not actual (16, 10)
assert_eq!(remembered[0].x, 16.5);
assert_eq!(remembered[0].y, 14.5);
if let EntityVisibility::Remembered { confidence, age_ticks } = &remembered[0].observation {
assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails);
assert_eq!(*age_ticks, 50); // tick 100 - last_observed 50
}
}
#[test]
fn direct_confidence_not_shown_as_remembered() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
// NPC exists but not in LOS
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 10, 0)))
.id();
let npc_sid = registry.register(npc);
// Knowledge still shows Direct (transient inconsistency)
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
// Still Direct — don't show as ghost
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
))
.id();
registry.register(player);
// Wall blocks actual NPC position
let mut walkability = world.resource_mut::<WalkabilityMap>();
walkability.set_walkable(&TilePosition::new(16, 12, 0), false);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
let remembered: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
.collect();
assert!(
remembered.is_empty(),
"Direct-confidence entities should not appear as remembered ghosts"
);
}
}
+2
View File
@@ -49,6 +49,8 @@ fn snapshot_roundtrip_over_unix_socket() {
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
}],
visible_tiles: vec![],
};
+2
View File
@@ -35,6 +35,8 @@ fn snapshot_roundtrip_over_tcp() {
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
}],
visible_tiles: vec![],
};
+14
View File
@@ -45,6 +45,8 @@ fn generate_msgpack_fixtures() {
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
}],
);
write_fixture(
@@ -86,6 +88,8 @@ fn generate_msgpack_fixtures() {
z: 0,
kind: EntityKind::Player,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
}],
);
write_fixture(
@@ -104,6 +108,8 @@ fn generate_msgpack_fixtures() {
z: 0,
kind: EntityKind::Player,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Known,
observation: EntityVisibility::Visible,
},
VisibleEntity {
entity_id: 2,
@@ -112,6 +118,8 @@ fn generate_msgpack_fixtures() {
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Peripheral,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
},
VisibleEntity {
entity_id: 3,
@@ -120,6 +128,8 @@ fn generate_msgpack_fixtures() {
z: 1,
kind: EntityKind::Object,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
},
VisibleEntity {
entity_id: 4,
@@ -128,6 +138,8 @@ fn generate_msgpack_fixtures() {
z: -1,
kind: EntityKind::Terrain,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
},
],
);
@@ -154,6 +166,8 @@ fn generate_msgpack_fixtures() {
z: 0,
kind: EntityKind::Player,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
}],
visible_tiles: vec![
VisibleTile {
+6
View File
@@ -32,6 +32,8 @@ fn observer_snapshot_roundtrip() {
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
}],
);
@@ -158,6 +160,8 @@ fn all_entity_kind_variants_roundtrip() {
z: 0,
kind,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
};
let snapshot = test_snapshot(0, vec![entity]);
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -190,6 +194,8 @@ fn snapshot_v2_fields_roundtrip() {
z: 0,
kind: EntityKind::Player,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
}],
visible_tiles: vec![
VisibleTile {