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>
410 lines
18 KiB
Rust
410 lines
18 KiB
Rust
//! Server tick cycle — named system set phases (#843).
|
|
//!
|
|
//! Replaces ad-hoc `.after()`/`.before()` constraints with a linear pipeline.
|
|
//! Each system belongs to exactly one phase. Phases execute sequentially.
|
|
//! Within a phase, systems with no intra-phase ordering run in parallel
|
|
//! (when the multi-threaded executor is enabled).
|
|
//!
|
|
//! ```text
|
|
//! PreInput → Input → Movement → Simulation → Economy
|
|
//! → Storyteller → Snapshot → PostSnapshot → Knowledge → TickAdvance
|
|
//! ```
|
|
//!
|
|
//! ## Rules
|
|
//!
|
|
//! 1. Every system in the Update schedule MUST have `.in_set(TickPhase::X)`.
|
|
//! 2. Cross-phase `.after()`/`.before()` is FORBIDDEN — if you need it,
|
|
//! the system is in the wrong phase.
|
|
//! 3. Intra-phase `.after()`/`.before()` is allowed for systems within
|
|
//! the same phase that have a real data dependency.
|
|
//!
|
|
//! **Exception — exclusive systems.** Exclusive systems (`&mut World`) cannot
|
|
//! use `.in_set()` in Bevy 0.18, so rule 1 is unsatisfiable for them. Instead
|
|
//! they MUST be pinned into their phase window with explicit constraints on
|
|
//! BOTH sides: `.after(TickPhase::<previous phase>)` for the lower bound and
|
|
//! `.before(...)`/`.after(...)` against systems or the phase sets around them
|
|
//! for the upper bound. A one-sided constraint leaves the scheduler free to
|
|
//! run the system anywhere earlier/later in the tick. Sole current instance:
|
|
//! `save_io::execute_save_load` (simulation/mod.rs), pinned after
|
|
//! `Storyteller` and before `compute_observer_snapshot` (Snapshot phase).
|
|
|
|
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.
|
|
/// Configure once in the App with `configure_sets(Update, ...)`.
|
|
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum TickPhase {
|
|
/// Setup before player input: chunk streaming, bridge input, NPC routine transitions.
|
|
PreInput,
|
|
/// Process player actions: input dispatch, door/terminal/examine interactions, dialogue.
|
|
Input,
|
|
/// Position resolution: pathfinding, path following, movement validation, spatial indexing.
|
|
Movement,
|
|
/// Game logic: NPC behavior, vision, conversations, monologue, sound, visibility geometry.
|
|
Simulation,
|
|
/// Economics: tick the economy simulation, serve economy state queries.
|
|
Economy,
|
|
/// Storyteller: contamination, activation, tell escalation, tell state derivation.
|
|
Storyteller,
|
|
/// Assemble tick output: observer snapshot, ticker, settings, debug commands, save/load.
|
|
Snapshot,
|
|
/// React to snapshot: observation events, send to client, anomaly detection.
|
|
PostSnapshot,
|
|
/// Update persistent state: knowledge events, cognitive delay, relationships.
|
|
Knowledge,
|
|
/// Increment the clock. Must be last.
|
|
TickAdvance,
|
|
}
|
|
|
|
impl TickPhase {
|
|
/// Configure the linear phase ordering on the app.
|
|
///
|
|
/// Call once during plugin setup:
|
|
/// ```ignore
|
|
/// TickPhase::configure(&mut app);
|
|
/// ```
|
|
pub fn configure(app: &mut bevy_app::App) {
|
|
use TickPhase::*;
|
|
app.configure_sets(
|
|
Update,
|
|
(
|
|
PreInput,
|
|
Input,
|
|
Movement,
|
|
Simulation,
|
|
Economy,
|
|
Storyteller,
|
|
Snapshot,
|
|
PostSnapshot,
|
|
Knowledge,
|
|
TickAdvance,
|
|
)
|
|
.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);
|
|
}
|
|
}
|