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
+423
View File
@@ -0,0 +1,423 @@
//! Debug console command handler (#580).
//!
//! Processes `DebugCommandKind` variants buffered by `process_player_input`
//! and writes `DebugResponsePayload` to `SnapshotBuffer.pending_debug_response`.
//!
//! Security: only executes when `DebugEnabled` resource is true.
//! v0.1: enabled by default. Cannot be toggled mid-session.
use bevy_ecs::prelude::*;
use crate::bridge::types::{
DebugCommandKind, DebugEnabled, DebugResponsePayload, SnapshotBuffer,
};
use crate::content::template::TriangleState;
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::conversation::NpcName;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
use crate::storyteller::{ContaminationActive, ContaminationEventQueue, CONTAMINATION_DELAY_TICKS};
// ---------------------------------------------------------------------------
// Buffer resource
// ---------------------------------------------------------------------------
/// Buffer for debug commands forwarded from `process_player_input`.
///
/// Drained by `handle_debug_commands` each tick. Only the last command's
/// response is delivered (debug console is request-response, not batched).
#[derive(Resource, Default, Debug)]
pub struct DebugCommandBuffer {
commands: Vec<DebugCommandKind>,
}
impl DebugCommandBuffer {
pub fn push(&mut self, cmd: DebugCommandKind) {
self.commands.push(cmd);
}
pub fn drain(&mut self) -> Vec<DebugCommandKind> {
std::mem::take(&mut self.commands)
}
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Processes buffered debug commands and writes responses to the snapshot buffer.
///
/// Runs after `process_player_input`, before `compute_observer_snapshot`.
/// Gated by `DebugEnabled` — if false, all commands are silently dropped.
#[allow(clippy::too_many_arguments)]
pub fn handle_debug_commands(
debug_enabled: Option<Res<DebugEnabled>>,
mut cmd_buffer: ResMut<DebugCommandBuffer>,
mut buffer: ResMut<SnapshotBuffer>,
mut time: ResMut<SimulationTime>,
mut contamination: Option<ResMut<ContaminationActive>>,
mut contamination_queue: Option<ResMut<ContaminationEventQueue>>,
registry: Res<EntityRegistry>,
mut player_query: Query<(Entity, &mut TilePosition), With<PlayerCharacter>>,
triangles: Query<(Entity, &TriangleState), With<ActiveSim>>,
npcs: Query<(Entity, &TilePosition, Option<&NpcName>), (With<Npc>, With<ActiveSim>, Without<PlayerCharacter>)>,
) {
// Gate: debug must be enabled
let enabled = debug_enabled.as_ref().map_or(false, |d| d.0);
let commands = cmd_buffer.drain();
if commands.is_empty() {
return;
}
// Process each command; last response wins (single debug_response per tick)
for cmd in commands {
let response = if !enabled {
DebugResponsePayload {
command: format!("{:?}", cmd),
text: "Debug console is disabled.".to_string(),
success: false,
}
} else {
match cmd {
DebugCommandKind::AdvanceTicks(n) => {
let old_tick = time.tick;
time.tick = time.tick.saturating_add(n);
DebugResponsePayload {
command: format!("AdvanceTicks({})", n),
text: format!(
"Advanced {} ticks: {} -> {}",
n, old_tick, time.tick
),
success: true,
}
}
DebugCommandKind::SkipToContamination => {
let is_active = contamination.as_ref().map(|c| c.0);
match is_active {
None => DebugResponsePayload {
command: "SkipToContamination".to_string(),
text: "Contamination system not available.".to_string(),
success: false,
},
Some(true) => DebugResponsePayload {
command: "SkipToContamination".to_string(),
text: format!(
"Contamination already active (fired at or before tick {}). Current tick: {}",
time.tick, time.tick
),
success: true,
},
Some(false) => {
let old_tick = time.tick;
time.tick = CONTAMINATION_DELAY_TICKS;
DebugResponsePayload {
command: "SkipToContamination".to_string(),
text: format!(
"Skipped to contamination delay: tick {} -> {}. Contamination will fire on next system run.",
old_tick, time.tick
),
success: true,
}
}
}
}
DebugCommandKind::TeleportToPosition { x, y, z } => {
if let Ok((_, mut pos)) = player_query.single_mut() {
let old = (pos.x, pos.y, pos.z);
pos.x = x;
pos.y = y;
pos.z = z;
DebugResponsePayload {
command: format!("TeleportToPosition({}, {}, {})", x, y, z),
text: format!(
"Teleported player: ({}, {}, {}) -> ({}, {}, {})",
old.0, old.1, old.2, x, y, z
),
success: true,
}
} else {
DebugResponsePayload {
command: format!("TeleportToPosition({}, {}, {})", x, y, z),
text: "No player entity found.".to_string(),
success: false,
}
}
}
DebugCommandKind::TeleportToLocation(ref name) => {
// Location-based teleport requires ContentStore (future: resolve location
// center from tile_bounds). For now, report unimplemented.
DebugResponsePayload {
command: format!("TeleportToLocation({})", name),
text: format!(
"TeleportToLocation not yet implemented (needs location tile_bounds from ContentStore). Use TeleportToPosition instead."
),
success: false,
}
}
DebugCommandKind::ForceContaminationActivate => {
if contamination.is_none() {
DebugResponsePayload {
command: "ForceContaminationActivate".to_string(),
text: "Contamination system not available.".to_string(),
success: false,
}
} else if contamination.as_ref().unwrap().0 {
DebugResponsePayload {
command: "ForceContaminationActivate".to_string(),
text: "Contamination already active.".to_string(),
success: true,
}
} else {
contamination.as_mut().unwrap().0 = true;
// Also push an event so downstream systems react
if let Some(ref mut queue) = contamination_queue {
queue.push(crate::storyteller::ContaminationEvent {
tick: time.tick,
triangles_affected: 0, // no pressure delta applied — use SkipToContamination for that
});
}
DebugResponsePayload {
command: "ForceContaminationActivate".to_string(),
text: format!(
"Contamination force-activated at tick {}. Note: no tension delta applied (use SkipToContamination for full effect).",
time.tick
),
success: true,
}
}
}
DebugCommandKind::ForceTriangleActivation(ref slug) => {
// Future: match triangle by slug and force-activate.
// Requires TriangleId slug lookup which isn't indexed yet.
DebugResponsePayload {
command: format!("ForceTriangleActivation({})", slug),
text: "ForceTriangleActivation not yet implemented (needs TriangleId slug index).".to_string(),
success: false,
}
}
DebugCommandKind::InspectNpc(wire_id) => {
let stable_id = crate::knowledge::types::StableId(wire_id);
if let Some(entity) = registry.to_entity(&stable_id) {
if let Ok((_, pos, name)) = npcs.get(entity) {
let name_str = name.map(|n| n.0.as_str()).unwrap_or("(unnamed)");
DebugResponsePayload {
command: format!("InspectNpc({})", wire_id),
text: format!(
"NPC {} (stable_id={})\n Position: ({}, {}, {})\n Name: {}",
entity, wire_id, pos.x, pos.y, pos.z, name_str
),
success: true,
}
} else {
DebugResponsePayload {
command: format!("InspectNpc({})", wire_id),
text: format!("Entity {} exists but is not an Active-tier NPC.", wire_id),
success: false,
}
}
} else {
DebugResponsePayload {
command: format!("InspectNpc({})", wire_id),
text: format!("No entity found for stable_id {}.", wire_id),
success: false,
}
}
}
DebugCommandKind::ListTriangles => {
let mut lines = Vec::new();
lines.push("=== Triangles ===".to_string());
let mut count = 0u32;
for (entity, state) in &triangles {
count += 1;
lines.push(format!(
" {} | id={} | phase={:?} | tension={} | class={:?} | template={}",
entity,
state.triangle_id.0,
state.phase,
state.tension,
state.classification,
state.template_id.0,
));
}
lines.push(format!("Total: {}", count));
DebugResponsePayload {
command: "ListTriangles".to_string(),
text: lines.join("\n"),
success: true,
}
}
DebugCommandKind::ListPopulation => {
let mut lines = Vec::new();
lines.push("=== Active-Tier NPCs ===".to_string());
let mut count = 0u32;
for (entity, pos, name) in &npcs {
count += 1;
let stable = registry.to_stable(entity);
let name_str = name.map(|n| n.0.as_str()).unwrap_or("(unnamed)");
lines.push(format!(
" {} | sid={} | pos=({},{},{}) | {}",
entity,
stable.map(|s| s.0.to_string()).unwrap_or_else(|| "?".to_string()),
pos.x, pos.y, pos.z,
name_str,
));
}
lines.push(format!("Total: {}", count));
DebugResponsePayload {
command: "ListPopulation".to_string(),
text: lines.join("\n"),
success: true,
}
}
DebugCommandKind::GetContaminationStatus => {
if let Some(ref cont) = contamination {
DebugResponsePayload {
command: "GetContaminationStatus".to_string(),
text: format!(
"Contamination active: {}\nCurrent tick: {}\nContamination delay: {} ticks",
cont.0, time.tick, CONTAMINATION_DELAY_TICKS
),
success: true,
}
} else {
DebugResponsePayload {
command: "GetContaminationStatus".to_string(),
text: "Contamination system not available.".to_string(),
success: false,
}
}
}
}
};
tracing::debug!(command = %response.command, success = response.success, "Debug command processed");
buffer.pending_debug_response = Some(response);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::movement::TilePosition;
use bevy_ecs::schedule::Schedule;
fn setup_debug_world() -> (World, Schedule) {
let mut world = World::new();
world.init_resource::<DebugCommandBuffer>();
world.init_resource::<SnapshotBuffer>();
world.init_resource::<SimulationTime>();
world.init_resource::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
world.init_resource::<EntityRegistry>();
world.insert_resource(DebugEnabled(true));
// Spawn a player entity
world.spawn((PlayerCharacter, TilePosition::new(10, 20, 0)));
let mut schedule = Schedule::default();
schedule.add_systems(handle_debug_commands);
(world, schedule)
}
#[test]
fn advance_ticks_updates_simulation_time() {
let (mut world, mut schedule) = setup_debug_world();
world.resource_mut::<SimulationTime>().tick = 100;
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::AdvanceTicks(50));
schedule.run(&mut world);
assert_eq!(world.resource::<SimulationTime>().tick, 150);
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(resp.success);
assert!(resp.text.contains("150"));
}
#[test]
fn skip_to_contamination_sets_tick() {
let (mut world, mut schedule) = setup_debug_world();
world.resource_mut::<SimulationTime>().tick = 10;
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::SkipToContamination);
schedule.run(&mut world);
assert_eq!(world.resource::<SimulationTime>().tick, CONTAMINATION_DELAY_TICKS);
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(resp.success);
}
#[test]
fn teleport_moves_player() {
let (mut world, mut schedule) = setup_debug_world();
world.resource_mut::<DebugCommandBuffer>().push(
DebugCommandKind::TeleportToPosition { x: 50, y: 60, z: 1 },
);
schedule.run(&mut world);
let mut q = world.query_filtered::<&TilePosition, With<PlayerCharacter>>();
let pos = q.single(&world).unwrap();
assert_eq!((pos.x, pos.y, pos.z), (50, 60, 1));
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(resp.success);
}
#[test]
fn force_contamination_activates() {
let (mut world, mut schedule) = setup_debug_world();
assert!(!world.resource::<ContaminationActive>().0);
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::ForceContaminationActivate);
schedule.run(&mut world);
assert!(world.resource::<ContaminationActive>().0);
assert!(!world.resource::<ContaminationEventQueue>().is_empty());
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(resp.success);
}
#[test]
fn debug_disabled_rejects_commands() {
let (mut world, mut schedule) = setup_debug_world();
world.insert_resource(DebugEnabled(false));
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::GetContaminationStatus);
schedule.run(&mut world);
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(!resp.success);
assert!(resp.text.contains("disabled"));
}
#[test]
fn get_contamination_status_reports_state() {
let (mut world, mut schedule) = setup_debug_world();
world.resource_mut::<SimulationTime>().tick = 42;
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::GetContaminationStatus);
schedule.run(&mut world);
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
assert!(resp.success);
assert!(resp.text.contains("false")); // not yet active
assert!(resp.text.contains("42")); // current tick
}
#[test]
fn no_commands_produces_no_response() {
let (mut world, mut schedule) = setup_debug_world();
schedule.run(&mut world);
assert!(world.resource::<SnapshotBuffer>().pending_debug_response.is_none());
}
}