From 9c0190c7c2442ea0a555186cfb9a31dcc7c7c1e3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 01:28:53 +0100 Subject: [PATCH] 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 --- .../msgpack/snapshot_multi_entity.msgpack | Bin 359 -> 521 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 173 -> 214 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 176 -> 217 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 274 -> 315 bytes server/src/bridge/mod.rs | 2 + server/src/bridge/types.rs | 9 + server/src/knowledge/types.rs | 8 +- server/src/perception/observer.rs | 237 +++++++++++++++++- server/tests/bridge_ipc.rs | 2 + server/tests/bridge_tcp.rs | 2 + server/tests/gen_fixtures.rs | 14 ++ server/tests/serialization.rs | 6 + 12 files changed, 270 insertions(+), 10 deletions(-) diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 55f1263d0fdac4ac5903f81f99f71c2cc2aeb8fc..9e2c536193d5a237cf861405bc71fce2115e5049 100644 GIT binary patch delta 205 zcmaFP)X6d-ov~wLhPB3;qSTzklFaIbB#o-D&?V?ngxYs(-`iZ98`NiCiL0Ga(*$p8QV delta 44 vcmeBVdCoK;ow03VhBc5l4J0NTFoMXbj5hph%QA~IlX6nyOEPm(izfg8XXg-e diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 978a620853864d996c5c82c3ed6f9cd139ae6e7c..cda5a37c227aa8203ddc2311fccbba2897e6a931 100644 GIT binary patch delta 68 zcmZ3>c#UyFDr3jQGz*J0MX5Q7C7Jno#Tl6e%R}?B^YY8{R_7-brxulg#g~U=7H1~q Qq^>Q4(D5aiIjO}H0NyDbHvj+t delta 27 icmcb{xR!B3Dr4KkGzQ4(D5aiIjO}H0N`mIKmY&$ delta 27 icmcb~xPfs(Dr4KkG;98~Wtqj9Nja(UC7C&?#S;LPqYCr@ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 6dd91a8ed6731a599c8eed99c097e4e8dd4c3df8..28ee4006a4e8efa74d4b1205b1427908f30c2e24 100644 GIT binary patch delta 56 zcmbQlw3}%{E@Q{UJZsH0MX5Q7C7Jno#Tl6e%R}?B^YY8{R_7-brxulg#g~U=7H1~q Jq)uF!2>_CF7ykeN delta 14 VcmdnZG>K_KE@RuoJnM;67t diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index dc6f398ee..0ca527bba 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -89,6 +89,8 @@ pub fn generate_snapshot( z, kind, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }); } diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 051a7e25a..1d71ca554 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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 diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index 892a4df1a..9cec2ea43 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -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 + } +} diff --git a/server/src/perception/observer.rs b/server/src/perception/observer.rs index 798d07a07..e250c8f22 100644 --- a/server/src/perception/observer.rs +++ b/server/src/perception/observer.rs @@ -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, walkability: Res, - observer_query: Query<(&TilePosition, Option<&Facing>), With>, + registry: Res, + observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With>, all_entities: Query<( Entity, &TilePosition, @@ -29,7 +31,7 @@ pub fn compute_observer_snapshot( )>, mut buffer: ResMut, ) { - 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 = 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::(); + world.init_resource::(); 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::(); @@ -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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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" + ); + } } diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 699b66902..d389fdd36 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -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![], }; diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 0aaf8a5bf..81faed4d0 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -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![], }; diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index e8d9b045c..d4c4c65a6 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -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 { diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 20cdcab27..1c5997354 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -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 {