diff --git a/src/simulator/crates/mc-climate/src/events.rs b/src/simulator/crates/mc-climate/src/events.rs index 1797eed1..eec0f7c0 100644 --- a/src/simulator/crates/mc-climate/src/events.rs +++ b/src/simulator/crates/mc-climate/src/events.rs @@ -202,16 +202,89 @@ pub fn process_events( 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); - } + let ev = match *category { + "wildfire" => dispatch_wildfire(grid, cfg, tier, turn_seed, channel), + "drought" => dispatch_drought(grid, cfg, tier, turn_seed, channel), + // TODO(p3-26 gap 2): volcanic/seismic/tsunami/plague/marine/… handlers. + _ => None, + }; + if let Some(ev) = ev { + fired.push(ev); } - // TODO(p3-26 gap 2): volcanic/seismic/drought/… effect handlers. } fired } +/// Deterministically pick a tile whose biome satisfies `matches`, using the category's +/// noise channel. Returns `None` if no tile matches. Internal-determinism only (the +/// headless sim doesn't byte-match the live game's Godot RNG). +fn pick_matching_tile bool>( + grid: &mc_core::grid::GridState, + channel: f64, + turn_seed: f64, + matches: F, +) -> Option<(i32, i32)> { + let tiles: 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| matches(&t.biome_label_id))) + .collect(); + if tiles.is_empty() { + return None; + } + let pick = (hash_noise(channel, 1.0, turn_seed) * tiles.len() as f64) as usize; + Some(tiles[pick.min(tiles.len() - 1)]) +} + +/// Apply a drought: reduce moisture (min 0) in a hex disk around `center`, skipping water +/// tiles. Returns tiles affected. Mirrors GDScript `process_drought`. +pub fn apply_drought( + grid: &mut mc_core::grid::GridState, + center: (i32, i32), + radius: i32, + moisture_loss: f32, +) -> i32 { + use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial}; + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + let (cq, cr) = offset_to_axial(center.0, center.1); + let mut affected = 0; + for (q, r) in hex_spiral(cq, cr, radius) { + let (col, row) = axial_to_offset(q, r); + if let Some(t) = grid.tile_mut(col, row) { + if !has_tag(&t.biome_label_id, BiomeTag::IsWater) { + t.moisture = (t.moisture - moisture_loss).max(0.0); + affected += 1; + } + } + } + affected +} + +/// Resolve the drought tier config + pick a non-water center, then dry the area. +fn dispatch_drought( + grid: &mut mc_core::grid::GridState, + cfg: &EventCategoryConfig, + tier: usize, + turn_seed: f64, + channel: f64, +) -> Option { + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + 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(2) as i32; + let moisture_loss = tier_cfg.get("moisture_loss").and_then(|v| v.as_f64()).unwrap_or(0.05) as f32; + let center = + pick_matching_tile(grid, channel, turn_seed, |b| !has_tag(b, BiomeTag::IsWater))?; + let affected = apply_drought(grid, center, radius, moisture_loss); + if affected == 0 { + return None; + } + Some(FiredEvent { + category: "drought".to_string(), + tier, + center, + affected, + }) +} + /// Resolve the wildfire tier config + pick a deterministic forest center, then burn. fn dispatch_wildfire( grid: &mut mc_core::grid::GridState, @@ -240,18 +313,9 @@ fn dispatch_wildfire( // 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 center = pick_matching_tile(grid, channel, turn_seed, |b| { + target_terrain.iter().any(|t| t.as_str() == b) + })?; let affected = apply_wildfire( grid, center, @@ -387,6 +451,29 @@ mod tests { assert_eq!(fired, fired2, "dispatch must be deterministic"); } + #[test] + fn apply_drought_dries_land_skips_water() { + use mc_core::grid::GridState; + let mut grid = GridState::new(10, 10); + for t in &mut grid.tiles { + t.biome_label_id = "grassland".into(); + t.moisture = 0.6; + } + if let Some(t) = grid.tile_mut(5, 5) { + t.biome_label_id = "ocean".into(); + t.moisture = 1.0; + } + let affected = apply_drought(&mut grid, (4, 5), 2, 0.2); + assert!(affected >= 1, "should dry some land"); + let land = grid.tile(4, 5).unwrap(); + assert!( + (land.moisture - 0.4).abs() < 1e-5, + "land dried 0.6-0.2=0.4 (got {})", + land.moisture + ); + assert_eq!(grid.tile(5, 5).unwrap().moisture, 1.0, "water tile not dried"); + } + #[test] fn load_event_configs_parses_real_categories() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))