diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index 7dd32c99..084a81ba 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -1913,6 +1913,7 @@ dependencies = [ "mc-comms", "mc-core", "mc-culture", + "mc-ecology", "mc-items", "mc-replay", "mc-score", diff --git a/src/simulator/crates/mc-climate/src/events.rs b/src/simulator/crates/mc-climate/src/events.rs index 860794c5..9e925890 100644 --- a/src/simulator/crates/mc-climate/src/events.rs +++ b/src/simulator/crates/mc-climate/src/events.rs @@ -209,7 +209,8 @@ pub fn process_events( "seismic" => dispatch_seismic(grid, cfg, tier, turn_seed, channel), "impact" => dispatch_impact(grid, cfg, tier, turn_seed, channel), "tsunami" => dispatch_tsunami(grid, cfg, tier, turn_seed, channel), - // TODO(p3-26 gap 2): plague/pandemic/marine/solar/glacial. + "plague" => dispatch_plague(grid, cfg, tier, turn_seed, channel), + // TODO(p3-26 gap 2): pandemic (fauna+city), marine, solar/glacial. _ => None, }; if let Some(ev) = ev { @@ -723,6 +724,132 @@ fn dispatch_tsunami( }) } +/// Apply an ecological plague/blight: on `target_terrain` tiles within a hex disk, drop +/// quality by `quality_loss` (min 1) and downgrade the biome per `terrain_downgrade` +/// (e.g. `enchanted_forest → forest`). Returns tiles affected. Mirrors GDScript +/// `process_plague` (the terrain side; fauna disease is the ecology engine's domain). +pub fn apply_plague( + grid: &mut mc_core::grid::GridState, + center: (i32, i32), + radius: i32, + quality_loss: i32, + target_terrain: &[String], + terrain_downgrade: &std::collections::BTreeMap, +) -> i32 { + use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial}; + let (cq, cr) = offset_to_axial(center.0, center.1); + let mut affected = 0; + for (q, r) in hex_spiral(cq, cr, radius) { + let (col, row) = axial_to_offset(q, r); + if let Some(t) = grid.tile_mut(col, row) { + let biome = t.biome_label_id.clone(); + if target_terrain.iter().any(|b| b == &biome) { + t.quality = (t.quality - quality_loss).max(1); + if let Some(down) = terrain_downgrade.get(&biome) { + t.biome_label_id = down.clone(); + } + affected += 1; + } + } + } + affected +} + +/// Resolve the plague tier config + pick a center on the target terrain, then blight it. +fn dispatch_plague( + grid: &mut mc_core::grid::GridState, + cfg: &EventCategoryConfig, + tier: usize, + turn_seed: f64, + channel: f64, +) -> Option { + let tier_cfg = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?; + let radius = tier_cfg.get("radius").and_then(|v| v.as_i64()).unwrap_or(2) as i32; + // GDScript reads `tier_loss` (quality drop); accept `quality_loss` as an alias. + let quality_loss = tier_cfg + .get("tier_loss") + .or_else(|| tier_cfg.get("quality_loss")) + .and_then(|v| v.as_i64()) + .unwrap_or(1) as i32; + let target_terrain: Vec = tier_cfg + .get("target_terrain") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()) + .unwrap_or_else(|| { + ["grassland", "plains", "forest", "jungle", "enchanted_forest"] + .iter() + .map(|s| s.to_string()) + .collect() + }); + let terrain_downgrade: std::collections::BTreeMap = tier_cfg + .get("terrain_downgrade") + .and_then(|v| v.as_object()) + .map(|o| { + o.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + let pick_targets = target_terrain.clone(); + let center = + pick_matching_tile(grid, channel, turn_seed, move |b| pick_targets.iter().any(|t| t == b))?; + let affected = + apply_plague(grid, center, radius, quality_loss, &target_terrain, &terrain_downgrade); + if affected == 0 { + return None; + } + Some(FiredEvent { + category: "plague".to_string(), + tier, + center, + affected, + }) +} + +#[cfg(test)] +mod plague_tests { + use super::*; + use mc_core::grid::GridState; + + #[test] + fn apply_plague_drops_quality_and_downgrades_target_terrain() { + let mut grid = GridState::new(10, 10); + for t in &mut grid.tiles { + t.biome_label_id = "enchanted_forest".into(); + t.quality = 4; + } + // a non-target tile in radius must be untouched. + if let Some(t) = grid.tile_mut(5, 4) { + t.biome_label_id = "desert".into(); + t.quality = 4; + } + let targets: Vec = vec!["enchanted_forest".into(), "forest".into()]; + let mut downgrade = std::collections::BTreeMap::new(); + downgrade.insert("enchanted_forest".to_string(), "forest".to_string()); + + let affected = apply_plague(&mut grid, (5, 5), 2, 2, &targets, &downgrade); + assert!(affected >= 1); + let c = grid.tile(5, 5).unwrap(); + assert_eq!(c.biome_label_id, "forest", "enchanted_forest downgraded"); + assert_eq!(c.quality, 2, "quality dropped by tier_loss"); + assert_eq!(grid.tile(5, 4).unwrap().biome_label_id, "desert", "non-target untouched"); + assert_eq!(grid.tile(5, 4).unwrap().quality, 4); + } + + #[test] + fn apply_plague_quality_floored_at_one() { + let mut grid = GridState::new(6, 6); + for t in &mut grid.tiles { + t.biome_label_id = "grassland".into(); + t.quality = 1; + } + let targets: Vec = vec!["grassland".into()]; + let downgrade = std::collections::BTreeMap::new(); + apply_plague(&mut grid, (3, 3), 1, 5, &targets, &downgrade); + assert_eq!(grid.tile(3, 3).unwrap().quality, 1, "quality floored at 1"); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/simulator/crates/mc-turn/src/ecology_phase.rs b/src/simulator/crates/mc-turn/src/ecology_phase.rs deleted file mode 100644 index be8095cc..00000000 --- a/src/simulator/crates/mc-turn/src/ecology_phase.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! 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 00dcf0ba..35495b19 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -47,8 +47,6 @@ 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 b0466f45..b69f6a34 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -503,11 +503,6 @@ 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);