// Input processing system // Timestamped player input events for deterministic simulation (D-010 principle 4) // PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance) use crate::bridge::types::{PlayerAction, PlayerInput}; use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; use crate::simulation::stance::{PlayerMoveCooldown, Stance}; use crate::simulation::time::{SimulationTime, TickRate}; use bevy_ecs::prelude::*; use std::collections::VecDeque; /// Queue of pending player inputs, ordered by tick #[derive(Resource, Debug, Default)] pub struct InputQueue { queue: VecDeque, } impl InputQueue { /// Add a new input to the queue. /// Inputs must be pushed in tick order for deterministic processing. /// Panics in debug builds if tick ordering is violated. pub fn push(&mut self, input: PlayerInput) { debug_assert!( self.queue.back().is_none_or(|last| last.tick <= input.tick), "InputQueue: tick ordering violated (last={}, new={})", self.queue.back().map_or(0, |last| last.tick), input.tick, ); self.queue.push_back(input); } /// Drain all inputs for ticks <= the given tick /// Returns inputs in FIFO order pub fn drain_for_tick(&mut self, tick: u64) -> Vec { let mut result = Vec::new(); while let Some(front) = self.queue.front() { if front.tick <= tick { result.push(self.queue.pop_front().unwrap()); } else { break; } } result } /// Get the current queue length pub fn len(&self) -> usize { self.queue.len() } /// Check if the queue is empty pub fn is_empty(&self) -> bool { self.queue.is_empty() } } /// Drains InputQueue for the current tick, converts PlayerActions to ECS components. /// Handles stance toggling (D-053) and movement cooldown based on current stance. #[allow(clippy::type_complexity)] pub fn process_player_input( mut input_queue: ResMut, mut time: ResMut, mut commands: Commands, mut player_query: Query< (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), With, >, ) { let current_tick = time.tick; let inputs = input_queue.drain_for_tick(current_tick); // Track whether any movement was attempted this tick (for cooldown tick advance) let mut move_attempted = false; for input in inputs { match input.action { PlayerAction::MoveNorth => { move_attempted = true; apply_move(&mut player_query, &mut commands, 0, -1); } PlayerAction::MoveSouth => { move_attempted = true; apply_move(&mut player_query, &mut commands, 0, 1); } PlayerAction::MoveEast => { move_attempted = true; apply_move(&mut player_query, &mut commands, 1, 0); } PlayerAction::MoveWest => { move_attempted = true; apply_move(&mut player_query, &mut commands, -1, 0); } PlayerAction::MoveNortheast => { move_attempted = true; apply_move(&mut player_query, &mut commands, 1, -1); } PlayerAction::MoveNorthwest => { move_attempted = true; apply_move(&mut player_query, &mut commands, -1, -1); } PlayerAction::MoveSoutheast => { move_attempted = true; apply_move(&mut player_query, &mut commands, 1, 1); } PlayerAction::MoveSouthwest => { move_attempted = true; apply_move(&mut player_query, &mut commands, -1, 1); } PlayerAction::ToggleStanceUp => { if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() { let new_stance = stance.0.step_up(); if new_stance != stance.0 { tracing::debug!("Stance up: {:?} -> {:?}", stance.0, new_stance); stance.0 = new_stance; } } } PlayerAction::ToggleStanceDown => { if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() { let new_stance = stance.0.step_down(); if new_stance != stance.0 { tracing::debug!("Stance down: {:?} -> {:?}", stance.0, new_stance); stance.0 = new_stance; } } } PlayerAction::Pause => { time.tick_rate = TickRate::Paused; tracing::debug!("Simulation paused by player input"); } PlayerAction::Unpause => { time.tick_rate = TickRate::Full; tracing::debug!("Simulation unpaused by player input"); } PlayerAction::SetTickRate(rate) => { time.tick_rate = rate; tracing::debug!("Tick rate set to {:?} by player input", rate); } PlayerAction::Interact { target_entity_id, verb } => { tracing::info!( "Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)", target_entity_id, verb, ); } PlayerAction::UsePerceptionMode(ref mode) => { tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode); } } } // If no movement was attempted this tick, still advance cooldown counter if !move_attempted { if let Ok((_, _, _, Some(mut cooldown))) = player_query.single_mut() { cooldown.tick(); } } } /// Apply a movement action with stance-based cooldown enforcement. /// If the player has a Stance and PlayerMoveCooldown, movement is throttled /// according to the stance's ticks_per_move. Without these components, /// movement is unrestricted (backward compatibility). #[allow(clippy::type_complexity)] fn apply_move( player_query: &mut Query< (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), With, >, commands: &mut Commands, dx: i32, dy: i32, ) { let (entity, pos, stance_opt, cooldown_opt) = player_query .single_mut() .expect("PlayerCharacter entity must exist when processing input"); let stance = stance_opt.map(|s| s.0).unwrap_or_default(); // Check cooldown if present if let Some(mut cooldown) = cooldown_opt { if !cooldown.try_move(stance) { tracing::trace!("Movement throttled by stance {:?} cooldown", stance); return; } } commands.entity(entity).insert(MoveIntent { target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z), }); // Update facing direction based on movement (D-015 vision cone) commands .entity(entity) .insert(Facing(facing_from_delta(dx, dy))); } #[cfg(test)] mod tests { use super::*; #[test] fn drain_returns_inputs_up_to_tick() { let mut queue = InputQueue::default(); queue.push(PlayerInput { tick: 1, action: PlayerAction::MoveNorth, }); queue.push(PlayerInput { tick: 2, action: PlayerAction::MoveSouth, }); queue.push(PlayerInput { tick: 5, action: PlayerAction::Interact { target_entity_id: None, verb: None }, }); let inputs = queue.drain_for_tick(3); assert_eq!(inputs.len(), 2); assert_eq!(queue.len(), 1); } #[test] fn drain_empty_queue_returns_empty() { let mut queue = InputQueue::default(); let inputs = queue.drain_for_tick(10); assert!(inputs.is_empty()); } #[test] #[should_panic(expected = "tick ordering violated")] fn push_rejects_out_of_order_in_debug() { let mut queue = InputQueue::default(); queue.push(PlayerInput { tick: 5, action: PlayerAction::MoveNorth, }); queue.push(PlayerInput { tick: 2, action: PlayerAction::MoveSouth, }); } #[test] fn process_input_move_creates_intent() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); let intent = world.get::(player).unwrap(); assert_eq!(intent.target, TilePosition::new(5, 4, 0)); } #[test] fn process_input_pause_sets_paused() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Pause, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); assert_eq!(world.resource::().tick_rate, TickRate::Paused); } #[test] fn process_input_set_tick_rate() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::SetTickRate(TickRate::Half), }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); assert_eq!(world.resource::().tick_rate, TickRate::Half); } #[test] #[should_panic(expected = "PlayerCharacter entity must exist")] fn process_input_no_player_panics() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); } #[test] fn process_input_future_tick_ignored() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); world.resource_mut::().push(PlayerInput { tick: 5, action: PlayerAction::MoveNorth, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); // No MoveIntent should be created (input for future tick) assert!(world.get::(player).is_none()); } use crate::bridge::types::MovementStance; #[test] fn toggle_stance_up_changes_stance() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), Stance::default(), PlayerMoveCooldown::default(), )); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::ToggleStanceUp, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); let mut query = world.query::<&Stance>(); let stance = query.single(&world).unwrap(); assert_eq!(stance.0, MovementStance::Sprint); } #[test] fn toggle_stance_down_changes_stance() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), Stance::default(), PlayerMoveCooldown::default(), )); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::ToggleStanceDown, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); let mut query = world.query::<&Stance>(); let stance = query.single(&world).unwrap(); assert_eq!(stance.0, MovementStance::Careful); } #[test] fn walk_stance_throttles_movement_to_every_2_ticks() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), Stance::default(), // Walk PlayerMoveCooldown::default(), )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); // Tick 0: move north — should succeed (first move) world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); schedule.run(&mut world); assert!(world.get::(player).is_some(), "first move should succeed"); // Remove MoveIntent (simulating validate_movement consuming it) world.entity_mut(player).remove::(); // Tick 0 again: move north — should be throttled (cooldown) world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); schedule.run(&mut world); assert!(world.get::(player).is_none(), "second move should be throttled"); // Tick 0 again: move north — should succeed (cooldown elapsed) world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); schedule.run(&mut world); assert!(world.get::(player).is_some(), "third move should succeed after cooldown"); } #[test] fn sprint_stance_allows_every_tick() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), Stance(MovementStance::Sprint), PlayerMoveCooldown::default(), )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); // First move world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); schedule.run(&mut world); assert!(world.get::(player).is_some()); world.entity_mut(player).remove::(); // Second move — sprint allows every tick world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); schedule.run(&mut world); assert!(world.get::(player).is_some(), "sprint should allow every tick"); } #[test] fn no_stance_component_moves_unrestricted() { // Backward compatibility: entities without Stance/Cooldown move freely let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); schedule.run(&mut world); assert!(world.get::(player).is_some()); world.entity_mut(player).remove::(); // Second move immediately — no throttle without components world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); schedule.run(&mut world); assert!(world.get::(player).is_some()); } }