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:
@@ -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] = []
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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::<interpretation::ObservationEventQueue>()
|
||||
.init_resource::<query::VisibilityGeometry>()
|
||||
.init_resource::<query::ActivePerceptionMode>()
|
||||
// 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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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<PhaseCounters>) {
|
||||
c.pre_input += 1;
|
||||
}
|
||||
fn mark_input(mut c: ResMut<PhaseCounters>) {
|
||||
c.input += 1;
|
||||
}
|
||||
fn mark_movement(mut c: ResMut<PhaseCounters>) {
|
||||
c.movement += 1;
|
||||
}
|
||||
fn mark_simulation(mut c: ResMut<PhaseCounters>) {
|
||||
c.simulation += 1;
|
||||
}
|
||||
fn mark_economy(mut c: ResMut<PhaseCounters>) {
|
||||
c.economy += 1;
|
||||
}
|
||||
fn mark_storyteller(mut c: ResMut<PhaseCounters>) {
|
||||
c.storyteller += 1;
|
||||
}
|
||||
fn mark_snapshot(mut c: ResMut<PhaseCounters>) {
|
||||
c.snapshot += 1;
|
||||
}
|
||||
fn mark_post_snapshot(mut c: ResMut<PhaseCounters>) {
|
||||
c.post_snapshot += 1;
|
||||
}
|
||||
fn mark_knowledge(mut c: ResMut<PhaseCounters>) {
|
||||
c.knowledge += 1;
|
||||
}
|
||||
fn mark_tick_advance(mut c: ResMut<PhaseCounters>) {
|
||||
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::<PhaseCounters>();
|
||||
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::<PhaseCounters>();
|
||||
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::<SimulationTime>().tick_rate = TickRate::Paused;
|
||||
app.update();
|
||||
let c = app.world().resource::<PhaseCounters>();
|
||||
|
||||
// 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<u32>,
|
||||
}
|
||||
|
||||
// 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<usize>);
|
||||
|
||||
// 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<StaleBuffer>) {
|
||||
buf.events.clear();
|
||||
}
|
||||
|
||||
// Stand-in for a genuine "world advancing" Simulation-phase system —
|
||||
// gated normally.
|
||||
fn other_simulation_work(mut c: ResMut<PhaseCounters>) {
|
||||
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<StaleBuffer>, mut seen: ResMut<SeenLengths>) {
|
||||
seen.0.push(buf.events.len());
|
||||
}
|
||||
|
||||
let mut app = App::new();
|
||||
TickPhase::configure(&mut app);
|
||||
app.init_resource::<PhaseCounters>();
|
||||
app.init_resource::<StaleBuffer>();
|
||||
app.init_resource::<SeenLengths>();
|
||||
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::<SeenLengths>().0.last().unwrap(),
|
||||
0,
|
||||
"tick 1: buffer starts empty"
|
||||
);
|
||||
assert_eq!(app.world().resource::<PhaseCounters>().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::<StaleBuffer>().events.push(1);
|
||||
app.world_mut().resource_mut::<SimulationTime>().tick_rate =
|
||||
crate::simulation::time::TickRate::Paused;
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<PhaseCounters>().simulation,
|
||||
1,
|
||||
"other_simulation_work must skip while paused (still 1, from tick 1's Full pass)"
|
||||
);
|
||||
assert_eq!(
|
||||
*app.world().resource::<SeenLengths>().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::<SimulationTime>().tick_rate = TickRate::Paused;
|
||||
app.update(); // Paused: world-advancing phases stay at 1
|
||||
|
||||
app.world_mut().resource_mut::<SimulationTime>().tick_rate = TickRate::Full;
|
||||
app.update(); // Full again: world-advancing phases go to 2
|
||||
|
||||
let c = app.world().resource::<PhaseCounters>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user