From af41ea10a90d6228bc6717a241f9570d30a455c1 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 26 Jun 2026 18:16:12 -0400 Subject: [PATCH] =?UTF-8?q?refactor(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=A7=A9=20unify=20turn=20phases=20=E2=80=94=20ecology=20in?= =?UTF-8?q?to=20mc-turn=20+=20end-of-turn=20phase=20registry=20(OCP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the dependency cycle broken (5ee312e45), mc-turn can finally depend on mc-ecology, so the ecology tick moves out of its mc-player-api::apply_end_turn special-case and into the turn itself — all phases now live in one crate. Introduces `mc-turn::sim_phases` — the end-of-turn world-sim phases declared as an ordered DATA registry (`END_OF_TURN_PHASES = [ecology, healing]`, uniform `fn(&mut GameState)`), run after climate inside step(). Extending the living world (marine, fauna disease, …) is now one line in the registry + a phase module — no edit to step()'s body, sequence visible in one place. - Moved ecology_phase.rs → mc-turn; added mc-turn → mc-ecology dep. - step(): replaced the direct healing call with `run_end_of_turn_phases` (ecology → healing). - Removed from mc-player-api: the apply_end_turn ecology call, module, file, and mc-ecology dep. mc-turn 276/0 (incl. ecology + sim_phases tests), mc-player-api 135/0. Behavior identical (ecology still ticks once per turn, now inside step). Dylib rebuild pending (functionally equivalent; FFI surface unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/simulator/Cargo.lock | 2 +- src/simulator/crates/mc-player-api/Cargo.toml | 1 - .../crates/mc-player-api/src/dispatch.rs | 6 --- src/simulator/crates/mc-player-api/src/lib.rs | 1 - src/simulator/crates/mc-turn/Cargo.toml | 1 + .../src/ecology_phase.rs | 18 ++----- src/simulator/crates/mc-turn/src/lib.rs | 4 ++ src/simulator/crates/mc-turn/src/processor.rs | 8 +-- .../crates/mc-turn/src/sim_phases.rs | 53 +++++++++++++++++++ 9 files changed, 69 insertions(+), 25 deletions(-) rename src/simulator/crates/{mc-player-api => mc-turn}/src/ecology_phase.rs (86%) create mode 100644 src/simulator/crates/mc-turn/src/sim_phases.rs diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index 084a81ba..2285db3e 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -1780,7 +1780,6 @@ dependencies = [ "mc-compute", "mc-core", "mc-flora", - "mc-mapgen", "mc-profiling", "rayon", "serde", @@ -2049,6 +2048,7 @@ dependencies = [ "mc-comms", "mc-core", "mc-culture", + "mc-ecology", "mc-economy", "mc-happiness", "mc-observation", diff --git a/src/simulator/crates/mc-player-api/Cargo.toml b/src/simulator/crates/mc-player-api/Cargo.toml index f6075556..109ff223 100644 --- a/src/simulator/crates/mc-player-api/Cargo.toml +++ b/src/simulator/crates/mc-player-api/Cargo.toml @@ -10,7 +10,6 @@ mc-tech = { path = "../mc-tech" } mc-trade = { path = "../mc-trade" } mc-state = { path = "../mc-state" } mc-turn = { path = "../mc-turn" } -mc-ecology = { path = "../mc-ecology" } mc-combat = { path = "../mc-combat" } mc-items = { path = "../mc-items" } mc-ai = { path = "../mc-ai" } diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index d2461e22..b6b5fb13 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -423,12 +423,6 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result, } let mut result = processor.step(state); - // p3-27 biosphere: tick the living ecology (fauna populations + flora - // succession) right after the turn step. Lives here, not in - // `TurnProcessor::step`, to avoid the `mc-turn → mc-ecology → mc-mapgen → - // mc-turn` dependency cycle. No-op until the species library is boot-loaded. - crate::ecology_phase::process_ecology_phase(state); - // Communications Phase 6 — end-of-turn comms passes. // Runs after the processor step (so `state.turn` is the new turn // and `step_comms` evaluates deliveries against it). Order: diff --git a/src/simulator/crates/mc-player-api/src/lib.rs b/src/simulator/crates/mc-player-api/src/lib.rs index 99075b56..43557d12 100644 --- a/src/simulator/crates/mc-player-api/src/lib.rs +++ b/src/simulator/crates/mc-player-api/src/lib.rs @@ -20,7 +20,6 @@ pub mod action; pub mod comms_dispatch; pub mod controllers; pub mod dispatch; -pub mod ecology_phase; pub mod error; pub mod learned; pub mod projection; diff --git a/src/simulator/crates/mc-turn/Cargo.toml b/src/simulator/crates/mc-turn/Cargo.toml index 53f5d607..7b9b02ab 100644 --- a/src/simulator/crates/mc-turn/Cargo.toml +++ b/src/simulator/crates/mc-turn/Cargo.toml @@ -20,6 +20,7 @@ mc-economy = { path = "../mc-economy" } mc-civics = { path = "../mc-civics" } mc-trade = { path = "../mc-trade" } mc-climate = { path = "../mc-climate" } +mc-ecology = { path = "../mc-ecology" } mc-tech = { path = "../mc-tech" } mc-replay = { path = "../mc-replay" } mc-comms = { path = "../mc-comms" } diff --git a/src/simulator/crates/mc-player-api/src/ecology_phase.rs b/src/simulator/crates/mc-turn/src/ecology_phase.rs similarity index 86% rename from src/simulator/crates/mc-player-api/src/ecology_phase.rs rename to src/simulator/crates/mc-turn/src/ecology_phase.rs index 55e01ae5..59585477 100644 --- a/src/simulator/crates/mc-player-api/src/ecology_phase.rs +++ b/src/simulator/crates/mc-turn/src/ecology_phase.rs @@ -1,15 +1,8 @@ -//! p3-27 biosphere — per-turn ecology tick for the headless turn. +//! p3-27 biosphere — per-turn ecology tick. //! //! The live game ticks `mc_ecology::EcologyEngine` every turn through the //! `GdFaunaEcology` GDExtension node (`process_step` + continuation-JSON -//! save/restore). The headless turn never did — so the simulated world had no -//! living fauna for the economy or natural events to interact with. -//! -//! This lives in `mc-player-api` (the turn-orchestration layer), NOT in -//! `mc-turn::step`, because `mc-turn → mc-ecology` would be a cyclic dependency -//! (`mc-ecology → mc-mapgen → mc-turn`). The dispatcher calls it immediately -//! after `TurnProcessor::step`, so the canonical headless play path -//! (`apply_end_turn`) ticks the biosphere every turn. +//! save/restore). This brings the same living biosphere to the headless turn. //! //! It reproduces the live construction exactly: a fresh `EcologyEngine` (default //! config), its species library loaded from the boot-supplied fauna JSONs, @@ -19,8 +12,9 @@ //! (emergence, population dynamics, flora succession) so the returned //! `FloraTransition`s are a report, not work the caller must apply. //! -//! Determinism: `process_step` and `seed_initial` are seeded from -//! `GameState::map_seed`, so the whole phase is reproducible. +//! Registered in [`crate::sim_phases::END_OF_TURN_PHASES`] and run after climate +//! so the biosphere reacts to the freshly-updated temperature/moisture. +//! Determinism: seeded from `GameState::map_seed`. use mc_ecology::engine::EcologyEngine; use mc_ecology::species::load_species_library; @@ -134,7 +128,6 @@ mod tests { let mut state = build_state(&species); assert!(state.worldsim_state_json.is_empty(), "starts barren"); - // Genesis tick: seeds base trophic populations, writes continuation JSON. process_ecology_phase(&mut state); assert!( !state.worldsim_state_json.is_empty(), @@ -142,7 +135,6 @@ mod tests { ); let after_first = state.worldsim_state_json.clone(); - // Second tick restores + evolves; continuation JSON advances (tick_count). process_ecology_phase(&mut state); assert!(!state.worldsim_state_json.is_empty()); assert_ne!( diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index 35495b19..9240a57b 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -47,6 +47,10 @@ pub mod courier_resolver; pub mod happiness_phase; /// p3-26 B2 — Unit + city healing tick. pub mod healing; +/// p3-27 — Per-turn ecology (fauna populations + flora succession) tick. +pub mod ecology_phase; +/// End-of-turn world-simulation phase registry (ecology, healing, …). +pub mod sim_phases; #[cfg(feature = "gpu")] pub mod gpu; diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index b69f6a34..c498e48e 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -503,9 +503,11 @@ impl TurnProcessor { // bench (`ClimatePhysics::new("{}","[]","{}")`). self.process_climate_phase(state); - // p3-26 B2: end-of-turn HP regen for units (territory/fortified rates) and cities - // (heal toward max_hp), mirroring the live `_process_healing`/`_process_city_healing`. - crate::healing::process_healing_phase(state); + // End-of-turn world-simulation phases (ecology → healing), declared as an + // ordered registry in `sim_phases` rather than hardcoded here. Extend the + // living world by adding a phase there, not by editing step(). Ecology + // reacts to the climate just ticked; healing settles afterward. + crate::sim_phases::run_end_of_turn_phases(state); // Phase 5a-sentry: wake sentrying units that have enemies in vision range (2 hex). // Runs after movement so positions are current; runs before PvP so the diff --git a/src/simulator/crates/mc-turn/src/sim_phases.rs b/src/simulator/crates/mc-turn/src/sim_phases.rs new file mode 100644 index 00000000..6cc88c82 --- /dev/null +++ b/src/simulator/crates/mc-turn/src/sim_phases.rs @@ -0,0 +1,53 @@ +//! End-of-turn world-simulation phase registry. +//! +//! The turn's end-of-turn phases — the living-world updates that run after the +//! economic + combat machinery — are declared here as DATA rather than hardcoded +//! calls threaded through `TurnProcessor::step`. Each is a uniform +//! `fn(&mut GameState)` that loops the map/players internally and is a safe no-op +//! when its inputs aren't booted (e.g. ecology with no species library). +//! +//! Extending the simulator with a new end-of-turn subsystem (marine ecology, +//! fauna disease, …) is one line here plus the phase module — no edit to the body +//! of `step()`, and the ordered sequence stays visible in one place (OCP). +//! +//! Climate (`process_climate_phase`) runs immediately *before* this block so the +//! biosphere reacts to the freshly-updated weather; it stays a `TurnProcessor` +//! method for now because it shares the processor's climate helpers. + +use mc_state::game_state::GameState; + +/// A turn phase: mutates the whole `GameState` for one turn, looping internally. +pub type SimPhaseFn = fn(&mut GameState); + +/// Ordered end-of-turn world-sim phases, run after climate. Order matters: +/// ecology evolves the biosphere first, then unit/city recovery settles. +pub const END_OF_TURN_PHASES: &[(&str, SimPhaseFn)] = &[ + ("ecology", crate::ecology_phase::process_ecology_phase), + ("healing", crate::healing::process_healing_phase), +]; + +/// Run every registered end-of-turn phase in order. +pub fn run_end_of_turn_phases(state: &mut GameState) { + for (_name, phase) in END_OF_TURN_PHASES { + phase(state); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_lists_phases_in_documented_order() { + let names: Vec<&str> = END_OF_TURN_PHASES.iter().map(|(n, _)| *n).collect(); + assert_eq!(names, vec!["ecology", "healing"]); + } + + #[test] + fn run_end_of_turn_phases_is_a_safe_noop_on_empty_state() { + // No grid / no species / no units → every phase no-ops without panicking. + let mut state = GameState::default(); + run_end_of_turn_phases(&mut state); + assert!(state.worldsim_state_json.is_empty()); + } +}