//! 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::)` 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) { 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); } }