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

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-07 07:22:59 -07:00
parent 2ae68a5610
commit f37a734600
2 changed files with 252 additions and 31 deletions

View file

@ -2,10 +2,12 @@
"_doc": "Biological event derivation thresholds for mc-ecology::biological. Sibling of climate weather thresholds. See EVENT_FREQUENCY_SPEC.md for plague target frequency (~0.0025/turn warm).",
"biological": {
"plague": {
"_doc": "civ_min = civilization_presence floor (pop-density proxy). quality_max = sanitation proxy; quality is i32 0..=4, ≤1 = poor. trigger_chance matches plague target in EVENT_FREQUENCY_SPEC.",
"_doc": "civ_min = civilization_presence floor (pop-density proxy). quality_max = sanitation proxy; quality is i32 0..=4, ≤1 = poor. trigger_chance matches plague target in EVENT_FREQUENCY_SPEC. spread_factor = fraction of civ_min a neighbour must meet to receive spread (0.7 = 70% of floor). spread_severity_scale = multiplier on source severity for spread events.",
"civ_min": 0.55,
"quality_max": 1,
"trigger_chance": 0.0025
"trigger_chance": 0.0025,
"spread_factor": 0.7,
"spread_severity_scale": 0.6
},
"bloom": {
"_doc": "Growing-season window: mean_temp band + mean_precip floor on flora-dense tile (canopy + undergrowth thresholds).",
@ -17,11 +19,12 @@
"trigger_chance": 0.01
},
"migration": {
"_doc": "Pulse from a packed source tile (lair_population ≥ source_min) to a depleted neighbour (≤ neighbour_max), differential gating prevents weak hops.",
"_doc": "Pulse from a packed source tile (lair_population ≥ source_min) to a depleted neighbour (≤ neighbour_max), differential gating prevents weak hops. max_hops = maximum single-turn chain length (each hop must still satisfy neighbour_max + differential_min).",
"source_min": 0.60,
"neighbour_max": 0.20,
"differential_min": 0.40,
"trigger_chance": 0.02
"trigger_chance": 0.02,
"max_hops": 3
}
}
}

View file

@ -74,6 +74,10 @@ pub struct BiologicalThresholds {
pub plague_civ_min: f32,
pub plague_quality_max: i32,
pub plague_trigger_chance: f32,
/// Fraction of `plague_civ_min` a neighbour must meet to receive spread.
pub plague_spread_factor: f32,
/// Severity multiplier for adjacency-spread events (< 1.0 = weaker than source).
pub plague_spread_severity_scale: f32,
// Bloom — favourable growing-season window on flora-rich tile.
pub bloom_temp_min: f32,
@ -88,6 +92,8 @@ pub struct BiologicalThresholds {
pub migration_neighbour_max: f32,
pub migration_differential_min: f32,
pub migration_trigger_chance: f32,
/// Maximum single-turn chain hops for a migration corridor walk.
pub migration_max_hops: u32,
}
impl Default for BiologicalThresholds {
@ -96,6 +102,8 @@ impl Default for BiologicalThresholds {
plague_civ_min: 0.55,
plague_quality_max: 1, // quality ∈ 0..=4; ≤1 = poor sanitation
plague_trigger_chance: 0.0025, // matches EVENT_FREQUENCY_SPEC plague target
plague_spread_factor: 0.7,
plague_spread_severity_scale: 0.6,
bloom_temp_min: 0.45,
bloom_temp_max: 0.80,
@ -108,6 +116,7 @@ impl Default for BiologicalThresholds {
migration_neighbour_max: 0.20,
migration_differential_min: 0.40,
migration_trigger_chance: 0.02,
migration_max_hops: 3,
}
}
}
@ -131,6 +140,9 @@ impl BiologicalThresholds {
t.plague_civ_min = get_f32(p, "civ_min", t.plague_civ_min);
t.plague_quality_max = get_i32(p, "quality_max", t.plague_quality_max);
t.plague_trigger_chance = get_f32(p, "trigger_chance", t.plague_trigger_chance);
t.plague_spread_factor = get_f32(p, "spread_factor", t.plague_spread_factor);
t.plague_spread_severity_scale =
get_f32(p, "spread_severity_scale", t.plague_spread_severity_scale);
}
if let Some(b) = block.get("bloom") {
t.bloom_temp_min = get_f32(b, "temp_min", t.bloom_temp_min);
@ -146,6 +158,9 @@ impl BiologicalThresholds {
t.migration_differential_min =
get_f32(m, "differential_min", t.migration_differential_min);
t.migration_trigger_chance = get_f32(m, "trigger_chance", t.migration_trigger_chance);
if let Some(h) = m.get("max_hops").and_then(|v| v.as_u64()) {
t.migration_max_hops = h as u32;
}
}
t
}
@ -181,13 +196,32 @@ fn tile_idx(grid: &GridState, col: i32, row: i32) -> Option<usize> {
/// Derive per-turn biological events. Pure: no grid mutation.
///
/// Channel allocation (must stay disjoint from `mc-climate` weather channels
/// 1..=6): plague=11, bloom=12, migration=13.
/// 1..=6): plague=11, bloom=12, migration=13, plague_spread=14.
///
/// ## Plague adjacency spread (second pass)
///
/// After the primary derivation, every plague-emitting tile scans its 6
/// axial neighbours. A neighbour spreads if:
/// - `civilization_presence ≥ plague_civ_min * plague_spread_factor` (lower
/// bar than primary — already-weakened population can catch plague)
/// - `quality ≤ plague_quality_max` (poor sanitation still required)
/// - det_roll channel 14 < `plague_trigger_chance` (same stochastic gate)
///
/// Spread severity = source severity × `plague_spread_severity_scale`.
///
/// ## Migration corridor walk
///
/// Each qualifying source tile attempts up to `migration_max_hops` hops.
/// Each hop must satisfy `neighbour_max` + `differential_min` relative to
/// the *current* position in the chain (not always the original source).
pub fn derive_biological_events(
grid: &GridState,
thresholds: &BiologicalThresholds,
turn: i32,
seed: u64,
) -> Vec<BiologicalEvent> {
// Collect plague-source (col,row,severity) for the spread second-pass.
let mut plague_sources: Vec<(i32, i32, f32)> = Vec::new();
let mut events: Vec<BiologicalEvent> = Vec::new();
for tile in &grid.tiles {
@ -203,6 +237,7 @@ pub fn derive_biological_events(
let severity = ((tile.civilization_presence - thresholds.plague_civ_min)
* 2.0)
.clamp(0.1, 1.0);
plague_sources.push((tile.col, tile.row, severity));
events.push(BiologicalEvent::Plague {
col: tile.col,
row: tile.row,
@ -235,40 +270,85 @@ pub fn derive_biological_events(
}
// ── MigrationPulse ──────────────────────────────────────────────────
// High lair_population source tile next to a depleted neighbour: a
// proxy for the predator-prey LV signal already living in dynamics.rs.
// High lair_population source tile next to a depleted neighbour.
// Chain walk: up to migration_max_hops hops along the steepest-
// eligible gradient. Each hop emits a separate MigrationPulse event.
if tile.lair_population >= thresholds.migration_source_min
&& det_roll(seed, turn, tile.col, tile.row, 13)
< thresholds.migration_trigger_chance
{
// Walk AXIAL_DIRECTIONS in fixed index order; pick the first
// qualifying neighbour for determinism.
for dir in 0..AXIAL_DIRECTIONS.len() as i32 {
let Some((ncol, nrow)) =
offset_neighbor_in_dir(tile.col, tile.row, dir, grid.width, grid.height)
else {
continue;
};
let Some(idx) = tile_idx(grid, ncol, nrow) else { continue };
let neighbour = &grid.tiles[idx];
let differential = tile.lair_population - neighbour.lair_population;
if neighbour.lair_population <= thresholds.migration_neighbour_max
&& differential >= thresholds.migration_differential_min
{
let magnitude = (differential * 1.0).clamp(0.1, 1.0);
events.push(BiologicalEvent::MigrationPulse {
from_col: tile.col,
from_row: tile.row,
to_col: ncol,
to_row: nrow,
magnitude,
});
let mut cur_col = tile.col;
let mut cur_row = tile.row;
let mut cur_pop = tile.lair_population;
for _hop in 0..thresholds.migration_max_hops {
// Walk AXIAL_DIRECTIONS in fixed index order; pick the first
// qualifying neighbour for determinism.
let mut hopped = false;
for dir in 0..AXIAL_DIRECTIONS.len() as i32 {
let Some((ncol, nrow)) =
offset_neighbor_in_dir(cur_col, cur_row, dir, grid.width, grid.height)
else {
continue;
};
let Some(idx) = tile_idx(grid, ncol, nrow) else { continue };
let neighbour = &grid.tiles[idx];
let differential = cur_pop - neighbour.lair_population;
if neighbour.lair_population <= thresholds.migration_neighbour_max
&& differential >= thresholds.migration_differential_min
{
let magnitude = differential.clamp(0.1, 1.0);
events.push(BiologicalEvent::MigrationPulse {
from_col: cur_col,
from_row: cur_row,
to_col: ncol,
to_row: nrow,
magnitude,
});
// Advance chain position.
cur_col = ncol;
cur_row = nrow;
cur_pop = neighbour.lair_population;
hopped = true;
break;
}
}
if !hopped {
break;
}
}
}
}
// ── Plague adjacency spread (second pass) ───────────────────────────────
// For each primary plague source, scan neighbours. Uses a lower civ-
// density bar (plague_civ_min * plague_spread_factor) and channel 14 to
// stay disjoint from the primary plague roll (channel 11).
let spread_civ_floor = thresholds.plague_civ_min * thresholds.plague_spread_factor;
for (src_col, src_row, src_severity) in &plague_sources {
for dir in 0..AXIAL_DIRECTIONS.len() as i32 {
let Some((ncol, nrow)) =
offset_neighbor_in_dir(*src_col, *src_row, dir, grid.width, grid.height)
else {
continue;
};
let Some(idx) = tile_idx(grid, ncol, nrow) else { continue };
let neighbour = &grid.tiles[idx];
if neighbour.civilization_presence >= spread_civ_floor
&& neighbour.quality <= thresholds.plague_quality_max
&& det_roll(seed, turn, ncol, nrow, 14) < thresholds.plague_trigger_chance
{
let spread_severity =
(src_severity * thresholds.plague_spread_severity_scale).clamp(0.05, 1.0);
events.push(BiologicalEvent::Plague {
col: ncol,
row: nrow,
severity: spread_severity,
});
}
}
}
events
}
@ -425,18 +505,156 @@ mod tests {
fn thresholds_load_from_spec_json() {
let spec: Value = serde_json::from_str(
r#"{"biological":{
"plague": {"civ_min": 0.42, "quality_max": 2},
"plague": {"civ_min": 0.42, "quality_max": 2, "spread_factor": 0.8, "spread_severity_scale": 0.5},
"bloom": {"trigger_chance": 0.5},
"migration": {"source_min": 0.9}
"migration": {"source_min": 0.9, "max_hops": 5}
}}"#,
)
.unwrap();
let t = BiologicalThresholds::from_spec(&spec);
assert!((t.plague_civ_min - 0.42).abs() < 1e-6);
assert_eq!(t.plague_quality_max, 2);
assert!((t.plague_spread_factor - 0.8).abs() < 1e-6);
assert!((t.plague_spread_severity_scale - 0.5).abs() < 1e-6);
assert!((t.bloom_trigger_chance - 0.5).abs() < 1e-6);
assert!((t.migration_source_min - 0.9).abs() < 1e-6);
assert_eq!(t.migration_max_hops, 5);
// Unset keys fall back.
assert!((t.plague_trigger_chance - 0.0025).abs() < 1e-6);
assert_eq!(t.migration_neighbour_max.to_bits(), 0.20f32.to_bits());
}
#[test]
fn test_plague_spreads_to_adjacent_cities() {
// Primary source at (1,1); neighbour (2,1) meets reduced spread floor.
// With trigger_chance=1.0 the spread roll always fires.
let mut g = empty_grid(4, 4);
// Source tile: fully infected.
let src_idx = tile_idx(&g, 1, 1).unwrap();
g.tiles[src_idx].civilization_presence = 0.90;
g.tiles[src_idx].quality = 0;
// Neighbour: meets spread floor (0.55 * 0.7 = 0.385) but NOT primary floor.
let nbr_idx = tile_idx(&g, 2, 1).unwrap();
g.tiles[nbr_idx].civilization_presence = 0.40; // ≥ 0.385, < 0.55
g.tiles[nbr_idx].quality = 0;
let mut t = BiologicalThresholds::default();
t.plague_trigger_chance = 1.0; // forces all rolls
let events = derive_biological_events(&g, &t, 1, 42);
let plagues: Vec<_> = events
.iter()
.filter(|e| matches!(e, BiologicalEvent::Plague { .. }))
.collect();
// Must have at least the primary at (1,1) plus spread to neighbour.
assert!(plagues.len() >= 2, "expected spread; got {}", plagues.len());
// Primary plague at (1,1).
assert!(plagues.iter().any(|e| matches!(e, BiologicalEvent::Plague { col: 1, row: 1, .. })));
// Spread plague at the neighbour — verify spread severity < source severity.
let spread_at_nbr: Vec<_> = plagues
.iter()
.filter(|e| matches!(e, BiologicalEvent::Plague { col: 2, row: 1, .. }))
.collect();
assert!(!spread_at_nbr.is_empty(), "no spread plague at neighbour (2,1)");
let BiologicalEvent::Plague { severity: spread_sev, .. } = spread_at_nbr[0] else {
panic!("not a plague")
};
let BiologicalEvent::Plague { severity: src_sev, .. } = plagues
.iter()
.find(|e| matches!(e, BiologicalEvent::Plague { col: 1, row: 1, .. }))
.unwrap()
else {
panic!()
};
assert!(
spread_sev < src_sev,
"spread severity {spread_sev} should be less than source {src_sev}"
);
// Tiles with zero civ-presence must not receive spread.
let zero_civ_plagues: Vec<_> = plagues
.iter()
.filter(|e| {
if let BiologicalEvent::Plague { col, row, .. } = e {
let Some(i) = tile_idx(&g, *col, *row) else { return false };
g.tiles[i].civilization_presence < 0.01
} else {
false
}
})
.collect();
assert!(
zero_civ_plagues.is_empty(),
"spread to zero-civ tile: {zero_civ_plagues:?}"
);
}
#[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.
let mut g = empty_grid(5, 3);
for tile in &mut g.tiles {
tile.lair_population = 0.0;
}
let idx0 = tile_idx(&g, 0, 0).unwrap();
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;
let mut t = BiologicalThresholds::default();
t.migration_trigger_chance = 1.0;
t.migration_max_hops = 3;
let events = derive_biological_events(&g, &t, 1, 42);
let pulses: Vec<_> = events
.iter()
.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());
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] {
assert!(
!(*from_col == 0 && *from_row == 0),
"second pulse should not re-originate at (0,0)"
);
}
}
}