//! TeleportToHub QA action (#491) — Gauntlet-only return-to-hub verb. //! //! Moved out of simulation::input with the dispatcher split (T-1062): the //! action only exists on the Gauntlet test world, so it lives with it. use bevy_ecs::prelude::*; use crate::simulation::input::PlayerInputQuery; /// 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. pub fn handle_teleport_to_hub(player_query: &mut PlayerInputQuery, 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 (including mid-confrontation state) commands .entity(player_entity) .remove::() .remove::() .remove::() .remove::() .remove::(); tracing::info!( x = hub_spawn.x, y = hub_spawn.y, z = hub_spawn.z, "TeleportToHub: player moved to hub spawn" ); } } // --------------------------------------------------------------------------- // Tests (#491; moved from input.rs, T-1062) // --------------------------------------------------------------------------- #[cfg(all(test, feature = "gauntlet"))] mod tests { use crate::bridge::types::{PlayerAction, PlayerInput}; use crate::simulation::input::{process_player_input, InputQueue}; use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; use crate::simulation::time::{SimulationTime, TickRate}; #[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"); } #[test] fn teleport_to_hub_clears_dialogue_markers() { // #491: TeleportToHub removes ActiveDialogue, TalkRequest, // WalkAwayRequest, and ConfrontationDelivered. 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 + mid-confrontation marker 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, }, crate::simulation::dialogue::WalkAwayRequest, crate::simulation::dialogue::ConfrontationDelivered { target: npc }, )) .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" ); assert!( world .get::(player) .is_none(), "WalkAwayRequest cleared after teleport" ); assert!( world .get::(player) .is_none(), "ConfrontationDelivered cleared after teleport" ); } #[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" ); } #[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"); } }