feat(@projects/@magic-civilization): ✨ add plague spread mechanics
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
2ae68a5610
commit
f37a734600
2 changed files with 252 additions and 31 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue