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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod debug;
|
||||
pub mod framing;
|
||||
pub mod local;
|
||||
pub mod tcp;
|
||||
@@ -221,12 +222,17 @@ impl Plugin for BridgePlugin {
|
||||
.init_resource::<ServerRunning>()
|
||||
.init_resource::<HandshakeState>()
|
||||
.init_resource::<SimErrorBuffer>()
|
||||
.init_resource::<debug::DebugCommandBuffer>()
|
||||
.init_resource::<DebugEnabled>()
|
||||
.init_resource::<crate::perception::query::VisibilityGeometry>()
|
||||
.init_resource::<crate::perception::query::ActivePerceptionMode>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
receive_bridge_inputs.before(crate::simulation::input::process_player_input),
|
||||
debug::handle_debug_commands
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
crate::perception::observer::compute_visibility_geometry
|
||||
.after(crate::simulation::movement::validate_movement),
|
||||
crate::simulation::interaction::compute_nearby_interactions
|
||||
|
||||
@@ -192,6 +192,7 @@ fn sector_label(sector: VisibilitySector) -> &'static str {
|
||||
match sector {
|
||||
VisibilitySector::Forward => "Forward",
|
||||
VisibilitySector::Peripheral => "Periph",
|
||||
VisibilitySector::BoundaryWall => "BndWall",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +317,7 @@ mod tests {
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
sim_errors: vec![],
|
||||
debug_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,6 +456,7 @@ mod tests {
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
sim_errors: vec![],
|
||||
debug_response: None,
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
|
||||
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 17;
|
||||
pub const PROTOCOL_VERSION: u8 = 18;
|
||||
|
||||
/// Handshake message sent as the very first framed message after connection (#555).
|
||||
/// Client reads this before entering the normal tick loop and validates
|
||||
@@ -191,6 +191,11 @@ pub struct ObserverSnapshot {
|
||||
/// Empty in normal operation. Client may display a warning toast.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub sim_errors: Vec<SimError>,
|
||||
/// Debug console response for this tick (#580).
|
||||
/// Present when a debug command was processed. Client renders in
|
||||
/// the tilde console overlay. None in normal gameplay.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub debug_response: Option<DebugResponsePayload>,
|
||||
}
|
||||
|
||||
/// Game time data for client display (D-031)
|
||||
@@ -336,6 +341,10 @@ pub enum VisibilitySector {
|
||||
Forward,
|
||||
/// Reduced range, dimmer rendering (side arcs)
|
||||
Peripheral,
|
||||
/// Wall tile 1 beyond the LOS boundary (#584).
|
||||
/// Gives the client wall data at the fog edge so fog composites over
|
||||
/// real geometry rather than empty space. Not stored in exploration/memory.
|
||||
BoundaryWall,
|
||||
}
|
||||
|
||||
/// A visible entity in the simulation
|
||||
@@ -473,6 +482,9 @@ pub enum PlayerAction {
|
||||
/// Client sends this when the player selects a save file to load.
|
||||
/// Server executes load_from_file and sends SaveLoadResultWire confirmation.
|
||||
LoadGame { path: String },
|
||||
/// Debug console command (#580). Only processed when `DebugEnabled` is true.
|
||||
/// Response delivered via `ObserverSnapshot.debug_response`.
|
||||
DebugCommand(DebugCommandKind),
|
||||
}
|
||||
|
||||
impl PlayerAction {
|
||||
@@ -492,6 +504,62 @@ impl PlayerAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug command variants for the in-game console (#580).
|
||||
///
|
||||
/// Sent via `PlayerAction::DebugCommand`. Only processed when `DebugEnabled`
|
||||
/// resource is true. Response returned in `ObserverSnapshot.debug_response`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DebugCommandKind {
|
||||
/// Fast-forward simulation by N ticks.
|
||||
AdvanceTicks(u64),
|
||||
/// Advance simulation to `CONTAMINATION_DELAY_TICKS` (skip early game).
|
||||
SkipToContamination,
|
||||
/// Move player to absolute tile position.
|
||||
TeleportToPosition { x: i32, y: i32, z: i32 },
|
||||
/// Move player to a named location's origin (e.g. "the-terminal").
|
||||
TeleportToLocation(String),
|
||||
/// Bypass contamination timer — fire `ContaminationActive` immediately.
|
||||
ForceContaminationActivate,
|
||||
/// Force-activate a triangle by its string slug identifier.
|
||||
ForceTriangleActivation(String),
|
||||
/// Dump NPC state: current routine, knowledge graph summary, relationships.
|
||||
InspectNpc(u64),
|
||||
/// Return all `TriangleState` entities with phase/tension/classification.
|
||||
ListTriangles,
|
||||
/// Return all Active-tier NPCs with tier, position, and name.
|
||||
ListPopulation,
|
||||
/// Return `ContaminationActive` status and current tick.
|
||||
GetContaminationStatus,
|
||||
}
|
||||
|
||||
/// Debug response payload included in `ObserverSnapshot` (#580).
|
||||
///
|
||||
/// Carries the result of a debug command as a human-readable text block
|
||||
/// plus structured data where useful. The client's debug console displays
|
||||
/// the `text` field directly.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DebugResponsePayload {
|
||||
/// The command that produced this response (for client-side echo).
|
||||
pub command: String,
|
||||
/// Human-readable response text (multi-line, displayed in console).
|
||||
pub text: String,
|
||||
/// Whether the command succeeded.
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Whether the debug console is enabled (#580).
|
||||
///
|
||||
/// Set at server startup. Cannot be toggled mid-session via IPC.
|
||||
/// v0.1: defaults to true. Production builds will default to false.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct DebugEnabled(pub bool);
|
||||
|
||||
impl Default for DebugEnabled {
|
||||
fn default() -> Self {
|
||||
Self(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Available interaction verbs for a nearby entity (D-060, #404)
|
||||
/// Embedded in ObserverSnapshot.nearby_interactions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -804,6 +872,8 @@ pub struct SnapshotBuffer {
|
||||
pub snapshot: Option<ObserverSnapshot>,
|
||||
/// Pending save/load result, consumed once by `compute_observer_snapshot` (#553).
|
||||
pub pending_save_result: Option<SaveLoadResultWire>,
|
||||
/// Pending debug response, consumed once by `compute_observer_snapshot` (#580).
|
||||
pub pending_debug_response: Option<DebugResponsePayload>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -300,6 +300,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
|
||||
save_result: None,
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
sim_errors: vec![SimError {
|
||||
kind: SimErrorKind::Panic,
|
||||
message: format!("Simulation panic: {}", panic_msg),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Timestamped player input events for deterministic simulation (D-010 principle 4)
|
||||
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance)
|
||||
|
||||
use crate::bridge::debug::DebugCommandBuffer;
|
||||
use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput};
|
||||
use crate::knowledge::{EntityRegistry, StableId};
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
@@ -98,6 +99,7 @@ pub fn process_player_input(
|
||||
reset_triggers: Query<&RoomResetTrigger>,
|
||||
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
|
||||
mut save_load: Option<ResMut<SaveLoadPending>>,
|
||||
mut debug_cmd_buffer: Option<ResMut<DebugCommandBuffer>>,
|
||||
door_states: Query<&DoorState>,
|
||||
object_types: Query<&ObjectType>,
|
||||
) {
|
||||
@@ -119,6 +121,7 @@ pub fn process_player_input(
|
||||
| PlayerAction::TeleportToHub
|
||||
| PlayerAction::SaveGame { .. }
|
||||
| PlayerAction::LoadGame { .. }
|
||||
| PlayerAction::DebugCommand(_)
|
||||
)
|
||||
{
|
||||
continue;
|
||||
@@ -364,6 +367,13 @@ pub fn process_player_input(
|
||||
tracing::warn!("LoadGame received but SaveLoadPending resource not registered");
|
||||
}
|
||||
}
|
||||
PlayerAction::DebugCommand(cmd) => {
|
||||
if let Some(ref mut buf) = debug_cmd_buffer {
|
||||
buf.push(cmd);
|
||||
} else {
|
||||
tracing::warn!("DebugCommand received but DebugCommandBuffer not registered");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user