diff --git a/src/simulator/crates/mc-player-api/Cargo.toml b/src/simulator/crates/mc-player-api/Cargo.toml index 109ff223..f6075556 100644 --- a/src/simulator/crates/mc-player-api/Cargo.toml +++ b/src/simulator/crates/mc-player-api/Cargo.toml @@ -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" } diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index a0b54c00..d2461e22 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -422,6 +422,13 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result, 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: diff --git a/src/simulator/crates/mc-player-api/src/ecology_phase.rs b/src/simulator/crates/mc-player-api/src/ecology_phase.rs new file mode 100644 index 00000000..55e01ae5 --- /dev/null +++ b/src/simulator/crates/mc-player-api/src/ecology_phase.rs @@ -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 { + 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" + ); + } +} diff --git a/src/simulator/crates/mc-player-api/src/lib.rs b/src/simulator/crates/mc-player-api/src/lib.rs index 43557d12..99075b56 100644 --- a/src/simulator/crates/mc-player-api/src/lib.rs +++ b/src/simulator/crates/mc-player-api/src/lib.rs @@ -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; diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index 28ed4054..fc5b3253 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -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, + /// 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, + /// 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::>(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 diff --git a/src/simulator/crates/mc-turn/src/ecology_phase.rs b/src/simulator/crates/mc-turn/src/ecology_phase.rs new file mode 100644 index 00000000..be8095cc --- /dev/null +++ b/src/simulator/crates/mc-turn/src/ecology_phase.rs @@ -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 { + 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" + ); + } +} diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index 35495b19..00dcf0ba 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -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; diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index b69f6a34..b0466f45 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -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);