feat(simulation): auto-pause sim on implant-fullscreen — inspection substrate (T-970)
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>
This commit is contained in:
@@ -24,7 +24,7 @@ use crate::simulation::inventory::{handle_place, handle_take, CarriedBy, Invento
|
||||
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::{SimulationTime, TickRate};
|
||||
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::*;
|
||||
@@ -108,7 +108,7 @@ impl InputQueue {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn process_player_input(
|
||||
mut input_queue: ResMut<InputQueue>,
|
||||
mut time: ResMut<SimulationTime>,
|
||||
mut pause: PauseParams,
|
||||
mut commands: Commands,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut player_query: PlayerInputQuery,
|
||||
@@ -124,8 +124,8 @@ pub fn process_player_input(
|
||||
object_types: Query<&ObjectType>,
|
||||
mut bookmark: BookmarkInputParams<'_>,
|
||||
) {
|
||||
let current_tick = time.tick;
|
||||
let paused = time.paused();
|
||||
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)
|
||||
@@ -134,11 +134,18 @@ pub fn process_player_input(
|
||||
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 { .. }
|
||||
@@ -193,15 +200,42 @@ pub fn process_player_input(
|
||||
handle_toggle_stance(&mut player_query, false);
|
||||
}
|
||||
PlayerAction::Pause => {
|
||||
time.tick_rate = TickRate::Paused;
|
||||
pause.time.tick_rate = TickRate::Paused;
|
||||
tracing::debug!("Simulation paused by player input");
|
||||
}
|
||||
PlayerAction::Unpause => {
|
||||
time.tick_rate = TickRate::Full;
|
||||
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) => {
|
||||
time.tick_rate = rate;
|
||||
pause.time.tick_rate = rate;
|
||||
tracing::debug!("Tick rate set to {:?} by player input", rate);
|
||||
}
|
||||
PlayerAction::Interact {
|
||||
@@ -410,6 +444,7 @@ 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() {
|
||||
@@ -855,4 +890,154 @@ mod tests {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user