diff --git a/client/scripts/autoloads/hud_groups.gd b/client/scripts/autoloads/hud_groups.gd index 7e35e2ac2..81efca4d5 100644 --- a/client/scripts/autoloads/hud_groups.gd +++ b/client/scripts/autoloads/hud_groups.gd @@ -45,6 +45,28 @@ var _active_app: String = "" # currently focused implant app ("" = none) var _active_mode: Mode = Mode.GAMEPLAY +func _ready() -> void: + # T-970 (D-226 layer 1): auto-pause/auto-resume the sim when a fullscreen + # implant app occludes gameplay. AutoPause/AutoResume are distinct + # PlayerAction variants from the manual Pause/Unpause (Space bar, D-088) — + # the server tracks whether ITS OWN auto-pause caused the current pause + # (AutoPauseState) so a pre-existing manual pause or Half rate survives + # implant open/close untouched (see server/src/simulation/input.rs and + # server/src/simulation/time.rs). + gameplay_occluded.connect(_on_gameplay_occluded_auto_pause) + + +## Sends AutoPause/AutoResume via SimBridge's outbound queue. Uses +## send_named_action (protocol-level, not bound to an InputMapper.Action +## keybind) — the same mechanism as RequestBookmarkCatalog — since occlusion +## is a UI-state transition, not a physical input. +func _on_gameplay_occluded_auto_pause(occluded: bool) -> void: + if occluded: + SimBridge.send_named_action("AutoPause") + else: + SimBridge.send_named_action("AutoResume") + + func register(node: CanvasItem, group: String) -> void: if not _groups.has(group): _groups[group] = [] diff --git a/client/tests/test_hud_groups_auto_pause.gd b/client/tests/test_hud_groups_auto_pause.gd new file mode 100644 index 000000000..2c1d9fd5a --- /dev/null +++ b/client/tests/test_hud_groups_auto_pause.gd @@ -0,0 +1,84 @@ +class_name TestHudGroupsAutoPause +extends GdUnitTestSuite +## T-970 (D-226 layer 1): HudGroups.gameplay_occluded must auto-pause/auto-resume +## the sim via SimBridge.send_named_action("AutoPause"/"AutoResume"). +## +## AutoPause/AutoResume are protocol-level requests, not InputMapper keybinds — +## same mechanism as RequestBookmarkCatalog (send_named_action bypasses +## action_enum_to_wire entirely, so there is nothing to map there). This suite +## only covers the signal -> outbound-action wiring in hud_groups.gd; the +## server-side pause reconciliation (Option A) is covered by Rust tests in +## server/src/simulation/input.rs. + +const TEST_APP_PATH := "implant/test_auto_pause" +const OTHER_APP_PATH := "implant/test_auto_pause_other" + + +func before_test() -> void: + SimBridge.connect_to_sim() # test mode: immediately CONNECTED + SimBridge._outbound_buffer.clear() + HudGroups._active_app = "" + HudGroups._active_mode = HudGroups.Mode.GAMEPLAY + + +func after_test() -> void: + HudGroups._active_app = "" + HudGroups._active_mode = HudGroups.Mode.GAMEPLAY + HudGroups._groups.erase(TEST_APP_PATH) + HudGroups._groups.erase(OTHER_APP_PATH) + SimBridge.disconnect_from_sim() + SimBridge._outbound_buffer.clear() + + +func _outbound_has_action(action_name: String) -> bool: + for entry in SimBridge._outbound_buffer: + if entry.get("action_name") == action_name: + return true + return false + + +func test_opening_fullscreen_app_sends_auto_pause() -> void: + HudGroups.open_app(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + assert_bool(_outbound_has_action("AutoPause")).override_failure_message( + "Entering a fullscreen implant app must send AutoPause" + ).is_true() + + +func test_closing_fullscreen_app_sends_auto_resume() -> void: + HudGroups.open_app(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + SimBridge._outbound_buffer.clear() + HudGroups.close_app() + assert_bool(_outbound_has_action("AutoResume")).override_failure_message( + "Closing a fullscreen implant app must send AutoResume" + ).is_true() + + +func test_insert_mode_does_not_send_auto_pause() -> void: + # INSERT mode does not occlude gameplay (D-170) — gameplay_occluded never + # fires, so no AutoPause should be sent. + HudGroups.open_app(TEST_APP_PATH, HudGroups.Mode.INSERT) + assert_bool(_outbound_has_action("AutoPause")).override_failure_message( + "INSERT mode must not trigger AutoPause — gameplay is not occluded" + ).is_false() + + +func test_switching_between_fullscreen_apps_does_not_resend_auto_pause() -> void: + # D-170: switching from one fullscreen app to another must not re-emit + # gameplay_occluded (occlusion state does not change) — no duplicate AutoPause. + HudGroups.open_app(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + SimBridge._outbound_buffer.clear() + HudGroups.open_app(OTHER_APP_PATH, HudGroups.Mode.FULLSCREEN) + assert_bool(_outbound_has_action("AutoPause")).override_failure_message( + "Switching between two fullscreen apps must not re-send AutoPause" + ).is_false() + + +func test_toggle_app_closed_from_fullscreen_sends_auto_resume() -> void: + # toggle_app(app) when already open calls close_app() internally — + # exercises the same gameplay_occluded(false) path via a different entry point. + HudGroups.open_app(TEST_APP_PATH, HudGroups.Mode.FULLSCREEN) + SimBridge._outbound_buffer.clear() + HudGroups.toggle_app(TEST_APP_PATH) + assert_bool(_outbound_has_action("AutoResume")).override_failure_message( + "toggle_app() closing a fullscreen app must send AutoResume" + ).is_true() diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index d300efaf8..43ef1edcd 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -520,6 +520,17 @@ pub enum PlayerAction { UsePerceptionMode(String), Pause, Unpause, + /// Auto-pause the sim when a fullscreen implant app occludes gameplay + /// (T-970, D-226 layer 1: `HudGroups.gameplay_occluded`, D-170). Distinct + /// from the manual `Pause` (Space bar, D-088) so the server can tell + /// whether IT caused the current pause via `AutoPauseState` — a + /// pre-existing manual pause or Half rate survives an implant + /// open/close cycle untouched. No-op unless the sim is currently + /// `TickRate::Full` (see `simulation::time::AutoPauseState`). + AutoPause, + /// Auto-resume when the fullscreen implant app closes (T-970, D-226 + /// layer 1). No-op unless `AutoPause` is what caused the current pause. + AutoResume, /// Player walked away during active dialogue (WASD during conversation, D-064). /// Client sends this when movement input is detected while dialogue box is visible. /// Server records incomplete interaction in KG and clears dialogue state. diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 459782690..aedca0970 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -24,7 +24,7 @@ use std::collections::BTreeMap; use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId}; use crate::simulation::movement::TilePosition; -use crate::simulation::time::DayPhase; +use crate::simulation::time::{sim_not_paused, DayPhase}; /// NPC plugin: initializes NPC-related resources and systems. pub struct NpcPlugin; @@ -80,6 +80,11 @@ impl Plugin for NpcPlugin { disclosure::process_unprompted_disclosure .after(disclosure::derive_disclosure_candidates), ) + // T-970: TickPhase::Simulation is not set-gated (see + // social_plugin.rs's collect_sound_events exemption) — + // every system here is genuine NPC-behavior world + // advancement, so this whole tuple gates safely. + .run_if(sim_not_paused) .in_set(TickPhase::Simulation), ) // Storyteller: tell state derivation (reads mood + routine deviation) diff --git a/server/src/perception/mod.rs b/server/src/perception/mod.rs index 745f5e6fb..51ac29371 100644 --- a/server/src/perception/mod.rs +++ b/server/src/perception/mod.rs @@ -20,18 +20,24 @@ pub struct PerceptionPlugin; impl Plugin for PerceptionPlugin { fn build(&self, app: &mut App) { + use crate::simulation::time::sim_not_paused; use crate::tick_phases::TickPhase; app.init_resource::() .init_resource::() .init_resource::() - // Simulation: anomaly detection (feeds monologue recognition chain) + // Simulation: anomaly detection (feeds monologue recognition chain). + // T-970: TickPhase::Simulation is not set-gated (see + // social_plugin.rs's collect_sound_events exemption) — anomaly + // detection is genuine world-advancing analysis, so this tuple + // gates safely on its own. .add_systems( Update, ( anomaly::clear_anomaly_markers, anomaly::detect_anomalies.after(anomaly::clear_anomaly_markers), ) + .run_if(sim_not_paused) .in_set(TickPhase::Simulation), ) // Knowledge: cognitive delay + observation interpretation diff --git a/server/src/simulation/economy.rs b/server/src/simulation/economy.rs index 57e15e33e..a35344312 100644 --- a/server/src/simulation/economy.rs +++ b/server/src/simulation/economy.rs @@ -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::(); + + // 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::().econ_tick, + STALE_ECON_TICK, + "tick_economy_simulation must be skipped while paused — econ_tick unchanged" + ); + assert!( + world.resource::().pending.is_none(), + "serve_econ_state_query must still drain the pending query while paused" + ); + let response = world + .resource::() + .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::(); + + 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::().econ_tick, + 1, + "tick_economy_simulation must run and overwrite the stale econ_tick when not paused" + ); + } } diff --git a/server/src/simulation/economy_plugin.rs b/server/src/simulation/economy_plugin.rs index 23ab02f5f..a417e76b5 100644 --- a/server/src/simulation/economy_plugin.rs +++ b/server/src/simulation/economy_plugin.rs @@ -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), ) diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 50dfa7afa..bffec6b0f 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -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, - mut time: ResMut, + mut pause: PauseParams, mut commands: Commands, registry: Res, 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::(); + 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" + ); + } } diff --git a/server/src/simulation/social_plugin.rs b/server/src/simulation/social_plugin.rs index 765ceb190..68b24ae32 100644 --- a/server/src/simulation/social_plugin.rs +++ b/server/src/simulation/social_plugin.rs @@ -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::() .init_resource::() .init_resource::() + // 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), ); } diff --git a/server/src/simulation/time.rs b/server/src/simulation/time.rs index e985316ca..2e1aa403c 100644 --- a/server/src/simulation/time.rs +++ b/server/src/simulation/time.rs @@ -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) { } } +/// 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) -> 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>, +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/src/simulation/time_plugin.rs b/server/src/simulation/time_plugin.rs index b66f8d76a..a4ddbb5fc 100644 --- a/server/src/simulation/time_plugin.rs +++ b/server/src/simulation/time_plugin.rs @@ -15,6 +15,9 @@ pub struct TimePlugin; impl Plugin for TimePlugin { fn build(&self, app: &mut App) { app.init_resource::() + // T-970 (D-226 layer 1): tracks whether the current pause was + // auto-triggered (implant fullscreen) vs. manual (D-088). + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/server/src/tick_phases.rs b/server/src/tick_phases.rs index abd5d2146..f8818d29b 100644 --- a/server/src/tick_phases.rs +++ b/server/src/tick_phases.rs @@ -32,6 +32,8 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; +use crate::simulation::time::sim_not_paused; + /// The 10 phases of the server tick cycle. /// /// Ordered linearly: each phase completes before the next begins. @@ -85,5 +87,323 @@ impl TickPhase { ) .chain(), ); + + // T-970 (D-226 layer 1): freeze the world-advancing phases while the + // sim is paused (TickRate::Paused). PreInput (gen-drain, D-206), Input + // (accepts AutoResume/Unpause), Snapshot, and PostSnapshot stay + // ungated so the client keeps getting served while frozen. + // + // Movement, Storyteller, Knowledge, TickAdvance are gated at the SET + // level, one `configure_sets` call each (NOT a single call over a + // tuple of all four — see the note below on why grouping is unsafe). + // + // Simulation and Economy are deliberately NOT set-gated here — each + // needs a per-system split instead, because at least one system in + // each phase must keep running every tick regardless of pause state: + // - Economy: `serve_econ_state_query` (economy_plugin.rs) must keep + // answering the paused-allowed `EconStateQuery`. + // - Simulation: `collect_sound_events` (social_plugin.rs) must keep + // clearing `SoundEventQueue` every tick — it is a this-tick-only + // transient buffer (cleared then refilled each pass) that + // `compute_observer_snapshot` (Snapshot, ungated) only *peeks* at + // via `Res` — it does not drain it itself. Freezing the system that + // clears it left stale sound events visible in every snapshot taken + // while paused, which is exactly the class of bug T-970 exists to + // avoid: it broke `tests/golden_suite.rs`'s determinism fixture + // (`sound_events[0]: unexpected in actual`) even on a trace that + // only pauses on the last tick. Each individual Simulation-phase + // system EXCEPT collect_sound_events is gated at its own + // registration site instead (npc/mod.rs, perception/mod.rs, + // bridge/mod.rs, social_plugin.rs) — audited for the same + // peek-only-Res-of-a-phase-cleared-resource pattern; none of the + // others exhibited it against this fixture. + // + // IMPORTANT: `.run_if()` is attached to each of the four SET-gated + // phases INDIVIDUALLY here — NOT to a tuple of them as a whole (i.e. + // NOT `(Movement, Storyteller, Knowledge, TickAdvance).run_if(...)`). + // Bevy's `configure_sets` treats a >1-element group's collective + // `run_if` as a request to wrap every member in a brand-new anonymous + // parent set (`ScheduleGraph::apply_collective_conditions` only + // attaches the condition directly to the existing set when the group + // has exactly one element; for more, it mints an anonymous set and + // makes every member a child of it). That turned out to be a red + // herring for the actual bug above (an always-true dummy condition on + // the same grouped shape stayed green), but there is no upside to the + // grouped form and it is one more moving part than the single-set + // form needs, so each set gets its own call. + app.configure_sets(Update, Movement.run_if(sim_not_paused)); + app.configure_sets(Update, Storyteller.run_if(sim_not_paused)); + app.configure_sets(Update, Knowledge.run_if(sim_not_paused)); + app.configure_sets(Update, TickAdvance.run_if(sim_not_paused)); + } +} + +#[cfg(test)] +mod tests { + use super::TickPhase; + use crate::simulation::time::{SimulationTime, TickRate}; + use bevy_app::prelude::*; + use bevy_ecs::prelude::*; + use bevy_ecs::schedule::IntoScheduleConfigs; + + #[derive(Resource, Default)] + struct PhaseCounters { + pre_input: u32, + input: u32, + movement: u32, + simulation: u32, + economy: u32, + storyteller: u32, + snapshot: u32, + post_snapshot: u32, + knowledge: u32, + tick_advance: u32, + } + + fn mark_pre_input(mut c: ResMut) { + c.pre_input += 1; + } + fn mark_input(mut c: ResMut) { + c.input += 1; + } + fn mark_movement(mut c: ResMut) { + c.movement += 1; + } + fn mark_simulation(mut c: ResMut) { + c.simulation += 1; + } + fn mark_economy(mut c: ResMut) { + c.economy += 1; + } + fn mark_storyteller(mut c: ResMut) { + c.storyteller += 1; + } + fn mark_snapshot(mut c: ResMut) { + c.snapshot += 1; + } + fn mark_post_snapshot(mut c: ResMut) { + c.post_snapshot += 1; + } + fn mark_knowledge(mut c: ResMut) { + c.knowledge += 1; + } + fn mark_tick_advance(mut c: ResMut) { + c.tick_advance += 1; + } + + /// Builds a bare App with only the phase skeleton + one marker system per + /// phase — no other plugin — so this test exercises exactly the gating + /// wired in `TickPhase::configure`, nothing else. + fn build_test_app() -> App { + let mut app = App::new(); + TickPhase::configure(&mut app); + app.init_resource::(); + app.insert_resource(SimulationTime::default()); + app.add_systems(Update, mark_pre_input.in_set(TickPhase::PreInput)); + app.add_systems(Update, mark_input.in_set(TickPhase::Input)); + app.add_systems(Update, mark_movement.in_set(TickPhase::Movement)); + app.add_systems(Update, mark_simulation.in_set(TickPhase::Simulation)); + app.add_systems(Update, mark_economy.in_set(TickPhase::Economy)); + app.add_systems(Update, mark_storyteller.in_set(TickPhase::Storyteller)); + app.add_systems(Update, mark_snapshot.in_set(TickPhase::Snapshot)); + app.add_systems(Update, mark_post_snapshot.in_set(TickPhase::PostSnapshot)); + app.add_systems(Update, mark_knowledge.in_set(TickPhase::Knowledge)); + app.add_systems(Update, mark_tick_advance.in_set(TickPhase::TickAdvance)); + app + } + + #[test] + fn world_advancing_phases_skip_while_paused_keep_alive_phases_still_run() { + // T-970: Movement/Storyteller/Knowledge/TickAdvance must freeze on + // TickRate::Paused via TickPhase::configure's SET-level gate. + // PreInput/Input/Snapshot/PostSnapshot must keep running every pass + // regardless. Simulation and Economy are deliberately NOT gated by + // TickPhase::configure at all — each needs a per-system split + // instead (economy_plugin.rs's tick_economy_simulation vs. + // serve_econ_state_query; social_plugin.rs's collect_sound_events + // exemption, proven by `simulation_phase_gates_everything_except_sound_event_collection` + // below) — so this bare-`configure()` test app, which registers no + // other plugin, correctly observes both as ungated here. + let mut app = build_test_app(); + + // Pass 1 (Full rate): every phase runs once. + app.update(); + { + let c = app.world().resource::(); + assert_eq!(c.pre_input, 1); + assert_eq!(c.input, 1); + assert_eq!(c.movement, 1); + assert_eq!(c.simulation, 1); + assert_eq!(c.economy, 1); + assert_eq!(c.storyteller, 1); + assert_eq!(c.snapshot, 1); + assert_eq!(c.post_snapshot, 1); + assert_eq!(c.knowledge, 1); + assert_eq!(c.tick_advance, 1); + } + + // Pause, then pass 2. + app.world_mut().resource_mut::().tick_rate = TickRate::Paused; + app.update(); + let c = app.world().resource::(); + + // Keep-alive phases ran again. + assert_eq!( + c.pre_input, 2, + "PreInput must keep running while paused (gen-drain, D-206)" + ); + assert_eq!( + c.input, 2, + "Input must keep running while paused (accepts AutoResume/Unpause)" + ); + assert_eq!( + c.economy, 2, + "Economy SET must stay ungated at the phase level — the split lives in economy_plugin.rs" + ); + assert_eq!( + c.simulation, 2, + "Simulation SET must stay ungated at the phase level — the split lives in social_plugin.rs (collect_sound_events)" + ); + assert_eq!( + c.snapshot, 2, + "Snapshot must keep running while paused (bridge assembly)" + ); + assert_eq!( + c.post_snapshot, 2, + "PostSnapshot must keep running while paused (bridge send)" + ); + + // World-advancing phases must NOT have run again — still 1. + assert_eq!(c.movement, 1, "Movement must skip while paused"); + assert_eq!(c.storyteller, 1, "Storyteller must skip while paused"); + assert_eq!(c.knowledge, 1, "Knowledge must skip while paused"); + assert_eq!(c.tick_advance, 1, "TickAdvance must skip while paused"); + } + + /// Proves the exact mechanism that broke `tests/golden_suite.rs` + /// (`sound_events[0]: unexpected in actual`) and its fix: gating + /// `collect_sound_events` (or any system with the same "clear a + /// this-tick transient buffer" job) as part of a wholesale `Simulation` + /// SET-level condition leaves the buffer un-cleared while paused, and an + /// UNGATED downstream reader (like `compute_observer_snapshot`, which + /// only peeks the queue via `Res`, never draining it itself) then serves + /// stale data. Modeled with two tiny stand-in systems reproducing that + /// exact shape — not the real sound module — to keep this test + /// self-contained and fast. + #[test] + fn simulation_phase_gates_everything_except_sound_event_collection() { + #[derive(Resource, Default)] + struct StaleBuffer { + events: Vec, + } + + // Records the buffer length observed by the Snapshot-phase reader on + // each pass — a plain Vec can't be a Resource directly (orphan rule). + #[derive(Resource, Default)] + struct SeenLengths(Vec); + + // Stand-in for collect_sound_events: clears-then-refills every tick + // it runs. Registered WITHOUT a run_if — it must always run. + fn clear_buffer(mut buf: ResMut) { + buf.events.clear(); + } + + // Stand-in for a genuine "world advancing" Simulation-phase system — + // gated normally. + fn other_simulation_work(mut c: ResMut) { + c.simulation += 1; + } + + // Stand-in for compute_observer_snapshot: an UNGATED Snapshot-phase + // reader that only peeks the buffer (never drains it) — the role + // that observed the staleness in the real bug. + fn peek_buffer_into_snapshot(buf: Res, mut seen: ResMut) { + seen.0.push(buf.events.len()); + } + + let mut app = App::new(); + TickPhase::configure(&mut app); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.insert_resource(SimulationTime::default()); + + // Mirrors social_plugin.rs's actual split: collect_sound_events + // ungated, everything else in Simulation gated individually. + app.add_systems(Update, clear_buffer.in_set(TickPhase::Simulation)); + app.add_systems( + Update, + other_simulation_work + .run_if(crate::simulation::time::sim_not_paused) + .in_set(TickPhase::Simulation), + ); + app.add_systems( + Update, + peek_buffer_into_snapshot.in_set(TickPhase::Snapshot), + ); + + // Tick 1 (Full), buffer starts empty: baseline pass. + app.update(); + assert_eq!( + *app.world().resource::().0.last().unwrap(), + 0, + "tick 1: buffer starts empty" + ); + assert_eq!(app.world().resource::().simulation, 1); + + // Simulate "a footstep just happened": an event lands in the buffer + // as a tick's leftover state — exactly what collect_sound_events + // would have harvested from a SoundEventEmitter earlier that same + // tick, in the real system. Then pause (mirrors tick 8 in + // golden_suite.rs: Pause is processed via Input, which runs before + // Simulation in the very same pass, so Simulation's gate already + // observes Paused by the time it's evaluated this tick). + app.world_mut().resource_mut::().events.push(1); + app.world_mut().resource_mut::().tick_rate = + crate::simulation::time::TickRate::Paused; + app.update(); + + assert_eq!( + app.world().resource::().simulation, + 1, + "other_simulation_work must skip while paused (still 1, from tick 1's Full pass)" + ); + assert_eq!( + *app.world().resource::().0.last().unwrap(), + 0, + "clear_buffer (collect_sound_events stand-in) must still run while paused, \ + clearing the leftover event instead of leaking it into the paused \ + tick's snapshot — this is the exact T-970 golden_suite.rs regression \ + (sound_events[0]: unexpected in actual). If Simulation were set-gated \ + as a whole again, clear_buffer would also skip and this would \ + observe 1, not 0." + ); + } + + #[test] + fn gated_phases_resume_after_unpause() { + // Round-trip: paused -> unpaused must resume exactly where it left + // off, no double-counting or lost passes. + let mut app = build_test_app(); + app.update(); // Full: 1 everywhere + + app.world_mut().resource_mut::().tick_rate = TickRate::Paused; + app.update(); // Paused: world-advancing phases stay at 1 + + app.world_mut().resource_mut::().tick_rate = TickRate::Full; + app.update(); // Full again: world-advancing phases go to 2 + + let c = app.world().resource::(); + assert_eq!(c.movement, 2, "Movement must resume after unpause"); + assert_eq!(c.storyteller, 2, "Storyteller must resume after unpause"); + assert_eq!(c.knowledge, 2, "Knowledge must resume after unpause"); + assert_eq!(c.tick_advance, 2, "TickAdvance must resume after unpause"); + // Keep-alive phases (plus Simulation/Economy, ungated at this SET + // level — see the per-system splits in social_plugin.rs / + // economy_plugin.rs) ran all 3 passes. + assert_eq!(c.pre_input, 3); + assert_eq!(c.input, 3); + assert_eq!(c.economy, 3); + assert_eq!(c.simulation, 3); } }