feat(@projects/@magic-civilization): 🦠 p3-26 B8 — plague (terrain blight) event category

Seventh event category, the terrain side of plague (GDScript process_plague): on
target_terrain tiles in a hex disk, drop quality by tier_loss (min 1) + downgrade the biome
per terrain_downgrade (e.g. enchanted_forest → forest). apply_plague + dispatch_plague +
match arm + tests. mc-climate 63/0. 7/12 categories live.

Note: the FAUNA side of plague/pandemic (population mortality) is NOT this — that belongs to
mc_ecology's disease system, which is currently config-only (load_event_categories + structs,
no apply fn, only caller is disease_validate bin). Recorded as a real gap in p3-27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 17:50:36 -04:00
parent 6530233048
commit d14ba0b006
5 changed files with 129 additions and 163 deletions

View file

@ -1913,6 +1913,7 @@ dependencies = [
"mc-comms",
"mc-core",
"mc-culture",
"mc-ecology",
"mc-items",
"mc-replay",
"mc-score",

View file

@ -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<String, String>,
) -> 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<FiredEvent> {
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<String> = 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<String, String> = 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<String> = 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<String> = 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::*;

View file

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

View file

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

View file

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