feat(@projects/@magic-civilization): 🔥 p3-26 gap 2 — wildfire effect (apply_wildfire) ported

The first per-category event EFFECT, pure + testable. apply_wildfire burns forest tiles in
a hex disk around a center (offset coords via offset_to_axial/hex_spiral/axial_to_offset):
transforms matching terrain to `becomes`, drops quality (min 1) + moisture (min 0); returns
tiles burned. Mirrors GDScript process_wildfire's per-tile effect.

Test: a forest patch + apply_wildfire(radius 2, becomes grassland, quality_loss 2) → forest
→ grassland, moisture/quality dropped, non-forest tiles untouched. mc-climate events 6/6.

Next: the dispatch (process_events — category_fires gate + roll_severity + deterministic
center pick → apply_wildfire) + config-on-GameState + wire into the mc-turn climate phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 10:42:29 -04:00
parent 4e82b322cb
commit fa93e59425

View file

@ -111,6 +111,40 @@ pub fn load_event_configs(
out
}
/// Burn the forest tiles within `radius` (hex disk) of `center` (offset col,row):
/// transform matching terrain to `becomes`, drop quality (min 1) + moisture (min 0).
/// Returns the count burned. Pure — the dispatch chooses the center. Mirrors the per-tile
/// effect of GDScript `process_wildfire` (forest-only, radius/loss/`becomes` by tier).
pub fn apply_wildfire(
grid: &mut mc_core::grid::GridState,
center: (i32, i32),
radius: i32,
moisture_loss: f32,
quality_loss: i32,
becomes: Option<&str>,
target_terrain: &[String],
) -> i32 {
use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial};
let (cq, cr) = offset_to_axial(center.0, center.1);
let mut burned = 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 target_terrain.iter().any(|b| b.as_str() == t.biome_label_id) {
if let Some(b) = becomes {
t.biome_label_id = b.to_string();
}
if quality_loss > 0 {
t.quality = (t.quality - quality_loss).max(1);
}
t.moisture = (t.moisture - moisture_loss).max(0.0);
burned += 1;
}
}
}
burned
}
#[cfg(test)]
mod tests {
use super::*;
@ -159,6 +193,34 @@ mod tests {
assert!(!category_fires(0.0, 10.0, 1.0));
}
#[test]
fn apply_wildfire_burns_forest_in_radius() {
use mc_core::grid::GridState;
let mut grid = GridState::new(12, 12);
for t in &mut grid.tiles {
t.biome_label_id = "grassland".into();
t.moisture = 0.5;
t.quality = 3;
}
for c in 4..7 {
for r in 4..7 {
if let Some(t) = grid.tile_mut(c, r) {
t.biome_label_id = "forest".into();
}
}
}
let target = vec!["forest".to_string()];
let burned = apply_wildfire(&mut grid, (5, 5), 2, 0.15, 2, Some("grassland"), &target);
assert!(burned >= 1, "should burn forest tiles in radius");
let center = grid.tile(5, 5).unwrap();
assert_eq!(center.biome_label_id, "grassland", "forest → grassland");
assert!(center.moisture < 0.5, "moisture dropped");
assert_eq!(center.quality, 1, "quality 3 - 2 = 1 (floored at 1)");
// A pre-existing grassland tile (not target terrain) is untouched.
let far = grid.tile(0, 0).unwrap();
assert_eq!(far.moisture, 0.5, "non-forest tile untouched");
}
#[test]
fn load_event_configs_parses_real_categories() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))