//! Perception query trait (D-017). //! //! Abstraction for perception mode geometry computation. Each mode //! (natural vision, thermal, EM, etc.) implements PerceptionQuery to //! provide mode-specific FOV and visibility sector computation. //! v0.1 implements only NaturalVision. //! //! Note: HashMap is used for `sector_lookup` — a per-frame scratch buffer //! looked up only by key. Iteration order is irrelevant here. Not subject to //! the simulation determinism constraint (see server/.clippy.toml). //! Consumer contract: every consumer must sort (or otherwise impose a //! deterministic order on) this data before it touches simulation state or //! the wire. #![allow(clippy::disallowed_types)] use std::collections::{BTreeSet, HashMap}; use bevy_ecs::prelude::*; use crate::bridge::types::{FacingDirection, TileKind, VisibilitySector, VisibleTile}; use crate::perception::shadowcast::compute_fov; use crate::perception::vision_cone::{apply_vision_cone, VisionConeConfig}; use crate::simulation::movement::{TilePosition, WalkabilityMap}; /// Cached FOV geometry for the current frame. Produced by /// compute_visibility_geometry, consumed by compute_observer_snapshot. /// D-017 perception modes swap the geometry producer while the consumer /// remains unchanged. #[derive(Resource, Default)] pub struct VisibilityGeometry { pub visible_tiles: Vec, pub visible_positions: BTreeSet<(i32, i32)>, pub sector_lookup: HashMap<(i32, i32), VisibilitySector>, pub observer_z: i32, } /// Trait for perception mode geometry computation (D-017). /// /// Each perception mode implements this to produce a VisibilityGeometry /// from the observer's position and facing. v0.1 only implements /// NaturalVision; D-017 adds Thermal, EM, etc. pub trait PerceptionQuery: Send + Sync { fn compute_geometry( &self, observer_pos: &TilePosition, facing: FacingDirection, walkability: &WalkabilityMap, ) -> VisibilityGeometry; } /// Natural vision — default perception mode. /// Uses symmetric shadowcasting (D-011) + directional vision cone (D-015). pub struct NaturalVision; impl PerceptionQuery for NaturalVision { fn compute_geometry( &self, observer_pos: &TilePosition, facing: FacingDirection, walkability: &WalkabilityMap, ) -> VisibilityGeometry { let config = VisionConeConfig::default(); let z = observer_pos.z; 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, ); let cone_tiles = apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config); let mut visible_tiles: Vec = cone_tiles .iter() .map(|&(x, y, sector)| { let tile_kind = if walkability.can_move_to(&TilePosition::new(x, y, z)) { TileKind::Floor } else { TileKind::Wall }; VisibleTile { x, y, z, visibility: sector, tile_kind, zone_id: None, } }) .collect(); visible_tiles.sort_by_key(|t| (t.x, t.y)); let visible_positions = cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect(); let sector_lookup = cone_tiles .iter() .map(|&(x, y, sector)| ((x, y), sector)) .collect(); // --- Boundary wall margin pass (#584) --- // Walk the LOS boundary and include non-walkable tiles 1 tile beyond. // This gives the client wall geometry at the fog edge. let boundary_walls = compute_boundary_walls(&visible_positions, walkability, z); for (bx, by) in &boundary_walls { visible_tiles.push(VisibleTile { x: *bx, y: *by, z, visibility: VisibilitySector::BoundaryWall, tile_kind: TileKind::Wall, zone_id: None, }); } // Re-sort after adding boundary walls visible_tiles.sort_by_key(|t| (t.x, t.y)); VisibilityGeometry { visible_tiles, visible_positions, sector_lookup, observer_z: z, } } } /// Compute wall tiles 1 tile beyond the LOS boundary (#584). /// /// For each tile on the boundary of the visible set (has at least one /// 4-neighbor outside the set), check each non-visible neighbor. /// If that neighbor is not walkable, include it as a boundary wall. /// /// Returns deduplicated (x, y) positions of wall tiles to add. fn compute_boundary_walls( visible_positions: &BTreeSet<(i32, i32)>, walkability: &WalkabilityMap, z: i32, ) -> Vec<(i32, i32)> { const NEIGHBORS: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; let mut walls = BTreeSet::new(); for &(x, y) in visible_positions { for (dx, dy) in NEIGHBORS { let nx = x + dx; let ny = y + dy; if !visible_positions.contains(&(nx, ny)) { let pos = TilePosition::new(nx, ny, z); if !walkability.can_move_to(&pos) { walls.insert((nx, ny)); } } } } walls.into_iter().collect() } /// Resource wrapping the active perception mode (D-017). /// Defaults to NaturalVision. Swap this resource to change perception modes. #[derive(Resource)] pub struct ActivePerceptionMode(pub Box); impl Default for ActivePerceptionMode { fn default() -> Self { Self(Box::new(NaturalVision)) } } #[cfg(test)] mod tests { use super::*; /// Build a small walkability map with walls around the edges. /// Layout (5x5, z=0): /// W W W W W /// W F F F W /// W F F F W /// W F F F W /// W W W W W fn make_walled_map() -> WalkabilityMap { let mut map = WalkabilityMap::new(5, 5, 1); // All tiles start walkable (floor). Set border to non-walkable (wall). for x in 0..5 { map.set_walkable(&TilePosition::new(x, 0, 0), false); map.set_walkable(&TilePosition::new(x, 4, 0), false); } for y in 0..5 { map.set_walkable(&TilePosition::new(0, y, 0), false); map.set_walkable(&TilePosition::new(4, y, 0), false); } map } #[test] fn boundary_walls_include_adjacent_walls() { // Visible set: just the center tile (2,2) let visible: BTreeSet<(i32, i32)> = [(2, 2)].into_iter().collect(); let map = make_walled_map(); let walls = compute_boundary_walls(&visible, &map, 0); // All 4 neighbors of (2,2) are floor tiles (walkable), so no walls. // This verifies we don't add walkable tiles as boundary walls. assert!(walls.is_empty()); } #[test] fn boundary_walls_found_at_edge() { // Visible set: tiles along the north interior edge (y=1) let visible: BTreeSet<(i32, i32)> = [(1, 1), (2, 1), (3, 1)].into_iter().collect(); let map = make_walled_map(); let walls = compute_boundary_walls(&visible, &map, 0); // North neighbors (y=0) are all walls: (1,0), (2,0), (3,0) // Also (0,1) is a wall (west of (1,1)) and (4,1) (east of (3,1)) assert!(walls.contains(&(1, 0))); assert!(walls.contains(&(2, 0))); assert!(walls.contains(&(3, 0))); assert!(walls.contains(&(0, 1))); assert!(walls.contains(&(4, 1))); } #[test] fn boundary_walls_deduplicated() { // Two adjacent visible tiles share a wall neighbor let visible: BTreeSet<(i32, i32)> = [(1, 1), (2, 1)].into_iter().collect(); let map = make_walled_map(); let walls = compute_boundary_walls(&visible, &map, 0); // Count how many times (1,0) appears — should be exactly 1 (deduplicated) let count = walls.iter().filter(|&&(x, y)| x == 1 && y == 0).count(); assert_eq!(count, 1, "boundary walls should be deduplicated"); } #[test] fn boundary_walls_not_in_visible_positions() { // Verify the full NaturalVision pipeline produces BoundaryWall tiles // that are NOT in visible_positions. let map = make_walled_map(); let nv = NaturalVision; let pos = TilePosition::new(2, 2, 0); let geometry = nv.compute_geometry(&pos, FacingDirection::North, &map); let boundary_tiles: Vec<_> = geometry .visible_tiles .iter() .filter(|t| t.visibility == VisibilitySector::BoundaryWall) .collect(); // There should be boundary wall tiles (the 5x5 map has walls at edges) assert!(!boundary_tiles.is_empty(), "expected boundary wall tiles"); // None of the boundary wall tiles should be in visible_positions for tile in &boundary_tiles { assert!( !geometry.visible_positions.contains(&(tile.x, tile.y)), "BoundaryWall tile ({}, {}) should NOT be in visible_positions", tile.x, tile.y ); } // All boundary wall tiles should have TileKind::Wall for tile in &boundary_tiles { assert_eq!(tile.tile_kind, TileKind::Wall); } } }