Files
settled-reach/server/src/bridge/debug.rs
T
jpmschweitzerandClaude Fable 5 257d979aed refactor(simulation): split the input.rs and dialogue.rs dispatchers (T-1062)
input.rs 2,739 → 858 lines: per-domain action handlers moved to their
owning modules (inventory, movement, stance, examine, follow, interaction,
save_io, settings, bridge::debug, economy, vision_cone, bookmark,
test_world reset + new teleport.rs); input.rs keeps the queue, the thin
dispatch table, and pause/cooldown glue. All 9 type_complexity allows
dissolved via one PlayerInputQuery alias.

dialogue.rs → dialogue/ directory module: selection (631), response
(1,473), confrontation (714), mod.rs (226, shared session components +
re-exports — public paths preserved). Documented seam deviation:
process_walk_away lives with confrontation (D-064/D-063 share the same
world-response shape).

Mechanical, zero behavior change: determinism + golden_suite byte-identical
(independently re-verified); 1,504 lib tests unchanged — 23 input tests
moved with their subjects, 53 dialogue tests redistributed, zero deleted.
System scheduling registrations untouched (input_plugin.rs 0-line diff).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:07:36 +02:00

711 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, EconDebugEffect, EconParamKind,
SnapshotBuffer,
};
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::economy::{EconSimResource, EconStateResource};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::npc_components::NpcName;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
use crate::simulation::triangle::TriangleState;
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()
}
}
/// Queue a debug command from player input.
/// `None` means the buffer was never registered (warn and drop).
pub fn queue_debug_command(buf: Option<&mut DebugCommandBuffer>, cmd: DebugCommandKind) {
match buf {
Some(buf) => buf.push(cmd),
None => {
tracing::warn!("DebugCommand received but DebugCommandBuffer not registered");
}
}
}
// ---------------------------------------------------------------------------
// 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>>,
walkability: Option<Res<WalkabilityMap>>,
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>),
>,
mut econ_sim: Option<ResMut<EconSimResource>>,
econ_state: Option<Res<EconStateResource>>,
) {
// Gate: debug must be enabled
let enabled = debug_enabled.as_ref().is_some_and(|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) if time.tick >= CONTAMINATION_DELAY_TICKS => {
// Tick is already past the delay — advancing would be a no-op
// or rewinding would break cooldowns. Report error instead.
DebugResponsePayload {
command: "SkipToContamination".to_string(),
text: format!(
"Current tick ({}) already past contamination delay ({}). Contamination should fire on next system run — use ForceContaminationActivate if it hasn't.",
time.tick, CONTAMINATION_DELAY_TICKS
),
success: false,
}
}
Some(false) => {
let old_tick = time.tick;
time.tick = CONTAMINATION_DELAY_TICKS;
DebugResponsePayload {
command: "SkipToContamination".to_string(),
text: format!(
"Advanced tick to contamination threshold: {} -> {}. Contamination will fire on next system run.",
old_tick, time.tick
),
success: true,
}
}
}
}
DebugCommandKind::TeleportToPosition { x, y, z } => {
let target = TilePosition::new(x, y, z);
// Validate target is walkable (if WalkabilityMap available)
let walkable = walkability
.as_ref()
.is_none_or(|wm| wm.can_move_to(&target));
if !walkable {
DebugResponsePayload {
command: format!("TeleportToPosition({}, {}, {})", x, y, z),
text: format!(
"Target ({}, {}, {}) is not walkable. Player would be stuck in wall/void.",
x, y, z
),
success: false,
}
} else 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: "TeleportToLocation not yet implemented (needs location tile_bounds from ContentStore). Use TeleportToPosition instead.".to_string(),
success: false,
}
}
DebugCommandKind::ForceContaminationActivate => {
if let Some(ref mut cont) = contamination {
if cont.0 {
DebugResponsePayload {
command: "ForceContaminationActivate".to_string(),
text: "Contamination already active.".to_string(),
success: true,
}
} else {
cont.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,
}
}
} else {
DebugResponsePayload {
command: "ForceContaminationActivate".to_string(),
text: "Contamination system not available.".to_string(),
success: false,
}
}
}
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,
}
}
}
DebugCommandKind::InjectEconEvent {
ref target,
ref effect,
magnitude,
duration_ticks,
} => {
use econ_sim::events::{
EconEvent, EconEventEffect, EconEventTarget, EconEventVisibility,
};
if let Some(ref mut sim) = econ_sim {
let econ_effect = match effect {
EconDebugEffect::CapacityMultiplier => {
EconEventEffect::CapacityMultiplier(magnitude)
}
EconDebugEffect::ProductivityMultiplier => {
EconEventEffect::ProductivityMultiplier(magnitude)
}
EconDebugEffect::DemandShock => EconEventEffect::DemandShock(magnitude),
EconDebugEffect::ExchangeShock => {
EconEventEffect::ExchangeShock(magnitude)
}
};
sim.sim.events.push(EconEvent {
target: EconEventTarget::Node(target.clone()),
effect: econ_effect,
duration: duration_ticks,
visibility: EconEventVisibility::Global,
});
DebugResponsePayload {
command: format!("InjectEconEvent({}, {:?}, {}×{})", target, effect, magnitude, duration_ticks),
text: format!(
"Event injected: {:?} ×{} on node '{}' for {} ticks.\nTakes effect on next economy tick.",
effect, magnitude, target, duration_ticks
),
success: true,
}
} else {
DebugResponsePayload {
command: "InjectEconEvent".to_string(),
text: "Economy simulation not loaded.".to_string(),
success: false,
}
}
}
DebugCommandKind::SetEconParam { ref param, value } => {
if let Some(ref mut sim) = econ_sim {
match param {
EconParamKind::TatonnementStep => {
let old = sim.sim.alpha;
sim.sim.alpha = value;
DebugResponsePayload {
command: format!("SetEconParam(TatonnementStep, {})", value),
text: format!("α (tâtonnement step): {} → {}", old, value),
success: true,
}
}
EconParamKind::DampingFactor => {
let old = sim.sim.beta;
sim.sim.beta = value;
DebugResponsePayload {
command: format!("SetEconParam(DampingFactor, {})", value),
text: format!("β (damping factor): {} → {}", old, value),
success: true,
}
}
EconParamKind::CorridorFriction { ref corridor_id } => {
DebugResponsePayload {
command: format!("SetEconParam(CorridorFriction({}))", corridor_id),
text: "Per-corridor friction override not yet implemented (requires corridor friction model in trade.rs).".to_string(),
success: false,
}
}
}
} else {
DebugResponsePayload {
command: "SetEconParam".to_string(),
text: "Economy simulation not loaded.".to_string(),
success: false,
}
}
}
DebugCommandKind::GetEconState { ref system_id } => {
if let Some(ref state) = econ_state {
let signals: Vec<_> = state
.signals
.iter()
.filter(|((sys, _), _)| sys == system_id)
.collect();
if signals.is_empty() {
DebugResponsePayload {
command: format!("GetEconState({})", system_id),
text: format!("System '{}' not found in economy state.", system_id),
success: false,
}
} else {
let mut lines = vec![
format!(
"=== Economy state for '{}' (econ_tick={}) ===",
system_id, state.econ_tick
),
format!(" FX rate (Tractus/Mark): {:.4}", state.tractus_mark_rate),
];
for ((_, commodity_id), sig) in &signals {
lines.push(format!(
" {} | price={:.2} trend={:+.2} flow={:.1} corps={} stockpile_wks={:.1} prod_vs_base={:.3} coverage={:.2}",
commodity_id,
sig.price_current,
sig.price_trend,
sig.trade_flow_volume,
sig.corporate_presence,
sig.stockpile_weeks,
sig.production_vs_baseline,
sig.official_coverage_ratio,
));
}
DebugResponsePayload {
command: format!("GetEconState({})", system_id),
text: lines.join("\n"),
success: true,
}
}
} else {
DebugResponsePayload {
command: format!("GetEconState({})", system_id),
text: "Economy simulation not loaded.".to_string(),
success: false,
}
}
}
}
};
tracing::debug!(command = %response.command, success = response.success, "Debug command processed");
if buffer.pending_debug_response.is_some() {
tracing::trace!(
"Debug response overwritten by '{}' — earlier response dropped (last-wins per tick)",
response.command
);
}
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());
}
#[test]
fn teleport_rejects_unwalkable_target() {
let (mut world, mut schedule) = setup_debug_world();
let mut map = WalkabilityMap::new(10, 10, 1);
map.set_walkable(&TilePosition::new(5, 5, 0), false);
world.insert_resource(map);
world
.resource_mut::<DebugCommandBuffer>()
.push(DebugCommandKind::TeleportToPosition { x: 5, y: 5, z: 0 });
schedule.run(&mut world);
// Player should NOT have moved
let mut q = world.query_filtered::<&TilePosition, With<PlayerCharacter>>();
let pos = q.single(&world).unwrap();
assert_eq!(
(pos.x, pos.y, pos.z),
(10, 20, 0),
"player must not teleport to unwalkable tile"
);
let resp = world
.resource::<SnapshotBuffer>()
.pending_debug_response
.as_ref()
.unwrap();
assert!(!resp.success);
assert!(resp.text.contains("not walkable"));
}
#[test]
fn skip_to_contamination_rejects_when_past_delay() {
let (mut world, mut schedule) = setup_debug_world();
// Set tick past the delay but contamination not yet active
world.resource_mut::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS + 100;
world
.resource_mut::<DebugCommandBuffer>()
.push(DebugCommandKind::SkipToContamination);
schedule.run(&mut world);
// Tick should NOT have changed (no rewind)
assert_eq!(
world.resource::<SimulationTime>().tick,
CONTAMINATION_DELAY_TICKS + 100,
"tick must not rewind"
);
let resp = world
.resource::<SnapshotBuffer>()
.pending_debug_response
.as_ref()
.unwrap();
assert!(!resp.success);
assert!(resp.text.contains("already past"));
}
#[test]
fn debug_enabled_defaults_to_debug_assertions() {
let d = DebugEnabled::default();
assert_eq!(d.0, cfg!(debug_assertions));
}
}