// 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::knowledge::{EntityRegistry, StableId}; use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::simulation::inventory::{ find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS, }; use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; use crate::simulation::stance::{PlayerMoveCooldown, Stance}; use crate::simulation::time::{SimulationTime, TickRate}; use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots}; use bevy_ecs::prelude::*; use std::collections::VecDeque; /// Maximum number of inputs the queue will hold before dropping oldest. /// Prevents unbounded memory growth from input flooding. pub const INPUT_QUEUE_CAPACITY: usize = 1000; /// 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. /// Drops oldest inputs when capacity is exceeded. 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, ); if self.queue.len() >= INPUT_QUEUE_CAPACITY { let dropped = self.queue.pop_front(); tracing::warn!( "InputQueue at capacity ({}), dropping oldest input (tick={})", INPUT_QUEUE_CAPACITY, dropped.map_or(0, |d| d.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), movement cooldown, and Take/Place verbs (#424). #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn process_player_input( mut input_queue: ResMut, mut time: ResMut, mut commands: Commands, registry: Res, mut player_query: Query< ( Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>, ), With, >, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, all_positions: Query<&TilePosition>, reset_triggers: Query<&RoomResetTrigger>, mut room_snapshots: Option>, ) { let current_tick = time.tick; let paused = time.paused(); 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 { // Discard all gameplay actions while paused (D-052, R2-OQ-01). // Only Pause/Unpause/TeleportToHub are processed — everything else is discarded. // TeleportToHub is exempted because it's a Gauntlet QA action (#491). if paused && !matches!( input.action, PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::TeleportToHub ) { continue; } 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, ref verb, } => match verb.as_deref() { Some("Take") => { handle_take( &mut commands, ®istry, &player_query, &inventory_items, target_entity_id, ); } Some("Place") => { handle_place(&mut commands, ®istry, &player_query, target_entity_id); } Some("Talk") => { handle_talk( &mut commands, ®istry, &player_query, &all_positions, target_entity_id, ); } Some("Confront") => { handle_confront( &mut commands, ®istry, &player_query, &all_positions, target_entity_id, ); } Some("Reset") => { handle_reset( &mut commands, ®istry, &reset_triggers, &mut room_snapshots, target_entity_id, current_tick, ); } _ => { tracing::info!( "Interact: target={:?}, verb={:?} — logged only", target_entity_id, verb, ); } }, PlayerAction::WalkAway => { if let Ok((player_entity, _, _, _)) = player_query.single() { commands .entity(player_entity) .insert(crate::simulation::dialogue::WalkAwayRequest); tracing::debug!("WalkAway: marker set on player"); } } PlayerAction::TeleportToHub => { handle_teleport_to_hub(&mut player_query, &mut commands); } 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))); } /// Handle Take verb: pick up an item entity and add it to the player's inventory. /// Removes TilePosition (item is no longer on the ground — info boundary enforcement), /// adds CarriedBy + InventorySlot components. #[allow(clippy::type_complexity)] fn handle_take( commands: &mut Commands, registry: &EntityRegistry, player_query: &Query< ( Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>, ), With, >, inventory_items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, target_entity_id: Option, ) { let Some(target_id) = target_entity_id else { tracing::warn!("Take verb without target_entity_id"); return; }; let Ok((player_entity, _, _, _)) = player_query.single() else { return; }; let Some(player_sid) = registry.to_stable(player_entity) else { tracing::error!("Player entity not in EntityRegistry"); return; }; // Resolve wire ID to ECS entity let target_stable = StableId(target_id); let Some(target_entity) = registry.to_entity(&target_stable) else { tracing::warn!(target_id, "Take: target entity not in registry"); return; }; // Check inventory capacity let occupied = occupied_slots_for(player_sid, inventory_items); let Some(slot) = find_next_slot(&occupied) else { tracing::info!( "Inventory full ({} slots), cannot take item", MAX_INVENTORY_SLOTS ); return; }; // Remove TilePosition (item leaves the ground), add CarriedBy + InventorySlot commands .entity(target_entity) .remove::() .insert((CarriedBy(player_sid), InventorySlot(slot))); tracing::info!( target_id, slot, "Take: item picked up and added to inventory slot", ); } /// Handle Talk verb: set TalkRequest marker on the player entity for the target NPC. /// The actual dialogue pipeline runs in process_talk_interaction (dialogue.rs). /// Server-side range check: Talk requires CLOSE_RANGE (same as interaction system). #[allow(clippy::type_complexity)] fn handle_talk( commands: &mut Commands, registry: &EntityRegistry, player_query: &Query< ( Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>, ), With, >, all_positions: &Query<&TilePosition>, target_entity_id: Option, ) { let Some(target_id) = target_entity_id else { tracing::warn!("Talk verb without target_entity_id"); return; }; let Ok((player_entity, player_pos, _, _)) = player_query.single() else { return; }; let target_stable = StableId(target_id); let Some(target_entity) = registry.to_entity(&target_stable) else { tracing::warn!(target_id, "Talk: target entity not in registry"); return; }; // Server-side range check: reject Talk if target is beyond close range if let Ok(target_pos) = all_positions.get(target_entity) { let distance = player_pos .manhattan_distance(target_pos) .unwrap_or(u32::MAX); if distance > crate::simulation::interaction::CLOSE_RANGE { tracing::info!( target_id, distance, "Talk: target out of range (max {})", crate::simulation::interaction::CLOSE_RANGE, ); return; } } commands .entity(player_entity) .insert(crate::simulation::dialogue::TalkRequest { target: target_entity, }); tracing::debug!(target_id, "Talk: TalkRequest marker set on player"); } /// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063). /// The confrontation response system runs in process_confrontation_response (dialogue.rs). /// Server-side range check: Confront requires CLOSE_RANGE (same as Talk). #[allow(clippy::type_complexity)] fn handle_confront( commands: &mut Commands, registry: &EntityRegistry, player_query: &Query< ( Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>, ), With, >, all_positions: &Query<&TilePosition>, target_entity_id: Option, ) { let Some(target_id) = target_entity_id else { tracing::warn!("Confront verb without target_entity_id"); return; }; let Ok((player_entity, player_pos, _, _)) = player_query.single() else { return; }; let target_stable = StableId(target_id); let Some(target_entity) = registry.to_entity(&target_stable) else { tracing::warn!(target_id, "Confront: target entity not in registry"); return; }; // Server-side range check: reject Confront if target is beyond close range if let Ok(target_pos) = all_positions.get(target_entity) { let distance = player_pos .manhattan_distance(target_pos) .unwrap_or(u32::MAX); if distance > crate::simulation::interaction::CLOSE_RANGE { tracing::info!( target_id, distance, "Confront: target out of range (max {})", crate::simulation::interaction::CLOSE_RANGE, ); return; } } commands .entity(player_entity) .insert(crate::simulation::dialogue::ConfrontationDelivered { target: target_entity, }); tracing::debug!(target_id, "Confront: ConfrontationDelivered marker set on player"); } /// Handle Place verb: remove an item from inventory and place it on the ground /// at the player's current position. Removes CarriedBy + InventorySlot, adds /// TilePosition at the player's current tile. #[allow(clippy::type_complexity)] fn handle_place( commands: &mut Commands, registry: &EntityRegistry, player_query: &Query< ( Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>, ), With, >, target_entity_id: Option, ) { let Some(target_id) = target_entity_id else { tracing::warn!("Place verb without target_entity_id"); return; }; let Ok((_, player_pos, _, _)) = player_query.single() else { return; }; let target_stable = StableId(target_id); let Some(target_entity) = registry.to_entity(&target_stable) else { tracing::warn!(target_id, "Place: target entity not in registry"); return; }; let place_pos = *player_pos; // Remove inventory components, place item at player's tile commands .entity(target_entity) .remove::() .remove::() .insert(place_pos); tracing::info!( target_id, x = place_pos.x, y = place_pos.y, z = place_pos.z, "Place: item dropped at player position", ); } /// Handle Reset verb: restore a room's entities to their initial positions. /// Target entity must have a RoomResetTrigger component. Respects debounce. fn handle_reset( commands: &mut Commands, registry: &EntityRegistry, reset_triggers: &Query<&RoomResetTrigger>, room_snapshots: &mut Option>, target_entity_id: Option, current_tick: u64, ) { let Some(target_id) = target_entity_id else { tracing::warn!("Reset verb without target_entity_id"); return; }; let Some(snapshots) = room_snapshots.as_mut() else { tracing::warn!("Reset verb but RoomSnapshots resource not available"); return; }; let target_stable = StableId(target_id); let Some(target_entity) = registry.to_entity(&target_stable) else { tracing::warn!(target_id, "Reset: target entity not in registry"); return; }; let Ok(trigger) = reset_triggers.get(target_entity) else { tracing::warn!(target_id, "Reset: target is not a reset trigger"); return; }; let Some(changes) = snapshots.plan_reset(&trigger.room_name, current_tick) else { tracing::info!( room = trigger.room_name.as_str(), "Reset: debounced or unknown room" ); return; }; let mut restored = 0; for (entity, position, is_floor_item) in changes { if is_floor_item { commands .entity(entity) .remove::() .remove::() .insert(position); } else { commands.entity(entity).insert(position); } restored += 1; } tracing::info!( room = trigger.room_name.as_str(), restored, current_tick, "Room reset executed via Reset verb" ); } /// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491). /// /// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning /// and returns. On Gauntlet maps, moves the player to HUB.spawn and removes /// dialogue, monologue, and interaction markers to prevent stale state. /// /// Does NOT affect: room state, inventory, game time, knowledge graph. #[allow(clippy::type_complexity)] fn handle_teleport_to_hub( player_query: &mut Query< ( Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>, ), With, >, commands: &mut Commands, ) { #[cfg(not(feature = "gauntlet"))] { tracing::warn!("TeleportToHub rejected: not a Gauntlet map"); return; } #[cfg(feature = "gauntlet")] { let Ok((player_entity, _, _, _)) = player_query.single() else { return; }; let hub_spawn = crate::test_world::constants::HUB.spawn; // Move player to hub spawn commands.entity(player_entity).insert(hub_spawn); // Clear any pending movement commands.entity(player_entity).remove::(); // Clear dialogue/interaction markers commands .entity(player_entity) .remove::() .remove::() .remove::(); tracing::info!( x = hub_spawn.x, y = hub_spawn.y, z = hub_spawn.z, "TeleportToHub: player moved to hub spawn" ); } } #[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()); world.init_resource::(); 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.init_resource::(); 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.init_resource::(); 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.init_resource::(); 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()); world.init_resource::(); 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.init_resource::(); 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.init_resource::(); 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()); world.init_resource::(); 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()); world.init_resource::(); 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()); world.init_resource::(); 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()); } #[test] fn take_verb_picks_up_item() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); // Spawn player and register let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world .resource_mut::() .register(player); // Spawn item near player let item = world .spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into()))) .id(); let item_sid = world .resource_mut::() .register(item); // Issue Take verb world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Interact { target_entity_id: Some(item_sid.0), verb: Some("Take".into()), }, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); // Item should have CarriedBy + InventorySlot, no TilePosition assert!( world.get::(item).is_none(), "item should leave the ground" ); let carried = world .get::(item) .expect("item should have CarriedBy"); assert_eq!(carried.0, player_sid); let slot = world .get::(item) .expect("item should have slot"); assert_eq!(slot.0, 0, "first item goes to slot 0"); } #[test] fn place_verb_drops_item() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world .resource_mut::() .register(player); // Spawn item already in inventory (no TilePosition) let item = world .spawn(( CarriedBy(player_sid), ItemName("Manifest Copy".into()), InventorySlot(0), )) .id(); let item_sid = world .resource_mut::() .register(item); // Issue Place verb world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Interact { target_entity_id: Some(item_sid.0), verb: Some("Place".into()), }, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); // Item should have TilePosition at player's location, no CarriedBy/InventorySlot let pos = world .get::(item) .expect("item should be on ground"); assert_eq!( *pos, TilePosition::new(5, 5, 0), "placed at player position" ); assert!(world.get::(item).is_none(), "CarriedBy removed"); assert!( world.get::(item).is_none(), "InventorySlot removed" ); } #[test] fn take_verb_assigns_sequential_slots() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world .resource_mut::() .register(player); // Item already in slot 0 world.spawn(( CarriedBy(player_sid), ItemName("Manifest".into()), InventorySlot(0), )); // New item on the ground let item2 = world .spawn((TilePosition::new(5, 4, 0), ItemName("Token".into()))) .id(); let item2_sid = world .resource_mut::() .register(item2); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Interact { target_entity_id: Some(item2_sid.0), verb: Some("Take".into()), }, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); let slot = world .get::(item2) .expect("item should have slot"); assert_eq!(slot.0, 1, "second item goes to slot 1"); } #[test] fn take_verb_full_inventory_rejected() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world .resource_mut::() .register(player); // Fill all 9 slots for slot in 0..MAX_INVENTORY_SLOTS { world.spawn(( CarriedBy(player_sid), ItemName(format!("Item {}", slot)), InventorySlot(slot), )); } // Try to take another item let item = world .spawn((TilePosition::new(5, 4, 0), ItemName("Overflow".into()))) .id(); let item_sid = world .resource_mut::() .register(item); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Interact { target_entity_id: Some(item_sid.0), verb: Some("Take".into()), }, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); // Item should still be on the ground assert!( world.get::(item).is_some(), "item stays on ground" ); assert!( world.get::(item).is_none(), "no CarriedBy when full" ); } #[test] fn take_then_place_roundtrip() { // D-065: full cycle — item on ground → Take → carried → Place → ground again let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world .resource_mut::() .register(player); let item = world .spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into()))) .id(); let item_sid = world .resource_mut::() .register(item); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); // Step 1: Take world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Interact { target_entity_id: Some(item_sid.0), verb: Some("Take".into()), }, }); schedule.run(&mut world); assert!( world.get::(item).is_none(), "item off ground after Take" ); assert_eq!(world.get::(item).unwrap().0, player_sid); assert_eq!(world.get::(item).unwrap().0, 0); // Step 2: Place world.resource_mut::().push(PlayerInput { tick: 1, action: PlayerAction::Interact { target_entity_id: Some(item_sid.0), verb: Some("Place".into()), }, }); world.resource_mut::().tick = 1; schedule.run(&mut world); let pos = world .get::(item) .expect("item back on ground after Place"); assert_eq!( *pos, TilePosition::new(5, 5, 0), "placed at player position" ); assert!( world.get::(item).is_none(), "CarriedBy removed after Place" ); assert!( world.get::(item).is_none(), "InventorySlot removed after Place" ); } // === Pause Guard Tests (#461, #462, #463) === // Prevent Bug #3 recurrence: player movement while paused. // The pause guard at process_player_input discards movement inputs // when SimulationTime.tick_rate == TickRate::Paused (D-052). #[test] fn movement_discarded_while_paused() { // #461: Movement input rejected while paused — prevents Bug #3 recurrence. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); let mut time = SimulationTime::default(); time.tick_rate = TickRate::Paused; world.insert_resource(time); world.init_resource::(); 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); // Movement must be discarded — no MoveIntent created assert!( world.get::(player).is_none(), "MoveNorth must be discarded while paused (Bug #3 guard)" ); } #[test] fn unpause_accepted_while_paused() { // #462: Unpause command is the one control action allowed while paused. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); let mut time = SimulationTime::default(); time.tick_rate = TickRate::Paused; world.insert_resource(time); world.init_resource::(); // Player entity required for process_player_input (even if no movement) world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Unpause, }); 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::Full, "Unpause must be accepted while paused" ); } #[test] fn pause_unpause_roundtrip_with_movement() { // #463: Full cycle — pause -> move (rejected) -> unpause -> move (accepted). let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); 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); // Step 1: Pause world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Pause, }); schedule.run(&mut world); assert_eq!( world.resource::().tick_rate, TickRate::Paused, "Step 1: game should be paused" ); // Step 2: Move while paused — must be rejected world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); schedule.run(&mut world); assert!( world.get::(player).is_none(), "Step 2: movement must be rejected while paused" ); // Step 3: Unpause world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Unpause, }); schedule.run(&mut world); assert_eq!( world.resource::().tick_rate, TickRate::Full, "Step 3: game should be unpaused" ); // Step 4: Move after unpause — must succeed world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); schedule.run(&mut world); assert!( world.get::(player).is_some(), "Step 4: movement must succeed after unpause" ); } // === Remaining Pause Guard Tests (#468) === // Edge cases: stance, interact, batch discard, and SetTickRate while paused. #[test] fn stance_toggle_rejected_while_paused() { // #468: Stance toggle rejected while paused. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); let mut time = SimulationTime::default(); time.tick_rate = TickRate::Paused; world.insert_resource(time); world.init_resource::(); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), Stance::default(), // Walk 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::Walk, "Stance toggle must be rejected while paused" ); } #[test] fn interact_rejected_while_paused() { // #468: Interact rejected while paused. // This test verifies no panic and no side effects — interact is a no-op while paused. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); let mut time = SimulationTime::default(); time.tick_rate = TickRate::Paused; world.insert_resource(time); world.init_resource::(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); world .resource_mut::() .register(player); // Spawn item on the ground let item = world .spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into()))) .id(); let item_sid = world .resource_mut::() .register(item); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Interact { target_entity_id: Some(item_sid.0), verb: Some("Take".into()), }, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); // Item must remain on ground — Take rejected while paused assert!( world.get::(item).is_some(), "Item must stay on ground — interact rejected while paused" ); assert!( world.get::(item).is_none(), "Item must not be picked up while paused" ); } #[test] fn batch_discard_while_paused() { // #468: All inputs in a batch discarded while paused (except Pause/Unpause). let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); let mut time = SimulationTime::default(); time.tick_rate = TickRate::Paused; world.insert_resource(time); world.init_resource::(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), Stance::default(), PlayerMoveCooldown::default(), )) .id(); // Push a batch of mixed inputs — all should be discarded except Unpause let queue = &mut world.resource_mut::(); queue.push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); queue.push(PlayerInput { tick: 0, action: PlayerAction::ToggleStanceUp, }); queue.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); // All gameplay actions discarded assert!( world.get::(player).is_none(), "Movement discarded in batch" ); let mut query = world.query::<&Stance>(); let stance = query.single(&world).unwrap(); assert_eq!(stance.0, MovementStance::Walk, "Stance unchanged in batch"); assert_eq!( world.resource::().tick_rate, TickRate::Paused, "SetTickRate discarded in batch — still paused" ); } #[test] fn set_tick_rate_rejected_while_paused() { // #468 / R2-OQ-01: SetTickRate(Half) while paused is a bug — must be rejected. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); let mut time = SimulationTime::default(); time.tick_rate = TickRate::Paused; world.insert_resource(time); world.init_resource::(); world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); 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::Paused, "SetTickRate must be rejected while paused (R2-OQ-01)" ); } #[test] fn take_without_target_id_is_noop() { // Edge case: Take verb with no target_entity_id should not panic let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Interact { target_entity_id: None, verb: Some("Take".into()), }, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); // should not panic } #[test] fn place_without_target_id_is_noop() { // Edge case: Place verb with no target_entity_id should not panic let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Interact { target_entity_id: None, verb: Some("Place".into()), }, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); // should not panic } // === Room Reset Tests (#490) === #[test] fn reset_verb_restores_floor_item() { // #490: Take a floor item, then Reset verb restores it to original position. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); // Player let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world .resource_mut::() .register(player); // Floor item at (5, 4) let item = world .spawn((TilePosition::new(5, 4, 0), ItemName("Keycard".into()))) .id(); let item_sid = world .resource_mut::() .register(item); // Reset plate entity let plate = world .spawn(( crate::simulation::interaction::Interactable, RoomResetTrigger { room_name: "test_room".to_string(), }, TilePosition::new(5, 3, 0), )) .id(); let plate_sid = world .resource_mut::() .register(plate); // Record snapshot: item is a floor item at its original position let mut snapshots = RoomSnapshots::default(); snapshots.record("test_room", item, TilePosition::new(5, 4, 0), true); world.insert_resource(snapshots); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); // Step 1: Take the item world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Interact { target_entity_id: Some(item_sid.0), verb: Some("Take".into()), }, }); schedule.run(&mut world); assert!( world.get::(item).is_none(), "Item should be picked up" ); assert_eq!(world.get::(item).unwrap().0, player_sid); // Step 2: Reset via verb world.resource_mut::().push(PlayerInput { tick: 1, action: PlayerAction::Interact { target_entity_id: Some(plate_sid.0), verb: Some("Reset".into()), }, }); world.resource_mut::().tick = 1; schedule.run(&mut world); // Item should be back on the ground at original position let pos = world .get::(item) .expect("Item should be restored to ground"); assert_eq!( *pos, TilePosition::new(5, 4, 0), "Item at original position" ); assert!( world.get::(item).is_none(), "CarriedBy removed after reset" ); assert!( world.get::(item).is_none(), "InventorySlot removed after reset" ); } #[test] fn reset_verb_debounces() { // #490: Reset debounce prevents rapid-fire resets. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); let _player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); // NPC entity let npc = world.spawn(TilePosition::new(10, 10, 0)).id(); world .resource_mut::() .register(npc); // Reset plate let plate = world .spawn(( crate::simulation::interaction::Interactable, RoomResetTrigger { room_name: "test_room".to_string(), }, TilePosition::new(5, 3, 0), )) .id(); let plate_sid = world .resource_mut::() .register(plate); let mut snapshots = RoomSnapshots::default(); snapshots.record("test_room", npc, TilePosition::new(10, 10, 0), false); world.insert_resource(snapshots); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); // First reset at tick 0 — should succeed world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Interact { target_entity_id: Some(plate_sid.0), verb: Some("Reset".into()), }, }); schedule.run(&mut world); // Move NPC to verify debounce blocks second reset *world.get_mut::(npc).unwrap() = TilePosition::new(20, 20, 0); // Second reset at tick 5 — should be debounced (< 10 ticks) world.resource_mut::().push(PlayerInput { tick: 5, action: PlayerAction::Interact { target_entity_id: Some(plate_sid.0), verb: Some("Reset".into()), }, }); world.resource_mut::().tick = 5; schedule.run(&mut world); // NPC should still be at moved position (reset was debounced) assert_eq!( world.get::(npc).unwrap().x, 20, "NPC not reset — debounced" ); // Third reset at tick 10 — should succeed world.resource_mut::().push(PlayerInput { tick: 10, action: PlayerAction::Interact { target_entity_id: Some(plate_sid.0), verb: Some("Reset".into()), }, }); world.resource_mut::().tick = 10; schedule.run(&mut world); // NPC should be back at original position assert_eq!( world.get::(npc).unwrap().x, 10, "NPC reset after debounce elapsed" ); } #[test] fn reset_verb_without_snapshots_is_noop() { // Reset verb when no RoomSnapshots resource exists should not panic. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); let plate = world .spawn(( crate::simulation::interaction::Interactable, RoomResetTrigger { room_name: "test_room".to_string(), }, TilePosition::new(5, 3, 0), )) .id(); let plate_sid = world .resource_mut::() .register(plate); // No RoomSnapshots resource inserted — should be gracefully handled world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Interact { target_entity_id: Some(plate_sid.0), verb: Some("Reset".into()), }, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); // should not panic } // === TeleportToHub Tests (#491) === #[cfg(feature = "gauntlet")] #[test] fn teleport_to_hub_moves_player() { // #491: TeleportToHub moves player to hub spawn position. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); // Spawn player at a non-hub position let player = world .spawn((PlayerCharacter, TilePosition::new(84, 58, 0))) .id(); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::TeleportToHub, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); let pos = world.get::(player).expect("player has position"); let hub_spawn = crate::test_world::constants::HUB.spawn; assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn"); assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn"); assert_eq!(pos.z, hub_spawn.z, "player z at hub spawn"); } #[cfg(feature = "gauntlet")] #[test] fn teleport_to_hub_clears_dialogue_markers() { // #491: TeleportToHub removes ActiveDialogue and TalkRequest. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); // Spawn a fake NPC target let npc = world.spawn(TilePosition::new(10, 10, 0)).id(); // Spawn player with active dialogue state let player = world .spawn(( PlayerCharacter, TilePosition::new(84, 58, 0), crate::simulation::dialogue::TalkRequest { target: npc }, crate::simulation::dialogue::ActiveDialogue { target: npc, interaction_type: crate::knowledge::events::InteractionType::Talk, started_tick: 0, }, )) .id(); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::TeleportToHub, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); assert!( world .get::(player) .is_none(), "TalkRequest cleared after teleport" ); assert!( world .get::(player) .is_none(), "ActiveDialogue cleared after teleport" ); } #[cfg(feature = "gauntlet")] #[test] fn teleport_to_hub_clears_move_intent() { // #491: TeleportToHub removes any pending MoveIntent. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); let player = world .spawn(( PlayerCharacter, TilePosition::new(84, 58, 0), MoveIntent { target: TilePosition::new(85, 58, 0), }, )) .id(); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::TeleportToHub, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); assert!( world.get::(player).is_none(), "MoveIntent cleared after teleport" ); } #[cfg(feature = "gauntlet")] #[test] fn teleport_to_hub_allowed_while_paused() { // #491: TeleportToHub is a QA action — allowed even when paused. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); let mut time = SimulationTime::default(); time.tick_rate = TickRate::Paused; world.insert_resource(time); world.init_resource::(); let player = world .spawn((PlayerCharacter, TilePosition::new(84, 58, 0))) .id(); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::TeleportToHub, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); schedule.run(&mut world); let pos = world.get::(player).expect("player has position"); let hub_spawn = crate::test_world::constants::HUB.spawn; assert_eq!(pos.x, hub_spawn.x, "teleport works while paused"); } }