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:
2026-07-14 15:44:56 +02:00
co-authored by Claude Fable 5
parent 672a583e64
commit 6bda9f1697
12 changed files with 844 additions and 11 deletions
+109
View File
@@ -408,6 +408,10 @@ pub fn try_load_economy(run_seed: u64) -> Option<(EconSimResource, EconStateReso
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::types::SnapshotBuffer;
use crate::simulation::time::{sim_not_paused, TickRate};
use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule};
use bevy_ecs::world::World;
use econ_sim::db::{Commodity, Economy, SystemInfo};
const SYS: &str = "sys-test";
@@ -647,4 +651,109 @@ mod tests {
.signals
.contains_key(&("ghost-system".to_string(), COM.to_string())));
}
// ── T-970 (D-226 layer 1): pause-gating split ────────────────────────────
// tick_economy_simulation is individually `run_if`-gated (economy_plugin.rs);
// serve_econ_state_query stays unconditioned so the paused-allowed
// EconStateQuery (input.rs) keeps being served while the sim is frozen.
// This mirrors the exact production system composition, not a stand-in.
// Sentinel econ_tick a real `Simulation::step()` can never produce: a
// fresh `Simulation::from_economy` starts its internal tick counter at 0
// and `step()` increments it to 1 — so 99 can only be observed here
// because we seeded it by hand, never as a coincidental real result.
// This makes "unchanged" and "changed" assertions unambiguous instead of
// accidentally matching the real post-step value (see PR review: a first
// draft of this test seeded econ_tick=1, which a real step also produces,
// so a "stays unchanged" assertion couldn't have caught the gate failing
// open).
const STALE_ECON_TICK: u64 = 99;
#[test]
fn tick_economy_simulation_skips_while_paused_but_query_still_served() {
let (mut econ, mut state) = test_resources();
// Seed a stale econ tick + real signals so a query has something to
// serve, and so a gate failure (system runs anyway) is observable.
set_state_and_rebuild(&mut econ, &mut state, STALE_ECON_TICK, 10.0, 1.0, 1.0, 1.0);
let mut world = World::new();
// NOTE: SimulationTime has a private field (`accumulated`, time.rs) —
// struct-literal update syntax (`{ tick_rate: ..., ..Default::default() }`)
// only compiles from within `time` itself or its descendants. From this
// sibling module, construct via Default::default() then assign the pub
// `tick_rate` field directly (matches the existing convention in
// simulation::input's pause-guard tests).
let mut time = SimulationTime::default();
time.tick_rate = TickRate::Paused;
world.insert_resource(time);
world.insert_resource(econ);
world.insert_resource(state);
world.insert_resource(EconQueryBuffer {
pending: Some(SYS.to_string()),
});
world.init_resource::<SnapshotBuffer>();
// Mirrors the exact production registration in economy_plugin.rs.
let mut schedule = Schedule::default();
schedule.add_systems((
tick_economy_simulation.run_if(sim_not_paused),
serve_econ_state_query.after(tick_economy_simulation),
));
schedule.run(&mut world);
assert_eq!(
world.resource::<EconStateResource>().econ_tick,
STALE_ECON_TICK,
"tick_economy_simulation must be skipped while paused — econ_tick unchanged"
);
assert!(
world.resource::<EconQueryBuffer>().pending.is_none(),
"serve_econ_state_query must still drain the pending query while paused"
);
let response = world
.resource::<SnapshotBuffer>()
.pending_economy_response
.as_ref()
.expect("serve_econ_state_query must still serve a response while paused");
assert_eq!(response.system_id, SYS);
// The served response must carry the STALE data (serve_econ_state_query
// reads whatever EconStateResource currently holds — it does not itself
// recompute anything) — confirms it served the frozen state, not some
// other fresh value.
assert_eq!(response.econ_tick, STALE_ECON_TICK);
}
#[test]
fn tick_economy_simulation_runs_normally_when_not_paused() {
// Control case: same stale-seed setup, but Full rate —
// tick_economy_simulation must actually run and overwrite the stale
// econ_tick with the real post-step value (1, from a fresh
// Simulation). Proves the gate — not some other no-op path — is what
// skips it in the test above.
let (mut econ, mut state) = test_resources();
set_state_and_rebuild(&mut econ, &mut state, STALE_ECON_TICK, 10.0, 1.0, 1.0, 1.0);
let mut world = World::new();
world.insert_resource(SimulationTime::default()); // Full, tick=0
world.insert_resource(econ);
world.insert_resource(state);
world.insert_resource(EconQueryBuffer { pending: None });
world.init_resource::<SnapshotBuffer>();
let mut schedule = Schedule::default();
schedule.add_systems((
tick_economy_simulation.run_if(sim_not_paused),
serve_econ_state_query.after(tick_economy_simulation),
));
schedule.run(&mut world);
// tick=0 (SimulationTime default) is a multiple of ECON_TICK_RATE, so
// the step runs for real: sim.step() advances the fresh Simulation's
// own internal counter from 0 to 1, overwriting the stale seed.
assert_eq!(
world.resource::<EconStateResource>().econ_tick,
1,
"tick_economy_simulation must run and overwrite the stale econ_tick when not paused"
);
}
}
+11 -1
View File
@@ -2,10 +2,20 @@
//!
//! All systems run in [`TickPhase::Economy`]. Intra-phase ordering:
//! - tick_economy_simulation → serve_econ_state_query (query reads fresh signals)
//!
//! T-970 (D-226 layer 1): `tick_economy_simulation` is individually gated with
//! `sim_not_paused` so it freezes while the sim is paused — but
//! `serve_econ_state_query` stays unconditioned so the paused-allowed
//! `EconStateQuery` (input.rs) keeps being served. `TickPhase::Economy` itself
//! is deliberately NOT set-gated in `tick_phases.rs` for exactly this reason.
//! `.after()` ordering still holds when the upstream system is skipped by its
//! own run condition — a run condition only prevents a system from running,
//! it doesn't relax the ordering edge for the passes where both do run.
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::simulation::time::sim_not_paused;
use crate::tick_phases::TickPhase;
pub struct EconomyPlugin {
@@ -27,7 +37,7 @@ impl Plugin for EconomyPlugin {
.add_systems(
Update,
(
super::economy::tick_economy_simulation,
super::economy::tick_economy_simulation.run_if(sim_not_paused),
super::economy::serve_econ_state_query
.after(super::economy::tick_economy_simulation),
)
+192 -7
View File
@@ -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"
);
}
}
+35 -1
View File
@@ -1,10 +1,20 @@
//! Social simulation plugin — NPC knowledge transfer, disclosure, and social systems.
//!
//! All systems run in [`TickPhase::Simulation`].
//!
//! T-970 (D-226 layer 1): `TickPhase::Simulation` is NOT set-gated while the
//! sim is paused — `collect_sound_events` must keep running every tick
//! regardless (see its own `.run_if`-free registration below and the doc
//! comment on `tick_phases::TickPhase::configure`). Every other system in
//! this phase IS individually gated with `sim_not_paused`, collectively via
//! the tuple's own `.run_if()` (safe here because collect_sound_events isn't
//! part of that tuple — the anonymous-set-wrapping this creates only affects
//! the gated group, not the always-on system).
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::simulation::time::sim_not_paused;
use crate::tick_phases::TickPhase;
pub struct SocialPlugin;
@@ -15,11 +25,34 @@ impl Plugin for SocialPlugin {
.init_resource::<super::follow::FollowEndEventQueue>()
.init_resource::<super::monologue::PostConversationQueue>()
.init_resource::<super::poi_discovery::PoiDiscoveryEventQueue>()
// collect_sound_events: NEVER gated (T-970). It only clears-then-
// refills the this-tick SoundEventQueue from SoundEventEmitter
// components — compute_observer_snapshot (Snapshot phase,
// ungated) peeks that queue via Res every tick without draining
// it itself, so skipping this system while paused would leave
// stale sound events visible in every snapshot taken while
// frozen (confirmed by tests/golden_suite.rs going red when this
// was gated as part of the whole Simulation set).
//
// Deliberate, documented semantics (do not "fix" this later):
// with collect_sound_events left unconditioned, the FIRST paused
// tick clears the queue and no gated producer re-emits into it
// (movement/dialogue/etc. that would populate SoundEventEmitter
// are all frozen), so every snapshot served while paused shows
// sound_events: [] — a stable, silent, frozen inspection view
// one tick after the pause takes effect. This matches D-226's
// "inspection substrate" intent (sound is an instantaneous
// event, not persistent world state, so a frozen view showing
// none is correct) rather than replaying/holding the last sound
// indefinitely while paused.
.add_systems(
Update,
super::sound::collect_sound_events.in_set(TickPhase::Simulation),
)
.add_systems(
Update,
(
super::npc_knowledge_transfer::transfer_npc_knowledge,
super::sound::collect_sound_events,
// Voice enrichment (D-138) — rewrite NPC text with voiced variants.
// No-op when VoiceCacheResource is absent.
crate::voice::integration::voice_enrich_dialogue_response,
@@ -34,6 +67,7 @@ impl Plugin for SocialPlugin {
// Triangle escalation (#250) — runs on game-minute boundaries
super::triangle::tick_triangle_escalation,
)
.run_if(sim_not_paused)
.in_set(TickPhase::Simulation),
);
}
+44
View File
@@ -3,6 +3,7 @@
// Injectable time resource for deterministic replay (D-030)
use bevy_ecs::prelude::*;
use bevy_ecs::system::SystemParam;
use serde::{Deserialize, Serialize};
pub const TICKS_PER_GAME_MINUTE: u64 = 10;
@@ -109,6 +110,49 @@ pub fn advance_tick(mut time: ResMut<SimulationTime>) {
}
}
/// Run condition (T-970, D-226 layer 1): gates the world-advancing tick phases
/// (Movement, Simulation, Storyteller, Knowledge, TickAdvance — wired in
/// `tick_phases::TickPhase::configure`) so they skip entirely while the sim is
/// paused. Keyed on `TickRate::Paused` only — Half rate (D-088) keeps running
/// these phases exactly as it does today; this adds a hard schedule-level stop
/// for Paused, matching what `advance_tick` above already does for the clock.
pub fn sim_not_paused(time: Res<SimulationTime>) -> bool {
!time.paused()
}
/// Tracks whether the CURRENT `TickRate::Paused` state was caused by the
/// auto-pause mechanism (T-970, D-226 layer 1: fullscreen implant apps
/// auto-pause the sim, D-170) rather than a manual player pause (D-088, Space
/// bar) or a pre-existing Half rate.
///
/// Reconciliation is "Option A" (lead ruling — no previous-rate stack):
/// - `PlayerAction::AutoPause` (`simulation::input`) only pauses — and sets
/// `active = true` — when the sim is currently `TickRate::Full`. A
/// pre-existing manual pause or Half rate is left completely untouched.
/// - `PlayerAction::AutoResume` only unpauses — and clears `active` — when
/// `active` is already true, i.e. only when auto-pause itself is what
/// caused the pause. A manual pause (or Half rate, which `AutoPause` never
/// touches) survives an implant open/close cycle untouched.
#[derive(Resource, Debug, Default)]
pub struct AutoPauseState {
pub active: bool,
}
/// Bundled SystemParam for pause-related input handling (T-970).
///
/// Bevy's blanket `IntoSystem` impl covers functions up to 16 parameters
/// (mirrors `bookmark::BookmarkInputParams`'s reason for existing).
/// `process_player_input` was already at exactly 16 — bundling `time` and
/// `auto_pause` together here (rather than adding `auto_pause` as its own
/// 17th top-level parameter) keeps it under the ceiling. The two fields
/// belong together anyway: `AutoPauseState` only makes sense in terms of
/// `SimulationTime.tick_rate`.
#[derive(SystemParam)]
pub struct PauseParams<'w> {
pub time: ResMut<'w, SimulationTime>,
pub auto_pause: Option<ResMut<'w, AutoPauseState>>,
}
#[cfg(test)]
mod tests {
use super::*;
+3
View File
@@ -15,6 +15,9 @@ pub struct TimePlugin;
impl Plugin for TimePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<super::time::SimulationTime>()
// T-970 (D-226 layer 1): tracks whether the current pause was
// auto-triggered (implant fullscreen) vs. manual (D-088).
.init_resource::<super::time::AutoPauseState>()
.init_resource::<super::chunk_streaming::ChunkLoadRadius>()
.init_resource::<super::chunk_streaming::ChunkStreamingCadence>()
.init_resource::<super::ticker::TickerPool>()