feat(@projects/@magic-civilization): 🌋 p3-26 gap 2 — natural-event dispatch (process_events) + wildfire end-to-end

The per-turn dispatch ties the events core together, matching GDScript
ecological_events.process_events:
- turn_seed = seed*1000 + turn; per category in CATEGORY_ORDER (channel = index*10+10),
  gate on category_fires(base_frequency), roll_severity (era-capped), apply the effect.
- Wildfire implemented end-to-end: dispatch_wildfire resolves the tier config (radius/
  moisture_loss/becomes from raw JSON), picks a deterministic forest center, calls
  apply_wildfire. Returns FiredEvent{category,tier,center,affected}. Other 11 categories
  are recognised (gate+severity) with effect handlers to follow.

Test: an always-fire wildfire config on a forest grid → fires once, burns forest →
grassland, deterministic for (turn,seed). mc-climate events 7/7.

Tile-pick is Rust-deterministic (internal determinism; the dispatch GATE matches GDScript
bit-for-bit). Next: wire process_events into the mc-turn climate phase (config carried on
GameState) so wildfires fire in headless self-play, then the remaining category handlers.

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

View file

@ -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<String, EventCategoryConfig>,
turn: u32,
seed: u64,
max_tier: usize,
) -> Vec<FiredEvent> {
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<FiredEvent> {
let target_terrain: Vec<String> = 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"))