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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user