From e205b38938522359ed4f8df3cfbea176768d48c4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 00:21:07 +0100 Subject: [PATCH] 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 --- server/src/bridge/mod.rs | 22 +- server/src/perception/observer.rs | 333 ++++++++++++++++++++++++++++++ server/tests/bridge_ipc.rs | 11 + server/tests/bridge_tcp.rs | 11 + server/tests/game_loop.rs | 7 + 5 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 server/src/perception/observer.rs diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 702bd608d..c115f68f3 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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, 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"); diff --git a/server/src/perception/observer.rs b/server/src/perception/observer.rs new file mode 100644 index 000000000..798d07a07 --- /dev/null +++ b/server/src/perception/observer.rs @@ -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, + walkability: Res, + observer_query: Query<(&TilePosition, Option<&Facing>), With>, + all_entities: Query<( + Entity, + &TilePosition, + Option<&PlayerCharacter>, + Option<&crate::npc::Npc>, + )>, + mut buffer: ResMut, +) { + 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 = 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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"); + } +} diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 3d110b8bd..699b66902 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -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 diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 9f5bdf97a..0aaf8a5bf 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -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 diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 97109f6ca..7927d1211 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -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);