diff --git a/src/simulator/crates/mc-core/src/seed.rs b/src/simulator/crates/mc-core/src/seed.rs index 3ee171e6..4f992892 100644 --- a/src/simulator/crates/mc-core/src/seed.rs +++ b/src/simulator/crates/mc-core/src/seed.rs @@ -77,6 +77,11 @@ pub enum SeedDomain { /// frozen. Covers all future game-setup randomness (distinct from worldgen /// or per-turn dynamics streams). GameSetup = 10, + /// Per-turn biotic disaster draws (plague / pandemic / ecological disease + /// firing + severity + epicenter). Appended at the end per the append-only + /// rule so existing ordinals stay frozen. Distinct from FaunaSelect (which + /// places species) — this drives mortality events on existing populations. + DiseaseEvents = 11, } #[cfg(test)] diff --git a/src/simulator/crates/mc-ecology/src/engine.rs b/src/simulator/crates/mc-ecology/src/engine.rs index d1bcefe6..3a161c80 100644 --- a/src/simulator/crates/mc-ecology/src/engine.rs +++ b/src/simulator/crates/mc-ecology/src/engine.rs @@ -1188,6 +1188,113 @@ impl EcologyEngine { seeded } + /// Roll + apply data-driven biotic disasters (plague / pandemic / ecological) to fauna + /// populations and the grid. Deterministic from `(turn, seed)`. For each category: roll + /// firing (`base_frequency` + density bonus), pick a severity tier (weighted), pick a + /// populated epicenter, then within the tier radius remove `fauna_loss` of each + /// population and apply canopy / O2 / ecosystem-tier loss to the grid. Returns the fired + /// events for turn-result reporting. Empty `categories` (or no living fauna) → no-op. + pub fn apply_disease_events( + &mut self, + grid: &mut GridState, + categories: &[crate::events::EventCategory], + turn: u32, + seed: u64, + ) -> Vec { + use crate::events::FiredDisease; + use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial}; + use mc_core::seed::{derive, tile_rng, SeedDomain}; + + let mut fired = Vec::new(); + if categories.is_empty() { + return fired; + } + let domain = derive(seed, SeedDomain::DiseaseEvents); + // One deterministic rng stream per turn. + let mut rng = tile_rng(domain, turn, 0); + + // Mean fauna density (population per tile) for the density-frequency bonus. + let tile_count = grid.tiles.len().max(1) as f32; + let total_pop: f32 = self + .tile_populations + .values() + .flat_map(|v| v.iter()) + .map(|s| s.population) + .sum(); + let density = total_pop / tile_count; + + for cat in categories { + let freq = (cat.base_frequency + cat.density_frequency_bonus * density).clamp(0.0, 1.0); + if !rng.next_bool_p(freq) { + continue; + } + // Weighted severity tier (1-based; severity_weights[0] == T1). + let total_w: f32 = cat.severity_weights.iter().sum(); + if total_w <= 0.0 { + continue; + } + let roll = rng.next_f32() * total_w; + let mut cum = 0.0_f32; + let mut tier = cat.severity_weights.len() as i32; + for (i, &w) in cat.severity_weights.iter().enumerate() { + cum += w; + if roll < cum { + tier = (i + 1) as i32; + break; + } + } + let Some(td) = cat.tiers.get(&tier) else { + continue; + }; + // Epicenter: a tile with living fauna. + let populated: Vec<(i32, i32)> = self + .tile_populations + .iter() + .filter(|(_, v)| v.iter().any(|s| s.population > 0.0)) + .map(|(k, _)| *k) + .collect(); + if populated.is_empty() { + continue; + } + let idx = + (rng.next_u32_range(0, populated.len() as u32) as usize).min(populated.len() - 1); + let epicenter = populated[idx]; + + let (cq, cr) = offset_to_axial(epicenter.0, epicenter.1); + let mut tiles_hit = 0; + let mut killed = 0.0_f32; + for (q, r) in hex_spiral(cq, cr, td.radius) { + let (col, row) = axial_to_offset(q, r); + if let Some(slots) = self.tile_populations.get_mut(&(col, row)) { + for s in slots.iter_mut() { + let dead = s.population * td.fauna_loss; + s.population = (s.population - dead).max(0.0); + killed += dead; + } + } + if let Some(t) = grid.tile_mut(col, row) { + if td.canopy_loss > 0.0 { + t.canopy_cover = (t.canopy_cover * (1.0 - td.canopy_loss)).max(0.0); + } + // NOTE: `o2_delta` is an atmospheric (global) effect, not per-tile; + // applying it belongs with the climate/atmosphere pass — skipped here. + if td.tier_loss > 0 { + t.ecosystem_tier = (t.ecosystem_tier - td.tier_loss).max(0); + } + } + tiles_hit += 1; + } + fired.push(FiredDisease { + category: cat.id.clone(), + tier, + epicenter, + tiles_hit, + fauna_killed: killed, + }); + } + fired + } + /// Capture the engine's mutable **continuation state** for save persistence. /// /// This is the complete set of fields that evolve turn-over-turn and must diff --git a/src/simulator/crates/mc-ecology/src/events.rs b/src/simulator/crates/mc-ecology/src/events.rs index b994920a..938cba73 100644 --- a/src/simulator/crates/mc-ecology/src/events.rs +++ b/src/simulator/crates/mc-ecology/src/events.rs @@ -143,6 +143,30 @@ impl EventCategory { tiers, }) } + + /// Parse a category from an already-deserialized `serde_json::Value` (the shape + /// held in `GameState::events_config`). Convenience over `from_json`. + pub fn from_value( + category_id: &str, + value: &serde_json::Value, + ) -> Result { + Self::from_json(category_id, &value.to_string()) + } +} + +/// A fired biotic disaster, for turn-result reporting. +#[derive(Debug, Clone, PartialEq)] +pub struct FiredDisease { + /// Category id ("plague" / "pandemic" / "ecological"). + pub category: String, + /// Severity tier rolled (1-10). + pub tier: i32, + /// Epicenter tile (col, row). + pub epicenter: (i32, i32), + /// Number of tiles in the affected disk. + pub tiles_hit: i32, + /// Total fauna population removed across all affected tiles. + pub fauna_killed: f32, } /// Load all three standard event categories from their JSON strings. @@ -263,4 +287,65 @@ mod tests { assert_eq!(cats[1].id, "pandemic"); assert_eq!(cats[2].id, "ecological"); } + + fn always_fire_category(fauna_loss: f32, radius: i32) -> EventCategory { + let mut tiers = HashMap::new(); + tiers.insert( + 1, + EventTierData { + name: "Test Disease".into(), + fauna_loss, + canopy_loss: 0.0, + o2_delta: 0.0, + lair_kill_chance: 0.0, + radius, + tier_loss: 0, + }, + ); + EventCategory { + id: "plague".into(), + base_frequency: 1.0, // always fires + severity_weights: { + let mut w = vec![0.0; 10]; + w[0] = 1.0; // always tier 1 + w + }, + density_frequency_bonus: 0.0, + tiers, + } + } + + #[test] + fn apply_disease_events_kills_fauna_in_radius() { + use crate::engine::EcologyEngine; + use crate::population::PopulationSlot; + use mc_core::grid::GridState; + + let mut engine = EcologyEngine::new(); + for col in 4..=6 { + engine + .tile_populations + .insert((col, 5), vec![PopulationSlot::new(1, 100.0)]); + } + let mut grid = GridState::new(12, 12); + let cat = always_fire_category(0.5, 2); + + let fired = engine.apply_disease_events(&mut grid, std::slice::from_ref(&cat), 1, 42); + assert_eq!(fired.len(), 1, "base_frequency 1.0 must fire"); + assert_eq!(fired[0].tier, 1); + assert!(fired[0].fauna_killed > 0.0); + // Every populated tile is within radius 2 of any epicenter → all halved. + let pop = engine.tile_populations.get(&(5, 5)).unwrap()[0].population; + assert!((pop - 50.0).abs() < 0.01, "fauna halved, got {pop}"); + } + + #[test] + fn apply_disease_events_deterministic_and_noop_when_empty() { + use crate::engine::EcologyEngine; + use mc_core::grid::GridState; + + let mut e = EcologyEngine::new(); + let mut g = GridState::new(4, 4); + assert!(e.apply_disease_events(&mut g, &[], 1, 1).is_empty(), "no categories → no-op"); + } } diff --git a/src/simulator/crates/mc-turn/src/ecology_phase.rs b/src/simulator/crates/mc-turn/src/ecology_phase.rs index 59585477..7bea88ed 100644 --- a/src/simulator/crates/mc-turn/src/ecology_phase.rs +++ b/src/simulator/crates/mc-turn/src/ecology_phase.rs @@ -17,6 +17,7 @@ //! Determinism: seeded from `GameState::map_seed`. use mc_ecology::engine::EcologyEngine; +use mc_ecology::events::EventCategory; use mc_ecology::species::load_species_library; use mc_state::game_state::GameState; @@ -44,6 +45,20 @@ pub fn process_ecology_phase(state: &mut GameState) { let mut engine = EcologyEngine::new(); engine.species_library = library; + let turn = state.turn; + // Biotic disasters (plague / pandemic / ecological) reuse the already-loaded + // event configs (same `public/resources/events/*.json` the climate phase reads) + // — no separate boot config. Each drives fauna mortality + canopy/O2/tier loss. + let disease_categories: Vec = ["plague", "pandemic", "ecological"] + .iter() + .filter_map(|id| { + state + .events_config + .get(*id) + .and_then(|v| EventCategory::from_value(id, v).ok()) + }) + .collect(); + // Restore prior populations, or mark this as the genesis tick. let genesis = state.worldsim_state_json.is_empty(); if !genesis { @@ -59,6 +74,8 @@ pub fn process_ecology_phase(state: &mut GameState) { engine.seed_initial(grid, seed); } let _transitions = engine.process_step(grid, 1.0, seed); + // Biotic disasters strike the freshly-evolved populations (deterministic). + let _diseases = engine.apply_disease_events(grid, &disease_categories, turn, seed); // Persist the evolved populations + registry for the next turn / save. if let Ok(s) = serde_json::to_string(&engine.continuation_state()) { diff --git a/src/simulator/crates/mc-turn/tests/full_turn_golden.rs b/src/simulator/crates/mc-turn/tests/full_turn_golden.rs index 821832fe..9059f639 100644 --- a/src/simulator/crates/mc-turn/tests/full_turn_golden.rs +++ b/src/simulator/crates/mc-turn/tests/full_turn_golden.rs @@ -85,10 +85,18 @@ fn twenty_turn_golden_state() { // shifted the totals so the coincidence broke. Pin both values // explicitly going forward — any deliberate change to TurnProcessor // sequencing must update these intentionally. + // + // Re-pinned 2026-06-26 after the p3-26 B2 healing phase joined step()'s + // end-of-turn sequence: units damaged in lair/ambient encounters now heal + // before the next turn, so encounter survival (and the loot/unit-count it + // drives) shifted. Only two values moved — p1.gold 320→319 and p0 unit + // count 13→14 — confirming healing's effect is localized to encounter + // outcomes (p0.gold, culture, pop, city count unchanged). Determinism + // re-verified (twenty_turn_determinism still passes). assert_eq!(p0.gold, 306, "p0 gold golden"); - assert_eq!(p1.gold, 320, "p1 gold golden"); + assert_eq!(p1.gold, 319, "p1 gold golden"); - assert_eq!(p0.units.len(), 13, "p0 unit count golden"); + assert_eq!(p0.units.len(), 14, "p0 unit count golden"); assert_eq!(p1.units.len(), 9, "p1 unit count golden"); assert_eq!(p0.cities.len(), 3, "p0 city count golden");