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:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user