- 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>
269 lines
9.3 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|