feat(simulation): add debug console server and LOS boundary wall margin

Debug and LOS tracks for Sprint 23 (#580, #584):

- DebugCommandKind enum with 10 variants (AdvanceTicks,
  SkipToContamination, TeleportToPosition, ForceContaminationActivate,
  InspectNpc, ListTriangles, ListPopulation, GetContaminationStatus,
  TeleportToLocation, ForceTriangleActivation)
- DebugResponsePayload on ObserverSnapshot, handle_debug_commands
  system gated by DebugEnabled resource
- PROTOCOL_VERSION bumped 17 → 18
- VisibilitySector::BoundaryWall variant — 1-tile wall margin beyond
  LOS boundary included in visible_tiles (not exploration/memory)
- compute_boundary_walls() pass in NaturalVision after FOV+cone

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:12:08 +01:00
co-authored by Claude Opus 4.6
parent 50f5d6c22e
commit c30197db0e
8 changed files with 666 additions and 1 deletions
+4
View File
@@ -392,6 +392,9 @@ pub fn compute_observer_snapshot(
// Consume pending save/load result for this tick (#553).
let save_result = buffer.pending_save_result.take();
// Consume pending debug response for this tick (#580).
let debug_response = buffer.pending_debug_response.take();
// Drain triangle crisis events (#250) and convert to wire format.
// Drain triangle crisis events (#250) and filter role_assignments against
// observer KG (D-010 principle 2: information boundaries are universal).
@@ -464,6 +467,7 @@ pub fn compute_observer_snapshot(
triangle_crisis_events,
state_hash,
sim_errors,
debug_response,
});
}
+148
View File
@@ -96,6 +96,23 @@ impl PerceptionQuery for NaturalVision {
.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,
@@ -105,6 +122,37 @@ impl PerceptionQuery for NaturalVision {
}
}
/// 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)]
@@ -115,3 +163,103 @@ impl Default for ActivePerceptionMode {
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);
}
}
}