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:
parent
6530233048
commit
d14ba0b006
5 changed files with 129 additions and 163 deletions
1
src/simulator/Cargo.lock
generated
1
src/simulator/Cargo.lock
generated
|
|
@ -1913,6 +1913,7 @@ dependencies = [
|
|||
"mc-comms",
|
||||
"mc-core",
|
||||
"mc-culture",
|
||||
"mc-ecology",
|
||||
"mc-items",
|
||||
"mc-replay",
|
||||
"mc-score",
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue