feat(@projects/@magic-civilization): 🏜️ p3-26 gap 2 — drought category + shared tile-picker

Second natural-event category, ported from GDScript process_drought:
- apply_drought: reduce moisture (min 0) in a hex disk, skipping water tiles
  (mc_core::grid::biome_registry::has_tag(.., IsWater)). Returns tiles affected.
- dispatch_drought: resolve tier (radius/moisture_loss) + pick a non-water center.
- Extracted pick_matching_tile (shared deterministic tile selection); refactored
  dispatch_wildfire to use it. process_events now dispatches wildfire + drought.

Test: apply_drought_dries_land_skips_water (land moisture drops, ocean untouched).
mc-climate events 8/8. Remaining categories (volcanic/seismic/tsunami/plague/marine/…)
follow the same pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 11:01:50 -04:00
parent 64ea08b7ce
commit 60e941caa5

View file

@ -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<F: Fn(&str) -> 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<FiredEvent> {
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"))