diff --git a/server/src/bridge/debug.rs b/server/src/bridge/debug.rs new file mode 100644 index 000000000..efbe628b8 --- /dev/null +++ b/server/src/bridge/debug.rs @@ -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, +} + +impl DebugCommandBuffer { + pub fn push(&mut self, cmd: DebugCommandKind) { + self.commands.push(cmd); + } + + pub fn drain(&mut self) -> Vec { + 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>, + mut cmd_buffer: ResMut, + mut buffer: ResMut, + mut time: ResMut, + mut contamination: Option>, + mut contamination_queue: Option>, + registry: Res, + mut player_query: Query<(Entity, &mut TilePosition), With>, + triangles: Query<(Entity, &TriangleState), With>, + npcs: Query<(Entity, &TilePosition, Option<&NpcName>), (With, With, Without)>, +) { + // 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::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + 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::().tick = 100; + world.resource_mut::().push(DebugCommandKind::AdvanceTicks(50)); + + schedule.run(&mut world); + + assert_eq!(world.resource::().tick, 150); + let resp = world.resource::().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::().tick = 10; + world.resource_mut::().push(DebugCommandKind::SkipToContamination); + + schedule.run(&mut world); + + assert_eq!(world.resource::().tick, CONTAMINATION_DELAY_TICKS); + let resp = world.resource::().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::().push( + DebugCommandKind::TeleportToPosition { x: 50, y: 60, z: 1 }, + ); + + schedule.run(&mut world); + + let mut q = world.query_filtered::<&TilePosition, With>(); + let pos = q.single(&world).unwrap(); + assert_eq!((pos.x, pos.y, pos.z), (50, 60, 1)); + + let resp = world.resource::().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::().0); + + world.resource_mut::().push(DebugCommandKind::ForceContaminationActivate); + schedule.run(&mut world); + + assert!(world.resource::().0); + assert!(!world.resource::().is_empty()); + let resp = world.resource::().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::().push(DebugCommandKind::GetContaminationStatus); + + schedule.run(&mut world); + + let resp = world.resource::().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::().tick = 42; + world.resource_mut::().push(DebugCommandKind::GetContaminationStatus); + + schedule.run(&mut world); + + let resp = world.resource::().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::().pending_debug_response.is_none()); + } +} diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index ac1f27f96..8f34db088 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .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 diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 1339302b5..4b45d7425 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -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")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index a2a14f177..2d1a14068 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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, + /// 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, } /// 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, /// Pending save/load result, consumed once by `compute_observer_snapshot` (#553). pub pending_save_result: Option, + /// Pending debug response, consumed once by `compute_observer_snapshot` (#580). + pub pending_debug_response: Option, } #[cfg(test)] diff --git a/server/src/main.rs b/server/src/main.rs index 91e97c2b0..30c76e037 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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), diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 011e65591..7586075e3 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -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, }); } diff --git a/server/src/perception/query.rs b/server/src/perception/query.rs index b38500e98..ed0b7b0fd 100644 --- a/server/src/perception/query.rs +++ b/server/src/perception/query.rs @@ -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); + } + } +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 789991789..126ef0f15 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -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>, mut save_load: Option>, + mut debug_cmd_buffer: Option>, 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"); + } + } } }