feat(simulation): integrate observer visibility query (#112)

Replace unfiltered generate_snapshot with compute_observer_snapshot
that combines shadowcasting + vision cone to send only visible
entities and tiles. Enforces information asymmetry (D-011): NPCs
behind walls or in the blind spot are excluded from the snapshot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 00:21:07 +01:00
co-authored by Claude Opus 4.6
parent f760624967
commit e205b38938
5 changed files with 381 additions and 3 deletions
+19 -3
View File
@@ -59,7 +59,9 @@ impl BridgeResource {
}
}
/// Generate ObserverSnapshot from ECS state
/// Generate ObserverSnapshot v2 from ECS state.
/// Pre-visibility version: sends ALL entities (no LOS filtering yet).
/// Will be replaced by perception::observer::compute_observer_snapshot in #112.
pub fn generate_snapshot(
time: Res<crate::simulation::time::SimulationTime>,
entities: Query<(
@@ -86,16 +88,29 @@ pub fn generate_snapshot(
y,
z,
kind,
visibility: VisibilitySector::Forward,
});
}
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!(
"generate_snapshot: tick={}, entities={}",
time.tick,
visible.len()
);
buffer.snapshot = Some(ObserverSnapshot {
version: 2,
tick: time.tick,
game_time,
player_facing: FacingDirection::default(),
entities: visible,
visible_tiles: Vec::new(), // Empty until #112 adds LOS filtering
});
}
@@ -159,10 +174,11 @@ impl Plugin for BridgePlugin {
Update,
(
receive_bridge_inputs.before(crate::simulation::input::process_player_input),
generate_snapshot
crate::perception::observer::compute_observer_snapshot
.after(crate::simulation::movement::validate_movement)
.before(crate::simulation::time::advance_tick),
send_bridge_snapshot.after(generate_snapshot),
send_bridge_snapshot
.after(crate::perception::observer::compute_observer_snapshot),
),
);
tracing::debug!("BridgePlugin initialized");
+333
View File
@@ -0,0 +1,333 @@
//! 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::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>,
observer_query: Query<(&TilePosition, Option<&Facing>), With<PlayerCharacter>>,
all_entities: Query<(
Entity,
&TilePosition,
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
)>,
mut buffer: ResMut<SnapshotBuffer>,
) {
let Ok((observer_pos, facing_opt)) = 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
let mut entities = Vec::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);
entities.push(VisibleEntity {
entity_id: entity.to_bits(), // Temporary: use entity.to_bits() until #362 StableEntityId
x: rx,
y: ry,
z: rz,
kind,
visibility: sector,
});
}
// Step 6: 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={}, entities={}, tiles={}",
time.tick,
entities.len(),
visible_tiles.len(),
);
// Step 7: Assemble v2 snapshot
buffer.snapshot = Some(ObserverSnapshot {
version: 2,
tick: time.tick,
game_time,
player_facing: facing,
entities,
visible_tiles,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::perception::vision_cone::Facing;
use bevy_ecs::world::World;
/// Helper: set up a test world with player and walkability map
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
}
#[test]
fn player_always_visible_in_snapshot() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
));
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, 2);
assert_eq!(snapshot.entities.len(), 1);
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
}
#[test]
fn npc_in_los_visible() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
));
// 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);
}
#[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),
));
// 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),
));
// 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(),
));
// 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(),
));
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(),
));
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");
}
}
+11
View File
@@ -4,6 +4,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::local::LocalBridge;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::SimBridge;
use settled_reach_server::simulation::time::DayPhase;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::thread;
@@ -32,14 +33,24 @@ fn snapshot_roundtrip_over_unix_socket() {
let bridge = LocalBridge::accept(&server_path).expect("failed to accept");
let snapshot = ObserverSnapshot {
version: 2,
tick: 42,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
paused: false,
},
player_facing: FacingDirection::North,
entities: vec![VisibleEntity {
entity_id: 100,
x: 10.5,
y: 20.3,
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
}],
visible_tiles: vec![],
};
bridge
+11
View File
@@ -4,6 +4,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::SimBridge;
use settled_reach_server::simulation::time::DayPhase;
use std::net::{TcpListener, TcpStream};
use std::thread;
@@ -18,14 +19,24 @@ fn snapshot_roundtrip_over_tcp() {
let bridge = TcpBridge::accept_on(listener).expect("failed to accept");
let snapshot = ObserverSnapshot {
version: 2,
tick: 42,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
paused: false,
},
player_facing: FacingDirection::North,
entities: vec![VisibleEntity {
entity_id: 100,
x: 10.5,
y: 20.3,
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
}],
visible_tiles: vec![],
};
bridge
+7
View File
@@ -56,9 +56,15 @@ 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.tick, 0);
assert_eq!(snapshot.entities.len(), 1);
// v2 fields populated
assert_eq!(snapshot.game_time.day, 0);
assert_eq!(snapshot.game_time.day_phase, settled_reach_server::simulation::time::DayPhase::Morning);
assert!(!snapshot.game_time.paused);
let player_entity = &snapshot.entities[0];
// Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0)
// Render coords: (16.5, 15.5, 0)
@@ -66,6 +72,7 @@ fn player_moves_north_through_full_pipeline() {
assert_eq!(player_entity.y, 15.5);
assert_eq!(player_entity.z, 0);
assert!(matches!(player_entity.kind, EntityKind::Player));
assert!(matches!(player_entity.visibility, VisibilitySector::Forward));
// Clean up
drop(reader);