diff --git a/src/simulator/crates/mc-climate/src/events.rs b/src/simulator/crates/mc-climate/src/events.rs index 71ebfc34..0ff328c8 100644 --- a/src/simulator/crates/mc-climate/src/events.rs +++ b/src/simulator/crates/mc-climate/src/events.rs @@ -145,6 +145,117 @@ pub fn apply_wildfire( burned } +/// The 12 natural-event categories, in the canonical dispatch order (matches GDScript +/// `ecological_events._CATEGORY_ORDER`). Each category's noise channel is `index*10 + 10`. +pub const CATEGORY_ORDER: [&str; 12] = [ + "volcanic", "impact", "seismic", "wildfire", "drought", "plague", "magical", "marine", + "solar", "glacial", "tsunami", "pandemic", +]; + +/// One event that fired this turn, for logging / downstream effects. +#[derive(Debug, Clone, PartialEq)] +pub struct FiredEvent { + pub category: String, + pub tier: usize, + pub center: (i32, i32), + pub affected: i32, +} + +/// Per-turn natural-event dispatch on the grid. Matches GDScript +/// `ecological_events.process_events`: `turn_seed = seed*1000 + turn`; per category +/// (channel = `index*10 + 10`), gate on `category_fires`, roll severity (capped at +/// `max_tier`), then apply the category's effect. Currently implements the **wildfire** +/// category end-to-end; the other 11 are recognised (gate + severity roll) but their +/// effect handlers land in subsequent increments. +pub fn process_events( + grid: &mut mc_core::grid::GridState, + configs: &std::collections::BTreeMap, + turn: u32, + seed: u64, + max_tier: usize, +) -> Vec { + let turn_seed = seed as f64 * 1000.0 + turn as f64; + let mut fired = Vec::new(); + for (idx, category) in CATEGORY_ORDER.iter().enumerate() { + let channel = idx as f64 * 10.0 + 10.0; + let cfg = match configs.get(*category) { + Some(c) => c, + None => continue, + }; + if cfg.base_frequency <= 0.0 || !category_fires(cfg.base_frequency, channel, turn_seed) { + continue; + } + let tier = roll_severity(&cfg.severity_weights, turn_seed, channel, max_tier); + if *category == "wildfire" { + if let Some(ev) = dispatch_wildfire(grid, cfg, tier, turn_seed, channel) { + fired.push(ev); + } + } + // TODO(p3-26 gap 2): volcanic/seismic/drought/… effect handlers. + } + fired +} + +/// Resolve the wildfire tier config + pick a deterministic forest center, then burn. +fn dispatch_wildfire( + grid: &mut mc_core::grid::GridState, + cfg: &EventCategoryConfig, + tier: usize, + turn_seed: f64, + channel: f64, +) -> Option { + let target_terrain: Vec = cfg + .raw + .get("target_terrain") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()) + .unwrap_or_else(|| { + ["forest", "jungle", "boreal_forest", "enchanted_forest"] + .iter() + .map(|s| s.to_string()) + .collect() + }); + let tier_cfg = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?; + let radius = tier_cfg.get("radius").and_then(|v| v.as_i64()).unwrap_or(1) as i32; + let moisture_loss = tier_cfg.get("moisture_loss").and_then(|v| v.as_f64()).unwrap_or(0.05) as f32; + // GDScript reads "quality_loss" (the JSON's "tier_loss" is unused by the live handler). + let quality_loss = tier_cfg.get("quality_loss").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let becomes = tier_cfg.get("becomes").and_then(|v| v.as_str()).map(String::from); + + // Deterministic forest-tile pick (headless sim needs internal determinism, not a + // byte-match with the live game's Godot RNG). + let forests: Vec<(i32, i32)> = (0..grid.height) + .flat_map(|row| (0..grid.width).map(move |col| (col, row))) + .filter(|&(c, r)| { + grid.tile(c, r) + .is_some_and(|t| target_terrain.iter().any(|b| b.as_str() == t.biome_label_id)) + }) + .collect(); + if forests.is_empty() { + return None; + } + let pick = (hash_noise(channel, 1.0, turn_seed) * forests.len() as f64) as usize; + let center = forests[pick.min(forests.len() - 1)]; + let affected = apply_wildfire( + grid, + center, + radius, + moisture_loss, + quality_loss, + becomes.as_deref(), + &target_terrain, + ); + if affected == 0 { + return None; + } + Some(FiredEvent { + category: "wildfire".to_string(), + tier, + center, + affected, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -221,6 +332,45 @@ mod tests { assert_eq!(far.moisture, 0.5, "non-forest tile untouched"); } + #[test] + fn process_events_fires_wildfire_and_burns_forest() { + use mc_core::grid::GridState; + let make = || { + let mut grid = GridState::new(12, 12); + for t in &mut grid.tiles { + t.biome_label_id = "forest".into(); + t.moisture = 0.5; + t.quality = 3; + } + grid + }; + let mut configs = std::collections::BTreeMap::new(); + configs.insert( + "wildfire".to_string(), + EventCategoryConfig { + base_frequency: 1.0, // always fires + severity_weights: vec![100], + raw: serde_json::json!({ + "target_terrain": ["forest"], + "tiers": { "1": { "radius": 2, "moisture_loss": 0.1, "becomes": "grassland" } } + }), + }, + ); + + let mut grid = make(); + let fired = process_events(&mut grid, &configs, 5, 7, 10); + assert_eq!(fired.len(), 1, "wildfire should fire (base_frequency 1.0)"); + assert_eq!(fired[0].category, "wildfire"); + assert!(fired[0].affected >= 1, "should burn forest"); + let grassland = grid.tiles.iter().filter(|t| t.biome_label_id == "grassland").count(); + assert!(grassland >= 1, "forest transformed to grassland"); + + // Deterministic for the same (turn, seed). + let mut grid2 = make(); + let fired2 = process_events(&mut grid2, &configs, 5, 7, 10); + assert_eq!(fired, fired2, "dispatch must be deterministic"); + } + #[test] fn load_event_configs_parses_real_categories() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))