// Input processing system // Timestamped player input events for deterministic simulation (D-010 principle 4) // PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance) // // This module owns the queue and the dispatch table only. Per-domain handler // bodies live in their owning modules (T-1062): movement, inventory, dialogue, // examine, follow, interaction, stance, save_io, settings, economy, bookmark, // bridge::debug, perception::vision_cone, and test_world (reset, teleport). use crate::bookmark::{ handle_confirm_bookmark, handle_request_bookmark_catalog, BookmarkInputParams, }; use crate::bridge::debug::{queue_debug_command, DebugCommandBuffer}; use crate::bridge::types::{ObjectType, PlayerAction, PlayerInput}; use crate::knowledge::EntityRegistry; use crate::perception::vision_cone::handle_set_facing; use crate::settings::{queue_settings_command, SettingsCommand, SettingsCommandBuffer}; use crate::simulation::dialogue::{handle_confront, handle_dialogue_response, handle_talk}; use crate::simulation::economy::{queue_econ_state_query, EconQueryBuffer}; use crate::simulation::examine::handle_examine; use crate::simulation::follow::handle_follow; use crate::simulation::interaction::{handle_door_interact, handle_terminal_interact, DoorState}; use crate::simulation::inventory::{handle_place, handle_take, CarriedBy, InventorySlot, ItemName}; use crate::simulation::movement::{apply_move, PlayerCharacter, TilePosition}; use crate::simulation::save_io::{queue_save_load, SaveLoadCommand, SaveLoadPending}; use crate::simulation::stance::{handle_toggle_stance, PlayerMoveCooldown, Stance}; use crate::simulation::time::{PauseParams, TickRate}; use crate::test_world::reset::{handle_reset, RoomResetTrigger, RoomSnapshots}; use crate::test_world::teleport::handle_teleport_to_hub; 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; /// Player query shared by the dispatch and the per-domain input handlers. /// One alias instead of nine per-handler `clippy::type_complexity` allows (T-1062). pub type PlayerInputQuery<'w, 's> = Query< 'w, 's, ( Entity, &'static TilePosition, Option<&'static mut Stance>, Option<&'static mut PlayerMoveCooldown>, ), With, >; /// 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, Take/Place verbs (#424), /// and save/load commands (#553). #[allow(clippy::too_many_arguments)] pub fn process_player_input( mut input_queue: ResMut, mut pause: PauseParams, mut commands: Commands, registry: Res, mut player_query: PlayerInputQuery, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, all_positions: Query<&TilePosition>, reset_triggers: Query<&RoomResetTrigger>, mut room_snapshots: Option>, mut save_load: Option>, mut debug_cmd_buffer: Option>, mut settings_cmd_buffer: Option>, mut econ_query_buf: Option>, door_states: Query<&DoorState>, object_types: Query<&ObjectType>, mut bookmark: BookmarkInputParams<'_>, ) { let current_tick = pause.time.tick; let paused = pause.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). // SaveGame/LoadGame are also exempted — saving while paused is valid (#553). // AutoPause/AutoResume (T-970) are whitelisted for the same reason as // Pause/Unpause: AutoResume MUST reach the match arm while paused (that's // the entire point — resuming from a paused state), and AutoPause is // included for symmetry even though its handler is a no-op whenever the // sim isn't already TickRate::Full. if paused && !matches!( input.action, PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::AutoPause | PlayerAction::AutoResume | PlayerAction::TeleportToHub | PlayerAction::SaveGame { .. } | PlayerAction::LoadGame { .. } | PlayerAction::DebugCommand(_) | PlayerAction::ChangeSetting { .. } | PlayerAction::RequestAllSettings | PlayerAction::DeleteSetting { .. } | PlayerAction::EconStateQuery { .. } | PlayerAction::RequestBookmarkCatalog | PlayerAction::ConfirmBookmark { .. } ) { 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 => { handle_toggle_stance(&mut player_query, true); } PlayerAction::ToggleStanceDown => { handle_toggle_stance(&mut player_query, false); } PlayerAction::Pause => { pause.time.tick_rate = TickRate::Paused; tracing::debug!("Simulation paused by player input"); } PlayerAction::Unpause => { pause.time.tick_rate = TickRate::Full; tracing::debug!("Simulation unpaused by player input"); } PlayerAction::AutoPause => { // T-970 (D-226 layer 1, Option A): only take effect when the sim // is currently fully running. A pre-existing manual pause or Half // rate (D-088) is left completely untouched — no previous-rate // stack, so if auto-pause didn't cause the pause, auto-resume // must not clear it either (see AutoResume below). if pause.time.tick_rate == TickRate::Full { pause.time.tick_rate = TickRate::Paused; if let Some(auto_pause) = pause.auto_pause.as_deref_mut() { auto_pause.active = true; } tracing::debug!("Simulation auto-paused (implant fullscreen)"); } } PlayerAction::AutoResume => { // T-970: only resume — and only clear the flag — if OUR OWN // auto-pause is what caused the current pause. A prior manual // pause or Half rate survives the implant close untouched. let should_resume = pause.auto_pause.as_deref().is_some_and(|a| a.active); if should_resume { pause.time.tick_rate = TickRate::Full; if let Some(auto_pause) = pause.auto_pause.as_deref_mut() { auto_pause.active = false; } tracing::debug!("Simulation auto-resumed (implant closed)"); } } PlayerAction::SetTickRate(rate) => { pause.time.tick_rate = rate; tracing::debug!("Tick rate set to {:?} by player input", rate); } PlayerAction::Interact { target_entity_id, ref verb, } => { // Cancel follow when player uses any non-Follow verb (#241). if verb.as_deref() != Some("Follow") { if let Ok((player_entity, _, _, _)) = player_query.single() { commands .entity(player_entity) .remove::(); } } 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("Follow") => { handle_follow( &mut commands, ®istry, &player_query, &all_positions, target_entity_id, current_tick, ); } Some("Examine NPC") | Some("ExamineNpc") | Some("Examine Object") | Some("ExamineObject") | Some("Observe") => { handle_examine( &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, ); } // #246: Door and Terminal behavior Some("Open") | Some("Close") => { handle_door_interact( &mut commands, ®istry, &player_query, &door_states, target_entity_id, ); } Some("Use") => { handle_terminal_interact( &mut commands, ®istry, &player_query, &object_types, target_entity_id, ); } _ => { 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::SetFacing { ref facing } => { handle_set_facing(&mut player_query, &mut commands, facing); } PlayerAction::TeleportToHub => { handle_teleport_to_hub(&mut player_query, &mut commands); } PlayerAction::DialogueResponse { target_entity_id, ref response_id, } => { handle_dialogue_response( &mut commands, ®istry, &player_query, &all_positions, target_entity_id, response_id, ); } PlayerAction::UsePerceptionMode(ref mode) => { tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode); } PlayerAction::SaveGame { ref path } => { queue_save_load( save_load.as_deref_mut(), SaveLoadCommand::Save { path: std::path::PathBuf::from(path), }, ); } PlayerAction::LoadGame { ref path } => { queue_save_load( save_load.as_deref_mut(), SaveLoadCommand::Load { path: std::path::PathBuf::from(path), }, ); } PlayerAction::DebugCommand(cmd) => { queue_debug_command(debug_cmd_buffer.as_deref_mut(), cmd); } PlayerAction::ChangeSetting { key, value } => { queue_settings_command( settings_cmd_buffer.as_deref_mut(), SettingsCommand::Change { key, value }, ); } PlayerAction::RequestAllSettings => { queue_settings_command( settings_cmd_buffer.as_deref_mut(), SettingsCommand::RequestAll, ); } PlayerAction::DeleteSetting { key } => { queue_settings_command( settings_cmd_buffer.as_deref_mut(), SettingsCommand::Delete { key }, ); } PlayerAction::EconStateQuery { system_id } => { queue_econ_state_query(econ_query_buf.as_deref_mut(), system_id); } PlayerAction::RequestBookmarkCatalog => { handle_request_bookmark_catalog(&mut bookmark); } PlayerAction::ConfirmBookmark { bookmark_id, starting_location_id, } => { handle_confirm_bookmark( bookmark_id, starting_location_id, &bookmark.registry, &mut bookmark.selected, &mut bookmark.sim_error_buf, current_tick, ); } } } // 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(); } } } #[cfg(test)] mod tests { use super::*; use crate::bridge::types::MovementStance; use crate::simulation::movement::MoveIntent; use crate::simulation::time::{AutoPauseState, SimulationTime}; #[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()); } // === 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)" ); } // === Auto-Pause Reconciliation Tests (T-970, D-226 layer 1) === // Option A (lead ruling, no previous-rate stack): AutoPause only takes // effect from TickRate::Full; AutoResume only fires when AutoPauseState // says auto-pause itself caused the current pause. Manual pause and Half // rate (D-088) must survive an implant open/close cycle untouched. #[test] fn auto_pause_alone_then_auto_resume_unpauses() { // No manual pause / Half rate in play — AutoPause causes the pause, so // AutoResume (implant closing) must actually resume it. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); world.init_resource::(); world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::AutoPause, }); schedule.run(&mut world); assert_eq!( world.resource::().tick_rate, TickRate::Paused, "AutoPause must pause from Full" ); assert!( world.resource::().active, "auto_pause_active must be set — this pause was auto-triggered" ); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::AutoResume, }); schedule.run(&mut world); assert_eq!( world.resource::().tick_rate, TickRate::Full, "auto-pause-alone -> auto-resume must actually resume" ); assert!( !world.resource::().active, "flag must clear on auto-resume" ); } #[test] fn manual_pause_survives_auto_pause_and_auto_resume() { // A prior MANUAL pause must survive the implant open/close cycle // untouched — auto-resume must NOT fire since auto-pause wasn't the trigger. let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); world.init_resource::(); world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); // Step 1: manual pause (Space bar). world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::Pause, }); schedule.run(&mut world); assert_eq!( world.resource::().tick_rate, TickRate::Paused ); // Step 2: implant opens — AutoPause fires. Already paused (not Full), // so it must no-op and leave the flag false. world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::AutoPause, }); schedule.run(&mut world); assert_eq!( world.resource::().tick_rate, TickRate::Paused ); assert!( !world.resource::().active, "manual pause was not caused by auto-pause" ); // Step 3: implant closes — AutoResume fires. Flag is false, so it must // NOT unpause. world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::AutoResume, }); schedule.run(&mut world); assert_eq!( world.resource::().tick_rate, TickRate::Paused, "manual pause -> auto-pause -> auto-resume must leave the sim still paused" ); } #[test] fn half_rate_survives_auto_pause_and_auto_resume_untouched() { // D-088 Half rate is untouched by auto-pause/auto-resume — AutoPause // only takes effect from Full, so Half stays Half through an implant // open/close cycle (no previous-rate stack, "keep it simple"). let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); let mut time = SimulationTime::default(); time.tick_rate = TickRate::Half; world.insert_resource(time); world.init_resource::(); world.init_resource::(); world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::AutoPause, }); schedule.run(&mut world); assert_eq!( world.resource::().tick_rate, TickRate::Half, "AutoPause must not touch Half rate" ); assert!(!world.resource::().active); world.resource_mut::().push(PlayerInput { tick: 0, action: PlayerAction::AutoResume, }); schedule.run(&mut world); assert_eq!( world.resource::().tick_rate, TickRate::Half, "AutoResume must not touch Half rate either" ); } }