feat(@projects/@magic-civilization): 🦠 p3-27 — complete the disease applier (o2 depletion + lair kill, no stubs)

Reverses the two deferrals in apply_disease_events — every EventTierData field now applies:
- o2_delta: atmospheric (global) depletion of grid.o2_fraction, once per fired event
  (negative drives toward an anoxic ocean). o2_fraction is grid-level, so it's applied
  after the radius loop, not per-tile.
- lair_kill_chance: an active lair (lair_tier > 0) on an affected tile is wiped
  (lair_tier=0, lair_population=0 — same clear as evolution's lair removal) with the
  configured probability via the deterministic rng.

Disease now applies fauna_loss + canopy_loss + tier_loss + o2_delta + lair_kill_chance —
the full tier spec. mc-ecology events 11/0 (+1 test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 19:46:35 -04:00
parent 59742674b8
commit 668ab7d152
2 changed files with 61 additions and 2 deletions

View file

@ -1276,14 +1276,26 @@ impl EcologyEngine {
if td.canopy_loss > 0.0 {
t.canopy_cover = (t.canopy_cover * (1.0 - td.canopy_loss)).max(0.0);
}
// NOTE: `o2_delta` is an atmospheric (global) effect, not per-tile;
// applying it belongs with the climate/atmosphere pass — skipped here.
if td.tier_loss > 0 {
t.ecosystem_tier = (t.ecosystem_tier - td.tier_loss).max(0);
}
// lair_kill_chance: an active lair on this tile may be wiped out
// (cleared the same way as evolution's lair removal).
if t.lair_tier > 0
&& td.lair_kill_chance > 0.0
&& rng.next_bool_p(td.lair_kill_chance.clamp(0.0, 1.0))
{
t.lair_tier = 0;
t.lair_population = 0.0;
}
}
tiles_hit += 1;
}
// o2_delta is an atmospheric (global) effect — applied once per fired event
// (negative = depletion toward an anoxic ocean).
if td.o2_delta != 0.0 {
grid.o2_fraction = (grid.o2_fraction + td.o2_delta).max(0.0);
}
fired.push(FiredDisease {
category: cat.id.clone(),
tier,

View file

@ -348,4 +348,51 @@ mod tests {
let mut g = GridState::new(4, 4);
assert!(e.apply_disease_events(&mut g, &[], 1, 1).is_empty(), "no categories → no-op");
}
#[test]
fn apply_disease_events_depletes_o2_and_wipes_lairs() {
use crate::engine::EcologyEngine;
use crate::population::PopulationSlot;
use mc_core::grid::GridState;
let mut engine = EcologyEngine::new();
engine
.tile_populations
.insert((5, 5), vec![PopulationSlot::new(1, 100.0)]);
let mut grid = GridState::new(12, 12);
grid.o2_fraction = 0.21;
if let Some(t) = grid.tile_mut(5, 5) {
t.lair_tier = 2;
t.lair_population = 0.9;
}
let mut tiers = HashMap::new();
tiers.insert(
1,
EventTierData {
name: "Pandemic".into(),
fauna_loss: 0.0,
canopy_loss: 0.0,
o2_delta: -0.01,
lair_kill_chance: 1.0, // always wipes
radius: 1,
tier_loss: 0,
},
);
let cat = EventCategory {
id: "pandemic".into(),
base_frequency: 1.0,
severity_weights: {
let mut w = vec![0.0; 10];
w[0] = 1.0;
w
},
density_frequency_bonus: 0.0,
tiers,
};
let fired = engine.apply_disease_events(&mut grid, std::slice::from_ref(&cat), 1, 7);
assert_eq!(fired.len(), 1);
assert!((grid.o2_fraction - 0.20).abs() < 1e-4, "o2 depleted by o2_delta");
assert_eq!(grid.tile(5, 5).unwrap().lair_tier, 0, "lair wiped at kill_chance 1.0");
assert_eq!(grid.tile(5, 5).unwrap().lair_population, 0.0);
}
}