feat(@projects/@magic-civilization): 🦌 p3-27 — living biosphere ticks in the headless turn
The ecology engine ticked only in the live game (GdFaunaEcology); the headless turn had no living fauna. Now apply_end_turn drives mc_ecology::EcologyEngine every turn: - process_ecology_phase (mc-player-api/ecology_phase.rs): build engine + species library from boot-supplied fauna JSONs, restore populations from the persisted continuation state (or seed_initial genesis on the first tick), process_step (mutates grid: emergence, population dynamics, flora succession), then re-serialize the continuation state. Deterministic from map_seed. - Lives in mc-player-api, NOT mc-turn::step — mc-turn → mc-ecology → mc-mapgen → mc-turn is a dependency cycle; the orchestration layer (apply_end_turn, right after step) avoids it. - GameState += ecology_species_json (#[serde(skip)] boot-loaded fauna JSONs) + worldsim_state_json (#[serde(default)] opaque continuation state — persists across the fresh-per-turn processor AND save/load, matching the live worldsim_state save payload) + load_ecology_species_json. Reuses the existing engine save/restore (continuation_state / restore_continuation_state) — no new ecology logic, just headless wiring. No-op until the species library is boot-loaded (the FFI + harness wiring is the next slice). mc-player-api 138/0 (+3 ecology tests), mc-state green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b8d2a1f20e
commit
b984143e60
8 changed files with 365 additions and 0 deletions
|
|
@ -10,6 +10,7 @@ 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" }
|
||||
|
|
|
|||
|
|
@ -422,6 +422,13 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result<Vec<Event>,
|
|||
processor.victory_config = Some(vc);
|
||||
}
|
||||
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:
|
||||
|
|
|
|||
166
src/simulator/crates/mc-player-api/src/ecology_phase.rs
Normal file
166
src/simulator/crates/mc-player-api/src/ecology_phase.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
//! p3-27 biosphere — per-turn ecology tick for the headless turn.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! It reproduces the live construction exactly: a fresh `EcologyEngine` (default
|
||||
//! config), its species library loaded from the boot-supplied fauna JSONs,
|
||||
//! populations restored from the persisted continuation state (or seeded once on
|
||||
//! the first tick), one `process_step`, then the continuation state is
|
||||
//! re-serialized back onto `GameState`. `process_step` mutates the grid in place
|
||||
//! (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.
|
||||
|
||||
use mc_ecology::engine::EcologyEngine;
|
||||
use mc_ecology::species::load_species_library;
|
||||
use mc_state::game_state::GameState;
|
||||
|
||||
/// Tick the ecology one step for the whole map. No-op when no species library is
|
||||
/// loaded (pure bench / ecology not booted) or there is no grid yet.
|
||||
pub fn process_ecology_phase(state: &mut GameState) {
|
||||
if state.ecology_species_json.is_empty() || state.grid.is_none() {
|
||||
return;
|
||||
}
|
||||
let seed = state.map_seed;
|
||||
|
||||
// Build the engine's species library from the boot-supplied fauna JSONs.
|
||||
let library = {
|
||||
let refs: Vec<&str> = state
|
||||
.ecology_species_json
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
match load_species_library(&refs) {
|
||||
Ok(lib) => lib,
|
||||
// Malformed species data must not abort the turn; skip the tick.
|
||||
Err(_) => return,
|
||||
}
|
||||
};
|
||||
let mut engine = EcologyEngine::new();
|
||||
engine.species_library = library;
|
||||
|
||||
// Restore prior populations, or mark this as the genesis tick.
|
||||
let genesis = state.worldsim_state_json.is_empty();
|
||||
if !genesis {
|
||||
if let Ok(cont) = serde_json::from_str(&state.worldsim_state_json) {
|
||||
engine.restore_continuation_state(cont);
|
||||
}
|
||||
}
|
||||
|
||||
let grid = state.grid.as_mut().expect("grid present (checked above)");
|
||||
if genesis {
|
||||
// Seed the base trophic level on habitable tiles — emergence alone is
|
||||
// too slow to populate a fresh world (mirrors the live first-tick seed).
|
||||
engine.seed_initial(grid, seed);
|
||||
}
|
||||
let _transitions = engine.process_step(grid, 1.0, seed);
|
||||
|
||||
// Persist the evolved populations + registry for the next turn / save.
|
||||
if let Ok(s) = serde_json::to_string(&engine.continuation_state()) {
|
||||
state.worldsim_state_json = s;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mc_core::grid::GridState;
|
||||
|
||||
/// Walk up from this crate's dir until the species resource dir is found —
|
||||
/// robust to where the crate sits in the workspace tree.
|
||||
fn species_dir() -> std::path::PathBuf {
|
||||
let mut p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf();
|
||||
loop {
|
||||
let cand = p.join("public/resources/ecology/fauna/species");
|
||||
if cand.is_dir() {
|
||||
return cand;
|
||||
}
|
||||
assert!(p.pop(), "species dir not found walking up from CARGO_MANIFEST_DIR");
|
||||
}
|
||||
}
|
||||
|
||||
/// Read every fauna species file, as the boot harness would.
|
||||
fn load_species_jsons() -> Vec<String> {
|
||||
let dir = species_dir();
|
||||
let mut out = Vec::new();
|
||||
for entry in std::fs::read_dir(&dir).expect("species dir exists") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
out.push(std::fs::read_to_string(&path).expect("read species file"));
|
||||
}
|
||||
}
|
||||
assert!(!out.is_empty(), "expected species files");
|
||||
out
|
||||
}
|
||||
|
||||
fn build_state(species: &[String]) -> GameState {
|
||||
let mut state = GameState::default();
|
||||
state.turn = 1;
|
||||
state.map_seed = 4242;
|
||||
let mut grid = GridState::new(12, 12);
|
||||
for t in &mut grid.tiles {
|
||||
t.biome_label_id = "temperate_grassland".into();
|
||||
t.temperature = 0.55;
|
||||
t.moisture = 0.6;
|
||||
t.quality = 3;
|
||||
}
|
||||
state.grid = Some(grid);
|
||||
state.ecology_species_json = species.to_vec();
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecology_phase_noops_without_species() {
|
||||
let mut state = GameState::default();
|
||||
state.grid = Some(GridState::new(4, 4));
|
||||
process_ecology_phase(&mut state); // no species → no panic, no state
|
||||
assert!(state.worldsim_state_json.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecology_phase_seeds_world_on_first_tick_then_persists() {
|
||||
let species = load_species_jsons();
|
||||
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(),
|
||||
"first tick seeds + persists the biosphere"
|
||||
);
|
||||
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!(
|
||||
after_first, state.worldsim_state_json,
|
||||
"the tick advances the persisted ecology state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecology_phase_is_deterministic_from_seed() {
|
||||
let species = load_species_jsons();
|
||||
let mut a = build_state(&species);
|
||||
let mut b = build_state(&species);
|
||||
process_ecology_phase(&mut a);
|
||||
process_ecology_phase(&mut b);
|
||||
assert_eq!(
|
||||
a.worldsim_state_json, b.worldsim_state_json,
|
||||
"same seed + inputs → identical ecology continuation state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ 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;
|
||||
|
|
|
|||
|
|
@ -418,6 +418,20 @@ pub struct GameState {
|
|||
/// boot-loaded like the other catalogs; empty (Default) → no events fire (safe no-op).
|
||||
#[serde(skip)]
|
||||
pub events_config: BTreeMap<String, serde_json::Value>,
|
||||
/// p3-27 biosphere: per-file fauna species JSON contents (boot-loaded from
|
||||
/// `public/resources/ecology/fauna/species/*.json`). `#[serde(skip)]` static
|
||||
/// content, re-supplied at boot like the other catalogs; empty → ecology
|
||||
/// phase is a safe no-op. `mc-turn` builds the `EcologyEngine` species library
|
||||
/// from these each turn (so mc-state needs no mc-ecology dependency).
|
||||
#[serde(skip)]
|
||||
pub ecology_species_json: Vec<String>,
|
||||
/// p3-27 biosphere: opaque ecology continuation state (per-tile fauna
|
||||
/// populations + species registry + emergence tick count) serialized as JSON.
|
||||
/// `#[serde(default)]` so it persists across the fresh-per-turn TurnProcessor
|
||||
/// AND across save/load (this is the `worldsim_state` save payload). Empty
|
||||
/// until the first ecology tick seeds the world.
|
||||
#[serde(default)]
|
||||
pub worldsim_state_json: String,
|
||||
/// p2-71: tactical-AI view of the producible-unit catalog. Mirrors
|
||||
/// `TacticalState::unit_catalog` and is populated once at harness boot
|
||||
/// by `GdPlayerApi::set_units_catalog_json` (or directly in Rust tests).
|
||||
|
|
@ -704,6 +718,20 @@ impl GameState {
|
|||
}
|
||||
}
|
||||
|
||||
/// p3-27: load the fauna species library JSONs from a JSON array of file
|
||||
/// contents (`["{species…}", …]`). Returns the count loaded; malformed input
|
||||
/// leaves the library empty (ecology phase no-ops). Called once at boot.
|
||||
pub fn load_ecology_species_json(&mut self, json_array: &str) -> usize {
|
||||
match serde_json::from_str::<Vec<String>>(json_array) {
|
||||
Ok(list) => {
|
||||
let n = list.len();
|
||||
self.ecology_species_json = list;
|
||||
n
|
||||
}
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// p2-65 Phase7 test helper: construct a GameState whose combat_balance
|
||||
/// (and future SimConfig fields) are pre-populated without touching the
|
||||
/// global RwLock singleton. Callers that need isolated config for
|
||||
|
|
|
|||
155
src/simulator/crates/mc-turn/src/ecology_phase.rs
Normal file
155
src/simulator/crates/mc-turn/src/ecology_phase.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
//! p3-27 biosphere — per-turn ecology tick in the headless turn.
|
||||
//!
|
||||
//! The live game ticks `mc_ecology::EcologyEngine` every turn through the
|
||||
//! `GdFaunaEcology` GDExtension node (`process_step` + continuation-JSON
|
||||
//! save/restore). The headless `TurnProcessor` never did — so the simulated
|
||||
//! world had no living fauna for the economy or natural events to interact with.
|
||||
//!
|
||||
//! This phase reproduces the live construction exactly: a fresh `EcologyEngine`
|
||||
//! (default config), its species library loaded from the boot-supplied fauna
|
||||
//! JSONs, populations restored from the persisted continuation state (or seeded
|
||||
//! once on the first tick), one `process_step`, then the continuation state is
|
||||
//! re-serialized back onto `GameState`. Because `process_step` mutates the grid
|
||||
//! in place (emergence, population dynamics, flora succession) 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.
|
||||
|
||||
use mc_ecology::engine::EcologyEngine;
|
||||
use mc_ecology::species::load_species_library;
|
||||
use mc_state::game_state::GameState;
|
||||
|
||||
/// Tick the ecology one step for the whole map. No-op when no species library is
|
||||
/// loaded (pure bench / ecology not booted) or there is no grid yet.
|
||||
pub fn process_ecology_phase(state: &mut GameState) {
|
||||
if state.ecology_species_json.is_empty() || state.grid.is_none() {
|
||||
return;
|
||||
}
|
||||
let seed = state.map_seed;
|
||||
|
||||
// Build the engine's species library from the boot-supplied fauna JSONs.
|
||||
let library = {
|
||||
let refs: Vec<&str> = state
|
||||
.ecology_species_json
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
match load_species_library(&refs) {
|
||||
Ok(lib) => lib,
|
||||
// Malformed species data must not abort the turn; skip the tick.
|
||||
Err(_) => return,
|
||||
}
|
||||
};
|
||||
let mut engine = EcologyEngine::new();
|
||||
engine.species_library = library;
|
||||
|
||||
// Restore prior populations, or mark this as the genesis tick.
|
||||
let genesis = state.worldsim_state_json.is_empty();
|
||||
if !genesis {
|
||||
if let Ok(cont) = serde_json::from_str(&state.worldsim_state_json) {
|
||||
engine.restore_continuation_state(cont);
|
||||
}
|
||||
}
|
||||
|
||||
let grid = state.grid.as_mut().expect("grid present (checked above)");
|
||||
if genesis {
|
||||
// Seed the base trophic level on habitable tiles — emergence alone is
|
||||
// too slow to populate a fresh world (mirrors the live first-tick seed).
|
||||
engine.seed_initial(grid, seed);
|
||||
}
|
||||
let _transitions = engine.process_step(grid, 1.0, seed);
|
||||
|
||||
// Persist the evolved populations + registry for the next turn / save.
|
||||
if let Ok(s) = serde_json::to_string(&engine.continuation_state()) {
|
||||
state.worldsim_state_json = s;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mc_core::grid::GridState;
|
||||
|
||||
fn workspace_root() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(3)
|
||||
.expect("workspace root")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
/// Read every fauna species file, as the boot harness would.
|
||||
fn load_species_jsons() -> Vec<String> {
|
||||
let dir = workspace_root().join("public/resources/ecology/fauna/species");
|
||||
let mut out = Vec::new();
|
||||
for entry in std::fs::read_dir(&dir).expect("species dir exists") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
out.push(std::fs::read_to_string(&path).expect("read species file"));
|
||||
}
|
||||
}
|
||||
assert!(!out.is_empty(), "expected species files");
|
||||
out
|
||||
}
|
||||
|
||||
fn build_state(species: &[String]) -> GameState {
|
||||
let mut state = GameState::default();
|
||||
state.turn = 1;
|
||||
state.map_seed = 4242;
|
||||
let mut grid = GridState::new(12, 12);
|
||||
for t in &mut grid.tiles {
|
||||
t.biome_label_id = "temperate_grassland".into();
|
||||
t.temperature = 0.55;
|
||||
t.moisture = 0.6;
|
||||
t.quality = 3;
|
||||
}
|
||||
state.grid = Some(grid);
|
||||
state.ecology_species_json = species.to_vec();
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecology_phase_noops_without_species() {
|
||||
let mut state = GameState::default();
|
||||
state.grid = Some(GridState::new(4, 4));
|
||||
process_ecology_phase(&mut state); // no species → no panic, no state
|
||||
assert!(state.worldsim_state_json.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecology_phase_seeds_world_on_first_tick_then_persists() {
|
||||
let species = load_species_jsons();
|
||||
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(),
|
||||
"first tick seeds + persists the biosphere"
|
||||
);
|
||||
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!(
|
||||
after_first, state.worldsim_state_json,
|
||||
"the tick advances the persisted ecology state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecology_phase_is_deterministic_from_seed() {
|
||||
let species = load_species_jsons();
|
||||
let mut a = build_state(&species);
|
||||
let mut b = build_state(&species);
|
||||
process_ecology_phase(&mut a);
|
||||
process_ecology_phase(&mut b);
|
||||
assert_eq!(
|
||||
a.worldsim_state_json, b.worldsim_state_json,
|
||||
"same seed + inputs → identical ecology continuation state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,8 @@ 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;
|
||||
#[cfg(feature = "gpu")]
|
||||
pub mod gpu;
|
||||
|
||||
|
|
|
|||
|
|
@ -503,6 +503,11 @@ impl TurnProcessor {
|
|||
// bench (`ClimatePhysics::new("{}","[]","{}")`).
|
||||
self.process_climate_phase(state);
|
||||
|
||||
// p3-27: tick the living biosphere (fauna populations + flora succession)
|
||||
// after climate so it reacts to the freshly-updated temperature/moisture.
|
||||
// No-op until the ecology species library is boot-loaded.
|
||||
crate::ecology_phase::process_ecology_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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue