refactor(simulation): replace ad-hoc system ordering with TickPhase pipeline (#843)

10-phase linear pipeline: PreInput → Input → Movement → Simulation →
Economy → Storyteller → Snapshot → PostSnapshot → Knowledge → TickAdvance.

Each system assigned to exactly one phase via .in_set(TickPhase::X).
Cross-phase .after()/.before() eliminated — only intra-phase ordering
remains. Prevents schedule cycles by construction.

SimulationPlugin refactored into sub-plugins by domain:
  - InputPlugin (player actions, interactions, dialogue dispatch)
  - MovementPlugin (pathfinding, movement validation, spatial indexing)
  - SocialPlugin (conversations, sound, voice enrichment, follow state)
  - EconomyPlugin (tâtonnement tick, IPC query serving)
  - TimePlugin (chunk streaming, news ticker, tick advancement)

All other plugins (NPC, Knowledge, Perception, Storyteller, Settings,
Bridge) updated to use TickPhase assignments instead of cross-plugin
ordering constraints. BridgePlugin trimmed to bridge I/O concerns only.

Part A of #843. Parts B (multi-threaded executor) and C (background
workers) follow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 23:51:06 +02:00
co-authored by Claude Opus 4.6
parent d434235985
commit 47d4918cc8
15 changed files with 443 additions and 274 deletions
+33
View File
@@ -0,0 +1,33 @@
//! Economy phase plugin — tâtonnement simulation tick and IPC query serving.
//!
//! All systems run in [`TickPhase::Economy`]. Intra-phase ordering:
//! - tick_economy_simulation → serve_econ_state_query (query reads fresh signals)
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::tick_phases::TickPhase;
pub struct EconomyPlugin;
impl Plugin for EconomyPlugin {
fn build(&self, app: &mut App) {
// Economy simulation (#821, D-031) — loaded once at startup, no-op when DB absent.
// Uses seed 0 for now; will be threaded through StartupMessage world seed (#826).
if let Some((econ_sim, econ_state)) = super::economy::try_load_economy(0) {
app.insert_resource(econ_sim).insert_resource(econ_state);
}
// EconQueryBuffer: always registered so EconStateQuery PlayerActions are accepted
// even when the economy DB is absent (queries just produce no response).
app.init_resource::<super::economy::EconQueryBuffer>()
.add_systems(
Update,
(
super::economy::tick_economy_simulation,
super::economy::serve_econ_state_query
.after(super::economy::tick_economy_simulation),
)
.in_set(TickPhase::Economy),
);
}
}
+36
View File
@@ -0,0 +1,36 @@
//! Input phase plugin — processes player actions and interaction commands.
//!
//! All systems run in [`TickPhase::Input`]. Intra-phase ordering:
//! - `process_player_input` runs first (dispatches queued actions)
//! - Door/terminal/examine/triangle run after input (consume dispatched events)
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::tick_phases::TickPhase;
pub struct InputPlugin;
impl Plugin for InputPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<super::input::InputQueue>()
.init_resource::<super::interaction::TerminalInteractedQueue>()
// Triangle resolution resources (#250)
.init_resource::<super::triangle::ResolveTriangleQueue>()
.add_systems(
Update,
(
super::input::process_player_input,
super::interaction::process_door_interaction
.after(super::input::process_player_input),
super::interaction::process_terminal_interaction
.after(super::input::process_player_input),
super::examine::process_examine_interaction
.after(super::input::process_player_input),
super::triangle::apply_resolve_triangle
.after(super::input::process_player_input),
)
.in_set(TickPhase::Input),
);
}
}
+31 -140
View File
@@ -5,6 +5,12 @@ use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod chunk_streaming;
// Phase sub-plugins (#843)
pub mod economy_plugin;
pub mod input_plugin;
pub mod movement_plugin;
pub mod social_plugin;
pub mod time_plugin;
pub mod contraband;
pub mod conversation;
pub mod dialogue;
@@ -39,8 +45,11 @@ pub mod time;
pub mod triangle;
pub mod zone;
/// Core simulation plugin
/// Manages simulation time, RNG, input processing, and tier transitions
/// Core simulation plugin — shell that delegates to phase sub-plugins (#843).
///
/// Each sub-plugin owns its domain's systems, resources, and phase assignment.
/// No system registration happens here — only sub-plugin composition and
/// shared resources needed by multiple sub-plugins.
pub struct SimulationPlugin;
impl Plugin for SimulationPlugin {
@@ -48,151 +57,33 @@ impl Plugin for SimulationPlugin {
// Tier marker components (D-026) — must register before behavior systems
app.add_plugins(tier::TierPlugin);
// Initialize core simulation resources
app.init_resource::<time::SimulationTime>()
.insert_resource(rng::SimRng::new(0))
.init_resource::<input::InputQueue>()
// Shared resources needed by multiple sub-plugins.
// Each sub-plugin inits its own domain-specific resources.
app.insert_resource(rng::SimRng::new(0))
.init_resource::<save_io::SaveLoadPending>()
.init_resource::<crate::knowledge::EntityRegistry>()
.init_resource::<sound::SoundEventQueue>()
.init_resource::<spatial::NaiveSpatialIndex>()
.init_resource::<chunk_streaming::ChunkLoadRadius>()
.init_resource::<chunk_streaming::ChunkStreamingCadence>()
.init_resource::<follow::FollowEndEventQueue>()
.init_resource::<monologue::PostConversationQueue>()
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
// Triangle escalation resources (#250)
.init_resource::<crate::simulation::triangle::TriangleCrisisEventQueue>()
.init_resource::<crate::simulation::triangle::ResolveTriangleQueue>()
// discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin).
// Init here so SimulationPlugin works standalone in tests without PerceptionPlugin.
// Triangle escalation event queue (#250) — consumed by storyteller + npc plugins
.init_resource::<triangle::TriangleCrisisEventQueue>()
// Cross-plugin resource safety: init resources that may be read by other plugins
// before those plugins register. Prevents "resource not found" in standalone tests.
.init_resource::<crate::perception::query::VisibilityGeometry>()
// transfer_npc_knowledge reads RelationshipGraph (also init by NpcPlugin) and
// KnowledgeEventQueue (also init by KnowledgePlugin).
// Init here so SimulationPlugin works standalone in tests without those plugins.
.init_resource::<crate::npc::relationships::RelationshipGraph>()
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
.init_resource::<interaction::TerminalInteractedQueue>()
// Zone crossing event queue (#512, D-077): detect when player moves between zones.
.init_resource::<zone::ZoneCrossEventQueue>()
.init_resource::<zone::PreviousPlayerZone>()
.add_systems(
Update,
(
input::process_player_input,
// execute_save_load is an exclusive system (takes &mut World).
// Must run after process_player_input (which queues the command)
// and before compute_observer_snapshot (which consumes the result).
save_io::execute_save_load
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
pathfinding::compute_paths.after(input::process_player_input),
path_follow::follow_paths.after(pathfinding::compute_paths),
movement::validate_movement.after(path_follow::follow_paths),
path_follow::cleanup_path_blocked.after(movement::validate_movement),
spatial::sync_spatial_index.after(movement::validate_movement),
listening::update_listening_focus.after(movement::validate_movement),
contraband::check_contraband_scan
.after(movement::validate_movement)
.before(crate::perception::observer::compute_observer_snapshot),
conversation::run_npc_conversations
.after(movement::validate_movement)
.before(sound::collect_sound_events),
npc_knowledge_transfer::transfer_npc_knowledge
.after(conversation::run_npc_conversations),
sound::collect_sound_events
.after(movement::validate_movement)
.before(crate::perception::observer::compute_observer_snapshot),
poi_discovery::discover_pois
.after(crate::perception::observer::compute_visibility_geometry)
.before(crate::perception::observer::compute_observer_snapshot),
interaction::process_door_interaction
.after(input::process_player_input)
.before(movement::validate_movement),
interaction::process_terminal_interaction
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
examine::process_examine_interaction
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
// Character pressure (#248) — reads NPC awareness + relationship graph
pressure::update_character_pressure
.after(crate::npc::awareness::detect_player_awareness)
.before(crate::perception::observer::compute_observer_snapshot),
// Triangle escalation (#250) — runs on game-minute boundaries (every 10 ticks)
crate::simulation::triangle::tick_triangle_escalation
.after(crate::npc::tolerance::check_tolerance_threshold)
.before(crate::perception::observer::compute_observer_snapshot),
// Triangle resolution (#250, D-089) — apply player resolve commands
crate::simulation::triangle::apply_resolve_triangle
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
time::advance_tick.after(path_follow::cleanup_path_blocked),
),
)
// Zone crossing detection (#512, D-077) — separate call to stay within
// Bevy's 20-element system tuple limit.
.add_systems(
Update,
zone::detect_zone_crossings.after(movement::validate_movement),
)
// Chunk streaming (#578, D-012) — loads/unloads chunks around the player.
// Runs before input processing so chunks are available for the current tick.
.add_systems(
Update,
chunk_streaming::chunk_streaming.before(input::process_player_input),
)
// News ticker rotation (#591) — deterministic via SimRng, before snapshot.
.add_systems(
Update,
ticker::tick_news_ticker
.before(crate::perception::observer::compute_observer_snapshot),
);
.init_resource::<crate::knowledge::KnowledgeEventQueue>();
// Voice enrichment (D-138, Phase 3) — rewrite NPC text with voiced
// variants from cache before the observer snapshot is assembled.
// No-op when VoiceCacheResource is absent (voice pipeline disabled).
// Phase sub-plugins (#843) — each owns its domain
app.add_plugins(time_plugin::TimePlugin);
app.add_plugins(input_plugin::InputPlugin);
app.add_plugins(movement_plugin::MovementPlugin);
app.add_plugins(social_plugin::SocialPlugin);
app.add_plugins(economy_plugin::EconomyPlugin);
// save_load is an exclusive system (takes &mut World).
// Exclusive systems in Bevy 0.18 cannot use .in_set() — use .before()/.after()
// to position it within the Snapshot phase window.
app.add_systems(
Update,
(
crate::voice::integration::voice_enrich_dialogue_response
.after(crate::simulation::dialogue::process_talk_interaction)
.before(crate::perception::observer::compute_observer_snapshot),
crate::voice::integration::voice_enrich_conversation_events
.after(conversation::run_npc_conversations)
.before(crate::perception::observer::compute_observer_snapshot),
),
);
// Initialize TickerPool with empty default; populated by ContentPlugin at Startup.
app.init_resource::<ticker::TickerPool>();
// Economy simulation (#821, D-031) — loaded once at startup, no-op when DB absent.
// Uses seed 0 for now; will be threaded through StartupMessage world seed (#826).
if let Some((econ_sim, econ_state)) = economy::try_load_economy(0) {
app.insert_resource(econ_sim).insert_resource(econ_state);
}
// EconQueryBuffer: always registered so EconStateQuery PlayerActions are accepted
// even when the economy DB is absent (queries just produce no response).
app.init_resource::<economy::EconQueryBuffer>();
// tick_economy_simulation + serve_econ_state_query use Option<ResMut<...>> — safe to
// register unconditionally. They no-op when EconSimResource / EconStateResource absent.
app.add_systems(
Update,
(
economy::tick_economy_simulation
.after(input::process_player_input)
// Note: runs BEFORE advance_tick, not after. The economy checks
// time.tick which is the CURRENT tick (not yet advanced). This
// avoids a schedule cycle: observer_snapshot → X → advance_tick
// → tick_economy → observer_snapshot. Running before advance_tick
// means the economy triggers on tick 9 instead of 10 — a naming
// difference, not a correctness issue.
.before(crate::perception::observer::compute_observer_snapshot),
economy::serve_econ_state_query
.after(economy::tick_economy_simulation)
.before(crate::perception::observer::compute_observer_snapshot),
),
save_io::execute_save_load
.before(crate::perception::observer::compute_observer_snapshot),
);
tracing::debug!("SimulationPlugin initialized");
+41
View File
@@ -0,0 +1,41 @@
//! Movement phase plugin — position resolution and spatial indexing.
//!
//! All systems run in [`TickPhase::Movement`]. Intra-phase ordering:
//! - pathfinding → follow_paths → validate_movement → cleanup_path_blocked
//! - After validate_movement: spatial index, listening focus, zone crossings, tier systems
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::tick_phases::TickPhase;
pub struct MovementPlugin;
impl Plugin for MovementPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<super::spatial::NaiveSpatialIndex>()
.init_resource::<super::zone::ZoneCrossEventQueue>()
.init_resource::<super::zone::PreviousPlayerZone>()
.add_systems(
Update,
(
// Core movement chain (strict sequence)
super::pathfinding::compute_paths,
super::path_follow::follow_paths
.after(super::pathfinding::compute_paths),
super::movement::validate_movement
.after(super::path_follow::follow_paths),
super::path_follow::cleanup_path_blocked
.after(super::movement::validate_movement),
// Post-movement indexing (all after validate_movement, parallel with each other)
super::spatial::sync_spatial_index
.after(super::movement::validate_movement),
super::listening::update_listening_focus
.after(super::movement::validate_movement),
super::zone::detect_zone_crossings
.after(super::movement::validate_movement),
)
.in_set(TickPhase::Movement),
);
}
}
+47
View File
@@ -0,0 +1,47 @@
//! Social simulation plugin — NPC conversations, knowledge transfer, disclosure.
//!
//! All systems run in [`TickPhase::Simulation`]. Intra-phase ordering:
//! - conversations → knowledge_transfer (transfer reads conversation results)
//! - conversations → sound collection (sound reads conversation events)
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::tick_phases::TickPhase;
pub struct SocialPlugin;
impl Plugin for SocialPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<super::sound::SoundEventQueue>()
.init_resource::<super::follow::FollowEndEventQueue>()
.init_resource::<super::monologue::PostConversationQueue>()
.init_resource::<super::poi_discovery::PoiDiscoveryEventQueue>()
.add_systems(
Update,
(
super::conversation::run_npc_conversations,
super::npc_knowledge_transfer::transfer_npc_knowledge
.after(super::conversation::run_npc_conversations),
super::sound::collect_sound_events
.after(super::conversation::run_npc_conversations),
// Voice enrichment (D-138) — rewrite NPC text with voiced variants.
// No-op when VoiceCacheResource is absent.
crate::voice::integration::voice_enrich_dialogue_response,
crate::voice::integration::voice_enrich_conversation_events
.after(super::conversation::run_npc_conversations),
// POI discovery reads visibility geometry (also Simulation phase)
super::poi_discovery::discover_pois,
// Follow state reads visibility geometry + movement
super::follow::update_follow_state,
// Contraband scan reads movement + NPC awareness
super::contraband::check_contraband_scan,
// Pressure reads NPC awareness + relationship graph
super::pressure::update_character_pressure,
// Triangle escalation (#250) — runs on game-minute boundaries
super::triangle::tick_triangle_escalation,
)
.in_set(TickPhase::Simulation),
);
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Time and streaming plugin — tick advancement, chunk streaming, news ticker.
//!
//! Systems span two phases:
//! - [`TickPhase::PreInput`]: chunk_streaming (loads chunks before input processing)
//! - [`TickPhase::TickAdvance`]: advance_tick (must be last in the pipeline)
//! - [`TickPhase::Snapshot`]: tick_news_ticker (assembles display content)
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::tick_phases::TickPhase;
pub struct TimePlugin;
impl Plugin for TimePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<super::time::SimulationTime>()
.init_resource::<super::chunk_streaming::ChunkLoadRadius>()
.init_resource::<super::chunk_streaming::ChunkStreamingCadence>()
.init_resource::<super::ticker::TickerPool>()
.add_systems(
Update,
super::chunk_streaming::chunk_streaming
.in_set(TickPhase::PreInput),
)
.add_systems(
Update,
super::ticker::tick_news_ticker
.in_set(TickPhase::Snapshot),
)
.add_systems(
Update,
super::time::advance_tick
.in_set(TickPhase::TickAdvance),
);
}
}