D-226 layer 1. World-advancing phases gated on pause: Movement/Storyteller/Knowledge/TickAdvance set-gated via sim_not_paused; Simulation + Economy gated per-system at their registration sites — collect_sound_events and serve_econ_state_query stay unconditioned (transient-buffer clear + paused-allowed query; set-gating Simulation leaked a stale tick-7 footstep into frozen snapshots — caught by golden_suite, fixed without touching the fixture; regression test encodes the bug shape). New PlayerAction::AutoPause/AutoResume + AutoPauseState resource implement Option A reconciliation: auto-resume only fires if auto-pause caused the pause; manual pause and Half rate survive implant open/close. PauseParams SystemParam bundle keeps process_player_input under the 16-param ceiling (BookmarkInputParams precedent). Client: HudGroups.gameplay_occluded now sends AutoPause/AutoResume via send_named_action; 5 gdUnit tests + 7 Rust tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1044 lines
39 KiB
Rust
1044 lines
39 KiB
Rust
// 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<PlayerCharacter>,
|
|
>;
|
|
|
|
/// Queue of pending player inputs, ordered by tick
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct InputQueue {
|
|
queue: VecDeque<PlayerInput>,
|
|
}
|
|
|
|
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<PlayerInput> {
|
|
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<InputQueue>,
|
|
mut pause: PauseParams,
|
|
mut commands: Commands,
|
|
registry: Res<EntityRegistry>,
|
|
mut player_query: PlayerInputQuery,
|
|
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
|
all_positions: Query<&TilePosition>,
|
|
reset_triggers: Query<&RoomResetTrigger>,
|
|
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
|
|
mut save_load: Option<ResMut<SaveLoadPending>>,
|
|
mut debug_cmd_buffer: Option<ResMut<DebugCommandBuffer>>,
|
|
mut settings_cmd_buffer: Option<ResMut<SettingsCommandBuffer>>,
|
|
mut econ_query_buf: Option<ResMut<EconQueryBuffer>>,
|
|
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::<crate::simulation::follow::FollowTarget>();
|
|
}
|
|
}
|
|
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::<crate::knowledge::EntityRegistry>();
|
|
|
|
let player = world
|
|
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
|
.id();
|
|
|
|
world.resource_mut::<InputQueue>().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::<MoveIntent>(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::<crate::knowledge::EntityRegistry>();
|
|
|
|
world.resource_mut::<InputQueue>().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::<SimulationTime>().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::<crate::knowledge::EntityRegistry>();
|
|
|
|
world.resource_mut::<InputQueue>().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::<SimulationTime>().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::<crate::knowledge::EntityRegistry>();
|
|
|
|
world.resource_mut::<InputQueue>().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::<crate::knowledge::EntityRegistry>();
|
|
|
|
let player = world
|
|
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
|
.id();
|
|
|
|
world.resource_mut::<InputQueue>().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::<MoveIntent>(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::<crate::knowledge::EntityRegistry>();
|
|
|
|
let player = world
|
|
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
|
.id();
|
|
|
|
world.resource_mut::<InputQueue>().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::<MoveIntent>(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::<crate::knowledge::EntityRegistry>();
|
|
|
|
// Player entity required for process_player_input (even if no movement)
|
|
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
|
|
|
world.resource_mut::<InputQueue>().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::<SimulationTime>().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::<crate::knowledge::EntityRegistry>();
|
|
|
|
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::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::Pause,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Paused,
|
|
"Step 1: game should be paused"
|
|
);
|
|
|
|
// Step 2: Move while paused — must be rejected
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert!(
|
|
world.get::<MoveIntent>(player).is_none(),
|
|
"Step 2: movement must be rejected while paused"
|
|
);
|
|
|
|
// Step 3: Unpause
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::Unpause,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Full,
|
|
"Step 3: game should be unpaused"
|
|
);
|
|
|
|
// Step 4: Move after unpause — must succeed
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert!(
|
|
world.get::<MoveIntent>(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::<crate::knowledge::EntityRegistry>();
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
Stance::default(), // Walk
|
|
PlayerMoveCooldown::default(),
|
|
));
|
|
|
|
world.resource_mut::<InputQueue>().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::<crate::knowledge::EntityRegistry>();
|
|
|
|
let player = world
|
|
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
|
.id();
|
|
world
|
|
.resource_mut::<crate::knowledge::EntityRegistry>()
|
|
.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::<crate::knowledge::EntityRegistry>()
|
|
.register(item);
|
|
|
|
world.resource_mut::<InputQueue>().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::<TilePosition>(item).is_some(),
|
|
"Item must stay on ground — interact rejected while paused"
|
|
);
|
|
assert!(
|
|
world.get::<CarriedBy>(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::<crate::knowledge::EntityRegistry>();
|
|
|
|
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::<InputQueue>();
|
|
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::<MoveIntent>(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::<SimulationTime>().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::<crate::knowledge::EntityRegistry>();
|
|
|
|
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
|
|
|
world.resource_mut::<InputQueue>().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::<SimulationTime>().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::<crate::knowledge::EntityRegistry>();
|
|
world.init_resource::<AutoPauseState>();
|
|
|
|
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::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::AutoPause,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Paused,
|
|
"AutoPause must pause from Full"
|
|
);
|
|
assert!(
|
|
world.resource::<AutoPauseState>().active,
|
|
"auto_pause_active must be set — this pause was auto-triggered"
|
|
);
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::AutoResume,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Full,
|
|
"auto-pause-alone -> auto-resume must actually resume"
|
|
);
|
|
assert!(
|
|
!world.resource::<AutoPauseState>().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::<crate::knowledge::EntityRegistry>();
|
|
world.init_resource::<AutoPauseState>();
|
|
|
|
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::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::Pause,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().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::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::AutoPause,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Paused
|
|
);
|
|
assert!(
|
|
!world.resource::<AutoPauseState>().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::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::AutoResume,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().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::<crate::knowledge::EntityRegistry>();
|
|
world.init_resource::<AutoPauseState>();
|
|
|
|
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::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::AutoPause,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Half,
|
|
"AutoPause must not touch Half rate"
|
|
);
|
|
assert!(!world.resource::<AutoPauseState>().active);
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::AutoResume,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Half,
|
|
"AutoResume must not touch Half rate either"
|
|
);
|
|
}
|
|
}
|