From f37a734600d90f234545a9fdb3f8f6cc6ae2c7f6 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 7 May 2026 07:22:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20plague=20spread=20mechanics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../data/balance/biological_events.json | 11 +- .../crates/mc-ecology/src/biological.rs | 272 ++++++++++++++++-- 2 files changed, 252 insertions(+), 31 deletions(-) diff --git a/public/games/age-of-dwarves/data/balance/biological_events.json b/public/games/age-of-dwarves/data/balance/biological_events.json index 56200e19..844aa5a8 100644 --- a/public/games/age-of-dwarves/data/balance/biological_events.json +++ b/public/games/age-of-dwarves/data/balance/biological_events.json @@ -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 } } } diff --git a/src/simulator/crates/mc-ecology/src/biological.rs b/src/simulator/crates/mc-ecology/src/biological.rs index 1445385c..de2f5159 100644 --- a/src/simulator/crates/mc-ecology/src/biological.rs +++ b/src/simulator/crates/mc-ecology/src/biological.rs @@ -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 { /// 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 { + // 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 = 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)" + ); + } } }