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:
parent
64ea08b7ce
commit
60e941caa5
1 changed files with 104 additions and 17 deletions
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue