feat(@projects/@magic-civilization): add plague adjacency spread logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-07 07:29:03 -07:00
parent f37a734600
commit a424d64e6e
2 changed files with 51 additions and 48 deletions

View file

@ -12,8 +12,10 @@ evidence:
- "src/simulator/crates/mc-ecology/src/biological.rs:280-410 (3 acceptance tests: test_plague_spreads_via_density, test_bloom_requires_optimal_window, test_migration_pulse_traverses_corridor)"
- "src/simulator/crates/mc-ecology/src/lib.rs:36-43 (pub mod biological + re-exports)"
- "public/games/age-of-dwarves/data/balance/biological_events.json (tunable thresholds, EVENT_FREQUENCY_SPEC plague target 0.0025/turn)"
- "cargo test -p mc-ecology --lib: 317 passed (313 pre-existing + 4 new)"
- "cargo check --workspace: clean (only pre-existing warnings)"
- "cargo test -p mc-ecology --lib: 319 passed (313 pre-existing + 6 new)"
- "src/simulator/crates/mc-ecology/src/biological.rs:327-357 (plague adjacency spread second pass, HashSet dedup, channel 14)"
- "src/simulator/crates/mc-ecology/src/biological.rs:270-324 (migration chain walk, source_pop held constant, max_hops cap)"
- "public/games/age-of-dwarves/data/balance/biological_events.json (plague.spread_factor, plague.spread_severity_scale, migration.max_hops added)"
blocked_by: []
---
## Context
@ -24,7 +26,7 @@ blocked_by: []
- ✓ `mc-ecology::derive_biological_events(grid, thresholds, turn, seed) -> Vec<BiologicalEvent>` returns `Plague { col,row,severity }`, `Bloom { col,row,intensity }`, `MigrationPulse { from_col,from_row,to_col,to_row,magnitude }`. Signature differs from the spec (`turn,world,players`): tile-only signals per Out-of-scope §; `players` deferred. `src/simulator/crates/mc-ecology/src/biological.rs:175-260`.
- ✓ Typed `BiologicalEvent` enum lives in `mc-ecology::biological` (re-exported from crate root) rather than `mc-core::events` — sibling crates `mc-climate::weather::WeatherEvent` follow the same in-domain pattern, no `mc-core::events` module exists. `src/simulator/crates/mc-ecology/src/biological.rs:42-65` + `src/simulator/crates/mc-ecology/src/lib.rs:36-43`.
- ❌ Plague per-city pop-density × inverse-medical-buildings + adjacent-city spread. **Tile proxy implemented**: `civilization_presence` (pop-density proxy) × low `quality` (sanitation proxy). Per-city spread is in follow-ups; `Out of scope §1` defers cross-realm + remediation tuning. `src/simulator/crates/mc-ecology/src/biological.rs:184-205`.
- ✓ Plague per-city pop-density × inverse-medical-buildings + adjacent-city spread. **Tile proxy**: `civilization_presence` × low `quality`. **Adjacency spread second pass** (lines 327-357): for each primary plague source, each of 6 axial neighbours receives a spread Plague event if `civilization_presence ≥ plague_civ_min * plague_spread_factor` and `quality ≤ plague_quality_max`; spread severity = source × `plague_spread_severity_scale`; deduped via HashSet so primary-infected tiles are never double-counted. `test_plague_spreads_to_adjacent_cities` covers the cluster and severity-attenuation assertions.
- ❌ Bloom "N consecutive turns" optimal window. **Single-turn proxy implemented**: tile must satisfy `mean_temp ∈ [bloom_temp_min, bloom_temp_max]`, `mean_precip ≥ bloom_precip_min`, plus flora-density gates (`canopy_cover` + `undergrowth`). Streak counter is a follow-up (needs new TileState field). `src/simulator/crates/mc-ecology/src/biological.rs:207-230`.
- ❌ Migration pulse along precomputed corridor with per-tile fauna-density boost. **Single-hop proxy implemented**: emit one `from→to` event when source `lair_population ≥ source_min`, neighbour `≤ neighbour_max`, differential `≥ differential_min`. Multi-turn corridor walk + density mutation are follow-ups. `src/simulator/crates/mc-ecology/src/biological.rs:232-265`.
- ✓ `cargo test -p mc-ecology` green: `test_plague_spreads_via_density`, `test_bloom_requires_optimal_window`, `test_migration_pulse_traverses_corridor` all pass; suite total 317 (313 pre-existing + 4 new incl. `thresholds_load_from_spec_json`). Run from `src/simulator/`: `cargo test -p mc-ecology --lib`. `src/simulator/crates/mc-ecology/src/biological.rs:280-410`.

View file

@ -38,6 +38,7 @@ use mc_core::algorithms::hex::{offset_neighbor_in_dir, AXIAL_DIRECTIONS};
use mc_core::grid::GridState;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;
/// Typed biological event variants. Each variant carries only the fields its
/// caller needs — no opaque payload bag.
@ -221,7 +222,9 @@ pub fn derive_biological_events(
seed: u64,
) -> Vec<BiologicalEvent> {
// Collect plague-source (col,row,severity) for the spread second-pass.
// Also track primary plague coords so spread does not double-emit.
let mut plague_sources: Vec<(i32, i32, f32)> = Vec::new();
let mut primary_plague_coords: HashSet<(i32, i32)> = HashSet::new();
let mut events: Vec<BiologicalEvent> = Vec::new();
for tile in &grid.tiles {
@ -238,6 +241,7 @@ pub fn derive_biological_events(
* 2.0)
.clamp(0.1, 1.0);
plague_sources.push((tile.col, tile.row, severity));
primary_plague_coords.insert((tile.col, tile.row));
events.push(BiologicalEvent::Plague {
col: tile.col,
row: tile.row,
@ -279,7 +283,11 @@ pub fn derive_biological_events(
{
let mut cur_col = tile.col;
let mut cur_row = tile.row;
let mut cur_pop = tile.lair_population;
// source_pop is held constant across hops so each hop's differential
// is measured from the original surge density, not the (depleted)
// intermediate node. This lets a wave propagate through a corridor of
// depleted tiles each of which individually satisfies neighbour_max.
let source_pop = tile.lair_population;
for _hop in 0..thresholds.migration_max_hops {
// Walk AXIAL_DIRECTIONS in fixed index order; pick the first
@ -293,7 +301,8 @@ pub fn derive_biological_events(
};
let Some(idx) = tile_idx(grid, ncol, nrow) else { continue };
let neighbour = &grid.tiles[idx];
let differential = cur_pop - neighbour.lair_population;
// Differential always relative to the original source density.
let differential = source_pop - neighbour.lair_population;
if neighbour.lair_population <= thresholds.migration_neighbour_max
&& differential >= thresholds.migration_differential_min
{
@ -305,10 +314,9 @@ pub fn derive_biological_events(
to_row: nrow,
magnitude,
});
// Advance chain position.
// Advance chain position; source_pop stays the same.
cur_col = ncol;
cur_row = nrow;
cur_pop = neighbour.lair_population;
hopped = true;
break;
}
@ -333,6 +341,10 @@ pub fn derive_biological_events(
continue;
};
let Some(idx) = tile_idx(grid, ncol, nrow) else { continue };
// Skip tiles that already have a primary plague event this turn.
if primary_plague_coords.contains(&(ncol, nrow)) {
continue;
}
let neighbour = &grid.tiles[idx];
if neighbour.civilization_presence >= spread_civ_floor
&& neighbour.quality <= thresholds.plague_quality_max
@ -444,22 +456,22 @@ mod tests {
#[test]
fn test_migration_pulse_traverses_corridor() {
// Source tile (1,1) packed; neighbour (2,1) depleted. Force trigger
// and expect a pulse from source → neighbour. "Corridor traversal" is
// modelled per-turn as one source→neighbour hop (full corridor walk
// requires multi-turn integration which is the player-tick concern,
// not derivation).
// Source tile (1,1) packed; all neighbours depleted. Force trigger with
// max_hops=1 to test the single-hop base case (multi-hop is tested in
// test_migration_multi_hop_corridor). With max_hops=1 exactly one pulse
// fires, originating at (1,1).
let mut g = empty_grid(4, 4);
// Default everything clear.
for tile in &mut g.tiles {
tile.lair_population = 0.0;
}
// Source at (1,1) — packed. Neighbour at (2,1) — depleted (default 0).
// Source at (1,1) — packed. All neighbours depleted (default 0).
let src_idx = tile_idx(&g, 1, 1).unwrap();
g.tiles[src_idx].lair_population = 0.95;
let mut t = BiologicalThresholds::default();
t.migration_trigger_chance = 1.0;
t.migration_max_hops = 1; // single-hop only for this base case
let events = derive_biological_events(&g, &t, 1, 42);
// Exactly one pulse, originating at (1,1).
@ -593,9 +605,13 @@ mod tests {
#[test]
fn test_migration_multi_hop_corridor() {
// Laddered lair_population: (0,0)=0.95, (1,0)=0.15, (2,0)=0.05.
// Differential 0→1 = 0.80 (≥ 0.40); after hopping cur_pop=0.15.
// Differential 1→2 = 0.10 (< 0.40) — second hop should NOT fire.
// Corridor of depleted tiles (all ≤ migration_neighbour_max=0.20):
// (0,0) = 0.95 source (qualifies as trigger: ≥ 0.60)
// (1,0) = 0.10 (depleted; differential vs source = 0.85 ≥ 0.40)
// (2,0) = 0.05 (depleted; differential vs source = 0.90 ≥ 0.40)
//
// source_pop is held constant at 0.95 across the chain walk, so each
// hop checks differential = 0.95 - neighbour_pop. Both hops qualify.
let mut g = empty_grid(5, 3);
for tile in &mut g.tiles {
tile.lair_population = 0.0;
@ -604,8 +620,8 @@ mod tests {
let idx1 = tile_idx(&g, 1, 0).unwrap();
let idx2 = tile_idx(&g, 2, 0).unwrap();
g.tiles[idx0].lair_population = 0.95;
g.tiles[idx1].lair_population = 0.15;
g.tiles[idx2].lair_population = 0.05;
g.tiles[idx1].lair_population = 0.10; // ≤ neighbour_max=0.20 ✓
g.tiles[idx2].lair_population = 0.05; // ≤ neighbour_max=0.20 ✓
let mut t = BiologicalThresholds::default();
t.migration_trigger_chance = 1.0;
@ -617,44 +633,29 @@ mod tests {
.filter(|e| matches!(e, BiologicalEvent::MigrationPulse { .. }))
.collect();
// Only one hop fires (second differential 0.10 < 0.40).
assert_eq!(pulses.len(), 1, "expected 1 hop; got {}", pulses.len());
// Two hops fire: (0,0)→(1,0) and (1,0)→(2,0).
assert!(pulses.len() >= 2, "expected ≥2 hops; got {}", pulses.len());
assert!(
matches!(pulses[0], BiologicalEvent::MigrationPulse { from_col: 0, from_row: 0, .. }),
"first pulse must originate at (0,0)"
);
// Now test true two-hop: (0,0)=0.95, (1,0)=0.50, (2,0)=0.05.
// Differential 0→1 = 0.45 ≥ 0.40; cur_pop becomes 0.50.
// Differential from (1,0) to (2,0) = 0.45 ≥ 0.40 and 0.05 ≤ 0.20.
let mut g2 = empty_grid(5, 3);
for tile in &mut g2.tiles {
tile.lair_population = 0.0;
}
let i0 = tile_idx(&g2, 0, 0).unwrap();
let i1 = tile_idx(&g2, 1, 0).unwrap();
let i2 = tile_idx(&g2, 2, 0).unwrap();
g2.tiles[i0].lair_population = 0.95;
g2.tiles[i1].lair_population = 0.50;
g2.tiles[i2].lair_population = 0.05;
let events2 = derive_biological_events(&g2, &t, 1, 42);
let pulses2: Vec<_> = events2
.iter()
.filter(|e| matches!(e, BiologicalEvent::MigrationPulse { .. }))
.collect();
assert!(pulses2.len() >= 2, "expected ≥2 hops; got {}", pulses2.len());
// First pulse: from (0,0).
assert!(
matches!(pulses2[0], BiologicalEvent::MigrationPulse { from_col: 0, from_row: 0, .. }),
"first pulse must originate at (0,0)"
);
// Second pulse: from wherever the first landed (must differ from (0,0)).
if let BiologicalEvent::MigrationPulse { from_col, from_row, .. } = pulses2[1] {
// Second pulse must not re-originate at (0,0).
if let BiologicalEvent::MigrationPulse { from_col, from_row, .. } = pulses[1] {
assert!(
!(*from_col == 0 && *from_row == 0),
"second pulse should not re-originate at (0,0)"
);
}
// Max-hops cap: with max_hops=1 only a single hop fires even with the
// same corridor.
let mut t1 = t.clone();
t1.migration_max_hops = 1;
let events_capped = derive_biological_events(&g, &t1, 1, 42);
let capped: Vec<_> = events_capped
.iter()
.filter(|e| matches!(e, BiologicalEvent::MigrationPulse { .. }))
.collect();
assert_eq!(capped.len(), 1, "max_hops=1 should cap at 1 pulse; got {}", capped.len());
}
}