//! 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, } 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() } } /// 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>, mut cmd_buffer: ResMut, mut buffer: ResMut, mut time: ResMut, mut contamination: Option>, mut contamination_queue: Option>, walkability: 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), >, mut econ_sim: Option>, econ_state: Option>, ) { // 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::(); 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()); } #[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::() .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>(); 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::() .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::().tick = CONTAMINATION_DELAY_TICKS + 100; world .resource_mut::() .push(DebugCommandKind::SkipToContamination); schedule.run(&mut world); // Tick should NOT have changed (no rewind) assert_eq!( world.resource::().tick, CONTAMINATION_DELAY_TICKS + 100, "tick must not rewind" ); let resp = world .resource::() .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)); } }