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:
parent
fa93e59425
commit
b07e1dd367
1 changed files with 150 additions and 0 deletions
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue