Files
settled-reach/server/src/perception/query.rs
T
jpmschweitzerandClaude Fable 5 0bd895fcac chore(engine): server hygiene batch — workers tests, surname dedup, save pin (T-1063, T-1064)
- workers/pool.rs: 5 new tests; catch_unwind keeps worker threads alive on
  handler panic (in-flight request loss unchanged, pinned by test + #843
  docs); stubs.rs no longer falsely claims the pool is tested
- save/load: execute_save_load pinned .after(Storyteller) so the scheduler
  cannot legally save pre-Input state; exclusive-system exception recorded
  in tick_phases.rs rules
- surname corpus extracted to bin/shared/surname_corpus.rs (both economy
  generators import it; byte-identical output verified on 23.6MB+1.45MB
  TOMLs); all three stamp/watch registries updated
- generator_spike gated behind non-default 'generator-spike' feature
- economy.rs: 11 new D-181 signal-derivation tests on the new
  econ_sim Simulation::from_economy in-memory constructor
- perception exemption comments now state the consumer sort contract;
  unused bytemuck removed; rayon comment corrected; the 22 allow(dead_code)
  documented as serde schema enforcement

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:22:28 +02:00

269 lines
9.3 KiB
Rust

//! 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<VisibleTile>,
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<VisibleTile> = 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<dyn PerceptionQuery>);
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);
}
}
}