Files
settled-reach/server/src/perception/observer.rs
T
jpmschweitzerandClaude Opus 4.6 0b599ed662 feat(simulation): spawn 3 NPCs with full component bundles
Implements ticket #84 for Sprint 3:
- Replace single bare NPC with 3 distinct NPCs: dock worker (16,13)
  with full 4-phase routine and MovementSpeed(2), field tech (14,18)
  with partial routine, stationary guard (18,14) with no routine
- Register all entities (player + 3 NPCs) in EntityRegistry
- Populate RelationshipGraph with colleague and rival edges
- Add NpcPlugin to app for routine system registration
- Add multi-entity visibility tests: 3 NPCs in LOS all visible,
  NPC behind wall excluded from snapshot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 17:51:50 +01:00

772 lines
27 KiB
Rust

//! Observer visibility query system (#112)
//!
//! Replaces the unfiltered `generate_snapshot` with a visibility-aware version.
//! Combines shadowcasting + vision cone to determine what the observer can see,
//! then populates ObserverSnapshot v2 with only visible entities and tiles.
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};
use crate::simulation::time::SimulationTime;
/// Compute observer snapshot with LOS filtering and vision cone.
///
/// System ordering: after validate_movement, before advance_tick.
/// Replaces bridge::generate_snapshot.
pub fn compute_observer_snapshot(
time: Res<SimulationTime>,
walkability: Res<WalkabilityMap>,
registry: Res<EntityRegistry>,
observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With<PlayerCharacter>>,
all_entities: Query<(
Entity,
&TilePosition,
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
)>,
mut buffer: ResMut<SnapshotBuffer>,
) {
let Ok((observer_pos, facing_opt, observer_kg)) = observer_query.single() else {
return;
};
let facing = facing_opt
.map(|f| f.0)
.unwrap_or(FacingDirection::default());
let config = VisionConeConfig::default();
let z = observer_pos.z;
// Step 1: Compute raw FOV using symmetric shadowcasting
let fov = compute_fov(
|x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)),
observer_pos.x,
observer_pos.y,
config.forward_range,
z,
);
// Step 2: Apply vision cone to get sector-tagged tiles
let cone_tiles =
apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config);
// Step 3: Build visible_tiles for the snapshot
let visible_tiles: Vec<VisibleTile> = cone_tiles
.iter()
.map(|&(x, y, sector)| VisibleTile {
x,
y,
z,
visibility: sector,
})
.collect();
// Step 4: Build lookup set for fast entity visibility check
let visible_positions: HashSet<(i32, i32)> =
cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect();
// Build sector lookup (position -> sector)
let sector_lookup: std::collections::HashMap<(i32, i32), VisibilitySector> = cone_tiles
.iter()
.map(|&(x, y, sector)| ((x, y), sector))
.collect();
// 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 {
continue;
}
// Not in visible tile set: not visible
if !visible_positions.contains(&(pos.x, pos.y)) {
continue;
}
let (rx, ry, rz) = pos.to_render_coords();
let kind = if is_player.is_some() {
EntityKind::Player
} else if is_npc.is_some() {
EntityKind::Npc
} else {
EntityKind::Object
};
let sector = sector_lookup
.get(&(pos.x, pos.y))
.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(),
x: rx,
y: ry,
z: rz,
kind,
visibility: sector,
relationship,
observation: EntityVisibility::Visible,
});
}
// 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;
};
// Skip if remembered position is on a different z-level than the observer
if position.z != z {
continue;
}
// Skip if the remembered tile is currently visible — if the player
// can see the tile and the entity isn't there, don't show a ghost.
if visible_positions.contains(&(position.x, position.y)) {
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(),
day_phase: time.day_phase(),
paused: time.paused,
};
tracing::trace!(
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
time.tick,
visible_entity_bits.len(),
entities.len() - visible_entity_bits.len(),
visible_tiles.len(),
);
// Step 8: Assemble snapshot (v3: added relationship + observation fields)
buffer.snapshot = Some(ObserverSnapshot {
version: 3,
tick: time.tick,
game_time,
player_facing: facing,
entities,
visible_tiles,
});
}
#[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, 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
}
#[test]
fn player_always_visible_in_snapshot() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
));
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().expect("snapshot should exist");
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);
}
#[test]
fn npc_in_los_visible() {
let mut world = setup_world(32, 32);
world.spawn((
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)));
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();
assert_eq!(snapshot.entities.len(), 2);
let npc = snapshot
.entities
.iter()
.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]
fn npc_behind_wall_not_visible() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
));
// Wall between player and NPC
let mut walkability = world.resource_mut::<WalkabilityMap>();
walkability.set_walkable(&TilePosition::new(16, 14, 0), false);
// NPC behind the wall
world.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)));
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();
// Only player should be visible, not the NPC behind the wall
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert!(npcs.is_empty(), "NPC behind wall should not be visible");
}
#[test]
fn npc_behind_player_not_visible() {
let mut world = setup_world(32, 32);
world.spawn((
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)));
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 npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert!(npcs.is_empty(), "NPC in blind spot should not be visible");
}
#[test]
fn different_z_level_not_visible() {
let mut world = setup_world(32, 32);
world.spawn((
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)));
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 npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert!(npcs.is_empty(), "NPC on different z should not be visible");
}
#[test]
fn game_time_populated() {
let mut world = setup_world(32, 32);
world.insert_resource(SimulationTime {
tick: 7200, // 720 minutes = Evening
paused: true,
});
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
));
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();
assert_eq!(snapshot.game_time.time_of_day, 720);
assert_eq!(
snapshot.game_time.day_phase,
crate::simulation::time::DayPhase::Evening
);
assert!(snapshot.game_time.paused);
}
#[test]
fn visible_tiles_populated() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
));
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();
assert!(
!snapshot.visible_tiles.is_empty(),
"should have visible tiles"
);
// Observer's tile should be in the list
let has_observer_tile = snapshot
.visible_tiles
.iter()
.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 far behind the player (not visible)
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(16, 30, 0)))
.id();
let npc_sid = registry.register(npc);
// 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, 28, 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);
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 at last_known_position (16, 28), not actual (16, 30)
assert_eq!(remembered[0].x, 16.5);
assert_eq!(remembered[0].y, 28.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"
);
}
#[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::<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(),
"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::<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(),
"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::<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(),
"entity without last_known_position should not appear as ghost"
);
}
#[test]
fn multiple_npcs_in_los_all_visible() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
));
// Three NPCs in front of player, no walls
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0)));
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();
// Player + 3 NPCs = 4 entities
assert_eq!(snapshot.entities.len(), 4);
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert_eq!(npcs.len(), 3);
assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible));
}
#[test]
fn npc_behind_wall_excluded_from_multi_entity_snapshot() {
let mut world = setup_world(32, 32);
// Wall at (16,14)
world
.resource_mut::<WalkabilityMap>()
.set_walkable(&TilePosition::new(16, 14, 0), false);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
));
// NPC 1: behind wall (should be hidden)
world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0)));
// NPC 2: to the side, no wall (should be visible)
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
// NPC 3: also visible
world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0)));
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();
// Player + 2 visible NPCs = 3 (NPC behind wall excluded)
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded");
}
}