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>
711 lines
31 KiB
Rust
711 lines
31 KiB
Rust
//! 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));
|
||
}
|
||
}
|