Files
settled-reach/server/src/tick_phases.rs
T
jpmschweitzerandClaude Fable 5 6bda9f1697 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>
2026-07-14 15:44:56 +02:00

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);
}
}