refactor(@projects/@magic-civilization): 🧩 unify turn phases — ecology into mc-turn + end-of-turn phase registry (OCP)

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 18:16:12 -04:00
parent 5ee312e452
commit af41ea10a9
9 changed files with 69 additions and 25 deletions

View file

@ -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",

View file

@ -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" }

View file

@ -423,12 +423,6 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result<Vec<Event>,
}
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:

View file

@ -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;

View file

@ -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" }

View file

@ -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!(

View file

@ -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;

View file

@ -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

View file

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