From 9c0190c7c2442ea0a555186cfb9a31dcc7c7c1e3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 01:28:53 +0100 Subject: [PATCH 1/3] 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 { -- 2.54.0 From 8c802a241256bd13b6ffc20713f924b6a652933b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 01:29:05 +0100 Subject: [PATCH 2/3] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e17af82f..1025c0ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Entity renderer skips redundant modulate.a writes when alpha unchanged (PR #11 review) ### Added +- Observer snapshot knowledge integration (#366) — VisibleEntity carries relationship state (D-033 color) and observation type (Visible/Remembered), remembered entities appear as fog ghosts at last known position - Knowledge graph system (#361, #362, #363, #365) — per-entity KnowledgeGraph component (D-041), StableEntityId + EntityRegistry, KnowledgeEventQueue, decay system, 4-level confidence hierarchy - Direct observation knowledge flow (#364) — perception emits DirectObservation/LeftLOS events to knowledge graph, entities entering/leaving LOS tracked - Protocol v2 decoder — extracts game_time, player_facing, visible_tiles, and per-entity visibility sectors from ObserverSnapshot v2 -- 2.54.0 From 22de6c714ffa4214a66a46be2ab89dce8031e588 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 01:47:35 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(simulation):=20address=20PR=20#12=20rev?= =?UTF-8?q?iew=20=E2=80=94=20z-level=20filter,=20visible=20tile=20dedup,?= =?UTF-8?q?=20version=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter remembered entities by z-level (Hoshe + Tyre warning) - Skip remembered ghosts on currently visible tiles (Hoshe warning) - Bump ObserverSnapshot version to 3 (Tyre suggestion) - Add edge case tests: visible tile collision, different z-level, knowledge without position (Hoshe suggestion) - Regenerate msgpack fixtures for v3 Co-Authored-By: Claude Opus 4.6 --- .../fixtures/msgpack/snapshot_empty.msgpack | Bin 116 -> 116 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 521 -> 521 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 214 -> 214 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 217 -> 217 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 315 -> 315 bytes server/src/bridge/mod.rs | 2 +- server/src/bridge/types.rs | 3 +- server/src/perception/observer.rs | 180 ++++++++++++++++-- server/tests/bridge_ipc.rs | 2 +- server/tests/bridge_tcp.rs | 2 +- server/tests/game_loop.rs | 2 +- server/tests/gen_fixtures.rs | 4 +- server/tests/serialization.rs | 10 +- 13 files changed, 179 insertions(+), 26 deletions(-) diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 548964aaa41147cb211e137d0172fdd013592651..1c42dbf6cb26e754439b5b723b86e48de18b9305 100644 GIT binary patch delta 16 XcmXRZ;c8o6mReMtnV-izkt+)TH^c@m delta 16 XcmXRZ;c8o6mReMtnV-irkt+)TH^2rh diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 9e2c536193d5a237cf861405bc71fce2115e5049..9e7b42e758c8b13c13533cea71f791d47f05ebaf 100644 GIT binary patch delta 19 acmeBV>15$*TV9r0RGgWg$Gnm2KO+D{cLx># delta 19 acmeBV>15$*TV9r0RGgWg$Fz~_KO+D{at9Rv diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index cda5a37c227aa8203ddc2311fccbba2897e6a931..17032fe868d170255a6ea33510c4e1e712049219 100644 GIT binary patch delta 18 Zcmcb{c#V;(ZFyO0QE_H|9`i)5GXO@Y2UY+8 delta 18 Zcmcb{c#V;(ZFyO0QE_H|9@9jwGXO@U2UP$7 diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index aead58014c472429f014c3a10f94178a76d8b433..8602106d9fb75c346a88ed0c095686a054d3bf89 100644 GIT binary patch delta 18 Zcmcb~c$1N(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 2); + assert_eq!(snapshot.version, 3); assert_eq!(snapshot.entities.len(), 1); assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); @@ -445,15 +456,16 @@ mod tests { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); - // NPC exists but is behind a wall (not visible) + // NPC exists far behind the player (not visible) let npc = world - .spawn((crate::npc::Npc, TilePosition::new(16, 10, 0))) + .spawn((crate::npc::Npc, TilePosition::new(16, 30, 0))) .id(); let npc_sid = registry.register(npc); - // Player previously saw NPC, now it left LOS (KnowsDetails) + // Player previously saw NPC at (16, 28) — behind the player (south), + // well beyond peripheral range. The tile is NOT in the player's FOV. let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.observe_entity(npc_sid, TilePosition::new(16, 28, 0), 50); kg.observe_entity_leaving_los(&npc_sid, 60); kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); @@ -466,11 +478,6 @@ mod tests { )) .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 }); @@ -490,9 +497,9 @@ mod tests { 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) + // Remembered entity at last_known_position (16, 28), not actual (16, 30) assert_eq!(remembered[0].x, 16.5); - assert_eq!(remembered[0].y, 14.5); + assert_eq!(remembered[0].y, 28.5); if let EntityVisibility::Remembered { confidence, age_ticks } = &remembered[0].observation { assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails); @@ -549,4 +556,149 @@ mod tests { "Direct-confidence entities should not appear as remembered ghosts" ); } + + #[test] + fn remembered_entity_on_visible_tile_not_shown() { + // If the player can see a tile and the entity isn't there, + // don't show a ghost — the player knows it moved. + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(30, 30, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player remembers NPC at (16, 15) — a tile the player can currently see + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + )) + .id(); + registry.register(player); + 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(), + "ghost should not appear on a tile the player can currently see" + ); + } + + #[test] + fn remembered_entity_different_z_not_shown() { + // Remembered entity on a different z-level should not appear + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(5, 5, 1))) + .id(); + let npc_sid = registry.register(npc); + + // Player remembers NPC at z=1, but player is at z=0 + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(5, 5, 1), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + )) + .id(); + registry.register(player); + 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(), + "remembered entity on different z-level should not appear in snapshot" + ); + } + + #[test] + fn knowledge_without_position_not_shown() { + // Entity known via gossip (no last_known_position) should not appear + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(5, 5, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player knows about NPC but has never seen it (no position) + let mut kg = KnowledgeGraph::new(); + // Insert knowledge manually without a position + kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: 50, + confidence: KnowledgeConfidence::KnowsOf, + source: crate::knowledge::KnowledgeSource::Background, + state: crate::knowledge::KnowledgeState::Active, + relationship: RelationshipState::PersonOfInterest, + known_attributes: std::collections::BTreeMap::new(), + }); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + )) + .id(); + registry.register(player); + 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(), + "entity without last_known_position should not appear as ghost" + ); + } } diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index d389fdd36..c55bfa4a5 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -33,7 +33,7 @@ fn snapshot_roundtrip_over_unix_socket() { let bridge = LocalBridge::accept(&server_path).expect("failed to accept"); let snapshot = ObserverSnapshot { - version: 2, + version: 3, tick: 42, game_time: GameTime { day: 0, diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 81faed4d0..c15cc484a 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -19,7 +19,7 @@ fn snapshot_roundtrip_over_tcp() { let bridge = TcpBridge::accept_on(listener).expect("failed to accept"); let snapshot = ObserverSnapshot { - version: 2, + version: 3, tick: 42, game_time: GameTime { day: 0, diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 500ab4096..bbced55fc 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -61,7 +61,7 @@ fn player_moves_north_through_full_pipeline() { rmp_serde::from_slice(&response).expect("deserialize snapshot"); // Snapshot captures state at end of tick 0 (before advance_tick increments to 1) - assert_eq!(snapshot.version, 2); + assert_eq!(snapshot.version, 3); assert_eq!(snapshot.tick, 0); assert_eq!(snapshot.entities.len(), 1); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index d4c4c65a6..d421b743c 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -18,7 +18,7 @@ fn write_fixture(name: &str, bytes: &[u8]) { /// Helper to create a minimal v2 snapshot for fixtures fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { ObserverSnapshot { - version: 2, + version: 3, tick, game_time: GameTime { day: 0, @@ -150,7 +150,7 @@ fn generate_msgpack_fixtures() { // v2 snapshot with visible_tiles and game_time populated let snapshot_v2_full = ObserverSnapshot { - version: 2, + version: 3, tick: 500, game_time: GameTime { day: 1, diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 1c5997354..a768136fe 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -7,7 +7,7 @@ use std::fs; /// Helper to create a minimal v2 snapshot for tests fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { ObserverSnapshot { - version: 2, + version: 3, tick, game_time: GameTime { day: 0, @@ -40,7 +40,7 @@ fn observer_snapshot_roundtrip() { let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.version, 2); + assert_eq!(decoded.version, 3); assert_eq!(decoded.tick, 42); assert_eq!(decoded.entities.len(), 1); assert_eq!(decoded.entities[0].entity_id, 1); @@ -178,7 +178,7 @@ fn all_entity_kind_variants_roundtrip() { #[test] fn snapshot_v2_fields_roundtrip() { let snapshot = ObserverSnapshot { - version: 2, + version: 3, tick: 100, game_time: GameTime { day: 3, @@ -216,7 +216,7 @@ fn snapshot_v2_fields_roundtrip() { let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.version, 2); + assert_eq!(decoded.version, 3); assert_eq!(decoded.game_time.day, 3); assert_eq!(decoded.game_time.time_of_day, 720); assert_eq!(decoded.game_time.day_phase, DayPhase::Evening); @@ -244,7 +244,7 @@ fn all_facing_direction_variants_roundtrip() { for dir in directions { let snapshot = ObserverSnapshot { - version: 2, + version: 3, tick: 0, game_time: GameTime { day: 0, -- 2.54.0