From 4e82b322cbb5cdba6ae53467c282c39bbbb9b591 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 26 Jun 2026 10:39:04 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=93=8B=20p3-26=20gap=202=20=E2=80=94=20event-config=20loa?= =?UTF-8?q?der=20(mc-climate::events)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next brick of the events port: load per-category configs from the canonical JSON (Rail-2, nothing hardcoded). - EventCategoryConfig { base_frequency, severity_weights, raw } — typed dispatch inputs + the full JSON kept in `raw` so each per-category handler reads its own fields (tiers, target_terrain, becomes, aerosol_strength) without modeling all 12 shapes up front. - load_event_configs(dir) reads public/resources/events/.json (category = filename stem; skips *.schema / cross_triggers / events). Test parses the real wildfire.json (base_frequency 0.04, severity_weights, target_terrain ∋ "forest"). mc-climate events 5/5. Next: dispatch (process_events using category_fires + roll_severity) + per-category handlers (wildfire first — burn forest in radius, transform biome) + wire into the mc-turn climate phase. Tile-picking will use a Rust-deterministic RNG (the headless sim needs internal determinism, not byte-match with the live game's Godot RNG; the dispatch GATE already matches GDScript bit-for-bit). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/simulator/crates/mc-climate/src/events.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/simulator/crates/mc-climate/src/events.rs b/src/simulator/crates/mc-climate/src/events.rs index 2883c41b..80a19830 100644 --- a/src/simulator/crates/mc-climate/src/events.rs +++ b/src/simulator/crates/mc-climate/src/events.rs @@ -48,6 +48,69 @@ pub fn category_fires(base_frequency: f64, channel: f64, turn_seed: f64) -> bool hash_noise(channel, 0.0, turn_seed) < base_frequency } +/// One natural-event category's config, loaded from `public/resources/events/.json`. +/// `base_frequency` + `severity_weights` drive the dispatch (typed); `raw` keeps the full +/// JSON so each per-category handler can read its specific fields (tiers, target_terrain, +/// becomes, aerosol_strength, …) without this struct having to model all 12 shapes. +#[derive(Debug, Clone)] +pub struct EventCategoryConfig { + pub base_frequency: f64, + pub severity_weights: Vec, + pub raw: serde_json::Value, +} + +/// Load per-category event configs from `public/resources/events/.json`. The +/// category name is the filename stem. Non-category files (`*.schema`, `cross_triggers`, +/// `events`) are skipped, as are files lacking `base_frequency`/`severity_weights`. The +/// per-category JSON is the canonical content store (Rail-2); nothing is hardcoded here. +pub fn load_event_configs( + events_dir: &std::path::Path, +) -> std::collections::BTreeMap { + let mut out = std::collections::BTreeMap::new(); + let entries = match std::fs::read_dir(events_dir) { + Ok(e) => e, + Err(_) => return out, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let stem = match path.file_stem().and_then(|s| s.to_str()) { + Some(s) => s.to_string(), + None => continue, + }; + if stem.ends_with(".schema") || stem == "cross_triggers" || stem == "events" { + continue; + } + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(_) => continue, + }; + let val: serde_json::Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + let base_frequency = val.get("base_frequency").and_then(|v| v.as_f64()); + let weights = val.get("severity_weights").and_then(|v| v.as_array()); + if let (Some(bf), Some(w)) = (base_frequency, weights) { + let severity_weights = w + .iter() + .filter_map(|x| x.as_i64().map(|n| n as i32)) + .collect(); + out.insert( + stem, + EventCategoryConfig { + base_frequency: bf, + severity_weights, + raw: val, + }, + ); + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -95,4 +158,34 @@ mod tests { assert!(category_fires(1.0, 10.0, 1.0)); assert!(!category_fires(0.0, 10.0, 1.0)); } + + #[test] + fn load_event_configs_parses_real_categories() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("workspace root") + .join("public/resources/events"); + let cfgs = load_event_configs(&dir); + assert!(!cfgs.is_empty(), "should load event categories from {dir:?}"); + let wf = cfgs.get("wildfire").expect("wildfire config present"); + assert!( + (wf.base_frequency - 0.04).abs() < 1e-9, + "wildfire base_frequency = {}", + wf.base_frequency + ); + assert!(!wf.severity_weights.is_empty(), "severity_weights parsed"); + let tt = wf + .raw + .get("target_terrain") + .and_then(|v| v.as_array()) + .expect("wildfire target_terrain in raw"); + assert!( + tt.iter().any(|t| t.as_str() == Some("forest")), + "wildfire targets forest" + ); + // Non-category files are not loaded as categories. + assert!(!cfgs.contains_key("cross_triggers")); + assert!(!cfgs.contains_key("events")); + } }