diff --git a/.project/objectives/p3-13c-biological-events.md b/.project/objectives/p3-13c-biological-events.md index 7bf08d90..53d95189 100644 --- a/.project/objectives/p3-13c-biological-events.md +++ b/.project/objectives/p3-13c-biological-events.md @@ -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` 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`. diff --git a/src/simulator/crates/mc-ecology/src/biological.rs b/src/simulator/crates/mc-ecology/src/biological.rs index de2f5159..a40e3ba4 100644 --- a/src/simulator/crates/mc-ecology/src/biological.rs +++ b/src/simulator/crates/mc-ecology/src/biological.rs @@ -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 { // 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 = 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()); } }