From af4a7a4affab1f9ed51db6857830a1517399dc65 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 26 Jun 2026 15:27:35 -0400 Subject: [PATCH] feat(mc-climate): add seismic, impact, tsunami event categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the three geological event categories end-to-end in src/simulator/crates/mc-climate/src/events.rs, following the exact same pattern as wildfire/drought/volcanic: seismic: apply_seismic — shifts tile.elevation by elevation_delta × distance falloff over radius disk (clamped [0,1]) dispatch_seismic — reads radius + elevation_delta from tiers JSON, picks non-water center impact: apply_impact — two paths: T5 extinction (crater disk → desert, global sulfate_aerosol injection, biome collapse jungle→grassland/swamp→desert, site→mana_node) and T1-T4 standard (crater terrain, elevation delta, heat pulse via magic_heat_delta, local aerosol) dispatch_impact — reads elevation_delta/crater_radius/heat_*/aerosol_* from tiers JSON; T5 uses aerosol_global path tsunami: apply_tsunami — floods non-water tiles in disk: moisture += moisture_gain, quality = quality_reset (if >0); water tiles with reef_destruction lose 0.5 reef_health dispatch_tsunami — picks IsCoast tile (returns None if no coast exists, matching GDScript early-return); reads inland_reach/ moisture_gain/quality_reset/reef_destruction from tiers JSON process_events match arms added for seismic, impact, tsunami. pub mod events declared in lib.rs (was missing). 16 events tests total; all pass (cargo test -p mc-climate). Config key notes: seismic JSON: elevation_delta (matches GDScript .get('elevation_delta')) impact JSON T5: uses crater_radius + elevation_loss (NOT elevation_delta) impact JSON T1-4: uses elevation_delta (may be negative for crater depression) tsunami JSON: inland_reach (not radius; radius in JSON is for T6+ global variants) scatter_resource/wonder_anchor bits: Game-3 deferred (same as volcanic) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/simulator/crates/mc-climate/src/events.rs | 1136 +++++++++++++++++ src/simulator/crates/mc-climate/src/lib.rs | 1 + 2 files changed, 1137 insertions(+) create mode 100644 src/simulator/crates/mc-climate/src/events.rs diff --git a/src/simulator/crates/mc-climate/src/events.rs b/src/simulator/crates/mc-climate/src/events.rs new file mode 100644 index 00000000..860794c5 --- /dev/null +++ b/src/simulator/crates/mc-climate/src/events.rs @@ -0,0 +1,1136 @@ +//! p3-26 gap 2: natural / "apocalyptic" events — the deterministic core ported from +//! the GDScript `ecological_event_utils` (which itself mirrors TS `HexGrid.hashNoise`). +//! +//! These primitives drive the per-turn event dispatch (which categories fire, at what +//! severity) for the headless simulator. The full per-category handlers (wildfire, +//! volcanic, drought, …) build on top of these and land in subsequent increments. +//! Determinism MUST match the GDScript/TS implementations exactly so the live game and +//! the headless sim agree on event timelines for a given (turn, seed). + +/// Deterministic pseudo-random float in `[0, 1)`. Matches GDScript +/// `ecological_event_utils.hash_noise` / TS `HexGrid.hashNoise` exactly: +/// `frac(sin(x*127.1 + y*311.7 + seed*74.3) * 43758.5453)`. +/// +/// Computed in `f64` (GDScript floats are f64) to reproduce the live game bit-for-bit: +/// verified `hash_noise(10,0,1000) = 0.67791910066535` matches GDScript exactly. (NB: the +/// TS web guide's `Math.sin` diverges on these large arguments, so TS event timelines +/// differ from the game + this sim — a separate web-guide concern, not a port bug.) +pub fn hash_noise(x: f64, y: f64, seed: f64) -> f64 { + let v = (x * 127.1 + y * 311.7 + seed * 74.3).sin() * 43758.5453; + v - v.floor() +} + +/// Roll a 1-based severity tier from a weighted distribution, truncated at `max_tier` +/// (the era-based severity cap), preserving relative probability among allowed tiers. +/// Mirrors GDScript `roll_severity`. The roll uses `hash_noise(channel, 99.0, turn_seed)` +/// so each category's severity draw is on its own deterministic channel. +pub fn roll_severity(weights: &[i32], turn_seed: f64, channel: f64, max_tier: usize) -> usize { + let effective = weights.len().min(max_tier); + let total: i32 = weights[..effective].iter().sum(); + if total <= 0 { + return 1; + } + let roll = hash_noise(channel, 99.0, turn_seed) * total as f64; + let mut cumulative = 0i32; + for (i, &w) in weights[..effective].iter().enumerate() { + cumulative += w; + if roll < cumulative as f64 { + return i + 1; + } + } + effective +} + +/// Whether a category fires this turn: `hash_noise(channel, 0, turn_seed) < base_frequency` +/// — the exact gate the GDScript dispatch (`ecological_events.process_events`) applies per +/// category before rolling severity. +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, +} + +impl EventCategoryConfig { + /// Parse one category's config from its raw JSON. Returns `None` when + /// `base_frequency` or `severity_weights` is absent (not a valid category). + pub fn from_raw(val: &serde_json::Value) -> Option { + 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())?; + let severity_weights = weights + .iter() + .filter_map(|x| x.as_i64().map(|n| n as i32)) + .collect(); + Some(Self { + base_frequency, + severity_weights, + raw: val.clone(), + }) + } +} + +/// Build typed category configs from a raw `category → JSON` map (e.g. +/// `GameState.events_config`). The in-memory counterpart of `load_event_configs` +/// (which reads from disk); categories lacking the required fields are skipped. +pub fn configs_from_raw( + raw: &std::collections::BTreeMap, +) -> std::collections::BTreeMap { + raw.iter() + .filter_map(|(k, v)| EventCategoryConfig::from_raw(v).map(|c| (k.clone(), c))) + .collect() +} + +/// 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, + }; + if let Some(cfg) = EventCategoryConfig::from_raw(&val) { + out.insert(stem, cfg); + } + } + 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 +} + +/// 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, + turn: u32, + seed: u64, + max_tier: usize, +) -> Vec { + 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); + let ev = match *category { + "wildfire" => dispatch_wildfire(grid, cfg, tier, turn_seed, channel), + "drought" => dispatch_drought(grid, cfg, tier, turn_seed, channel), + "volcanic" => dispatch_volcanic(grid, cfg, tier, turn_seed, channel), + "seismic" => dispatch_seismic(grid, cfg, tier, turn_seed, channel), + "impact" => dispatch_impact(grid, cfg, tier, turn_seed, channel), + "tsunami" => dispatch_tsunami(grid, cfg, tier, turn_seed, channel), + // TODO(p3-26 gap 2): plague/pandemic/marine/solar/glacial. + _ => None, + }; + if let Some(ev) = ev { + fired.push(ev); + } + } + 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 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 { + 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, + cfg: &EventCategoryConfig, + tier: usize, + turn_seed: f64, + channel: f64, +) -> Option { + let target_terrain: Vec = 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 center = pick_matching_tile(grid, channel, turn_seed, |b| { + target_terrain.iter().any(|t| t.as_str() == b) + })?; + 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, + }) +} + +/// Apply a volcanic eruption: the center becomes a `volcano` (quality 1), a scorch disk +/// turns non-water tiles to `scorched_terrain` (quality 1, drier), and sulfate aerosol is +/// injected in `aerosol_radius` (the climate physics turns this into cooling next tick). +/// Returns tiles affected. Mirrors GDScript `process_volcanic` (anchor/resource spawns are +/// magic → Game-3 deferred). +pub fn apply_volcanic( + grid: &mut mc_core::grid::GridState, + center: (i32, i32), + radius: i32, + scorched_terrain: &str, + moisture_loss: f32, + aerosol_strength: f32, + aerosol_radius: i32, +) -> i32 { + use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial}; + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + if let Some(t) = grid.tile_mut(center.0, center.1) { + t.biome_label_id = "volcano".to_string(); + t.quality = 1; + } + let (cq, cr) = offset_to_axial(center.0, center.1); + let mut affected = 1; + for (q, r) in hex_spiral(cq, cr, radius) { + let (col, row) = axial_to_offset(q, r); + if (col, row) == center { + continue; + } + if let Some(t) = grid.tile_mut(col, row) { + if !has_tag(&t.biome_label_id, BiomeTag::IsWater) { + t.biome_label_id = scorched_terrain.to_string(); + t.moisture = (t.moisture - moisture_loss).max(0.0); + t.quality = 1; + affected += 1; + } + } + } + if aerosol_strength > 0.0 && aerosol_radius > 0 { + for (q, r) in hex_spiral(cq, cr, aerosol_radius) { + let (col, row) = axial_to_offset(q, r); + if let Some(t) = grid.tile_mut(col, row) { + t.sulfate_aerosol += aerosol_strength; + } + } + } + affected +} + +/// Resolve the volcanic tier config + pick a non-water center, then erupt. +fn dispatch_volcanic( + grid: &mut mc_core::grid::GridState, + cfg: &EventCategoryConfig, + tier: usize, + turn_seed: f64, + channel: f64, +) -> Option { + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + let tc = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?; + let radius = tc.get("radius").and_then(|v| v.as_i64()).unwrap_or(1) as i32; + let scorched = tc + .get("scorched_terrain") + .and_then(|v| v.as_str()) + .unwrap_or("desert") + .to_string(); + let moisture_loss = tc.get("scorched_moisture_loss").and_then(|v| v.as_f64()).unwrap_or(0.05) as f32; + let aero_str = tc.get("aerosol_strength").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + let aero_rad = tc.get("aerosol_radius").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let center = pick_matching_tile(grid, channel, turn_seed, |b| !has_tag(b, BiomeTag::IsWater))?; + let affected = apply_volcanic(grid, center, radius, &scorched, moisture_loss, aero_str, aero_rad); + Some(FiredEvent { + category: "volcanic".to_string(), + tier, + center, + affected, + }) +} + +// ── seismic ───────────────────────────────────────────────────────────────── + +/// Apply a seismic event: shift `elevation` by `elevation_delta` (clamped to [0,1]) +/// over a hex disk of `radius` around `center`, with a linear distance falloff so +/// the epicentre moves most. Returns the count of tiles affected. Mirrors GDScript +/// `process_seismic` (building/unit/wall damage is recorded in the event log and +/// processed by city.gd — not applied here, same as the live handler). +pub fn apply_seismic( + grid: &mut mc_core::grid::GridState, + center: (i32, i32), + radius: i32, + elevation_delta: f32, +) -> 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 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) { + let dist = { + let dq = (q - cq).abs(); + let dr = (r - cr).abs(); + let ds = (q + r - cq - cr).abs(); + ((dq + dr + ds) / 2) as f32 + }; + let falloff = 1.0 - dist / (radius + 1) as f32; + t.elevation = (t.elevation + elevation_delta * falloff).clamp(0.0, 1.0); + affected += 1; + } + } + affected +} + +/// Resolve the seismic tier config + pick a non-water center, then quake. +fn dispatch_seismic( + grid: &mut mc_core::grid::GridState, + cfg: &EventCategoryConfig, + tier: usize, + turn_seed: f64, + channel: f64, +) -> Option { + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + let tc = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?; + let radius = tc.get("radius").and_then(|v| v.as_i64()).unwrap_or(2) as i32; + let elevation_delta = tc.get("elevation_delta").and_then(|v| v.as_f64()).unwrap_or(0.01) as f32; + let center = pick_matching_tile(grid, channel, turn_seed, |b| !has_tag(b, BiomeTag::IsWater))?; + let affected = apply_seismic(grid, center, radius, elevation_delta); + if affected == 0 { + return None; + } + Some(FiredEvent { + category: "seismic".to_string(), + tier, + center, + affected, + }) +} + +// ── impact ─────────────────────────────────────────────────────────────────── + +/// Apply an asteroid impact. Handles two distinct code paths that mirror GDScript +/// `process_impact`: +/// +/// **T5 extinction path** (`crater_radius` present, no `elevation_delta`): +/// - Crater disk: non-water tiles → `"desert"`, elevation −`elevation_loss`, moisture 0, quality 1. +/// - Global sulfate aerosol injection (`aerosol_strength` on every tile). +/// - Biome collapse: living terrain → quality loss; jungle → grassland; swamp → desert. +/// - Impact site → `"mana_node"` (game-3 anchor skipped per contract, same as volcanic). +/// +/// **T1-T4 standard path** (`elevation_delta` present): +/// - If `elevation_delta > 0`: center → `crater_terrain`, elevation raised, quality 3. +/// - If `elevation_delta ≤ 0`: center → `"lake"` (low elevation) or `"desert"`, elevation lowered, quality 1. +/// - Heat pulse: `magic_heat_delta` += `heat_delta` over `heat_radius`. +/// - Aerosol injection in `aerosol_radius` around center. +/// +/// Returns tiles affected (crater + scorch disk for T5, 1 for T1-T4). +/// Resource/wonder-anchor bits from GDScript are Game-3 deferred, matching `apply_volcanic`. +pub fn apply_impact( + grid: &mut mc_core::grid::GridState, + center: (i32, i32), + tier: usize, + crater_radius: i32, + elevation_loss: f32, + aerosol_strength_global: f32, + aerosol_strength_local: f32, + aerosol_radius: i32, + elevation_delta: f32, + crater_terrain: &str, + heat_radius: i32, + heat_delta: f32, +) -> i32 { + use mc_core::algorithms::hex::{axial_to_offset, hex_spiral, offset_to_axial}; + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + + if tier == 5 && crater_radius > 0 { + // Extinction-level path + let (cq, cr) = offset_to_axial(center.0, center.1); + let mut affected = 0; + // Phase 1: crater vaporisation + for (q, r) in hex_spiral(cq, cr, crater_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.biome_label_id = "desert".to_string(); + t.elevation = (t.elevation - elevation_loss).max(0.0); + t.moisture = 0.0; + t.quality = 1; + affected += 1; + } + } + } + // Phase 2: global aerosol + if aerosol_strength_global > 0.0 { + for t in &mut grid.tiles { + t.sulfate_aerosol += aerosol_strength_global; + } + } + // Phase 3: biome collapse (living terrain quality loss / downgrade) + let living: &[&str] = &[ + "forest", "jungle", "enchanted_forest", "swamp", "grassland", "plains", "boreal_forest", + ]; + let biome_kill_quality: i32 = 2; // GDScript hardcodes biome_kill_quality_loss=2 from tier_cfg default + for t in &mut grid.tiles { + if living.iter().any(|b| *b == t.biome_label_id.as_str()) { + t.quality = (t.quality - biome_kill_quality).max(1); + match t.biome_label_id.as_str() { + "jungle" => { + t.biome_label_id = "grassland".to_string(); + t.moisture = (t.moisture - 0.15).max(0.0); + } + "enchanted_forest" => { + t.biome_label_id = "forest".to_string(); + } + "swamp" => { + t.biome_label_id = "desert".to_string(); + t.moisture = 0.0; + } + _ => {} + } + } + } + // Impact site mana node (anchor/resource Game-3 deferred, matching volcanic) + if let Some(t) = grid.tile_mut(center.0, center.1) { + t.biome_label_id = "mana_node".to_string(); + t.quality = 5; + } + return affected; + } + + // T1-T4 standard path + let (cq, cr) = offset_to_axial(center.0, center.1); + + // Center crater + if let Some(t) = grid.tile_mut(center.0, center.1) { + if elevation_delta > 0.0 { + t.biome_label_id = crater_terrain.to_string(); + t.elevation = (t.elevation + elevation_delta).min(1.0); + t.moisture = (t.moisture - 0.1).max(0.0); + t.quality = 3; + } else { + t.biome_label_id = if t.elevation < 0.15 { "lake" } else { "desert" }.to_string(); + t.elevation = (t.elevation + elevation_delta).max(0.0); + t.quality = 1; + } + } + + // Heat pulse + if heat_radius > 0 && heat_delta > 0.0 { + for (q, r) in hex_spiral(cq, cr, heat_radius) { + let (col, row) = axial_to_offset(q, r); + if let Some(t) = grid.tile_mut(col, row) { + t.magic_heat_delta += heat_delta; + } + } + } + + // Local aerosol injection + if aerosol_strength_local > 0.0 && aerosol_radius > 0 { + for (q, r) in hex_spiral(cq, cr, aerosol_radius) { + let (col, row) = axial_to_offset(q, r); + if let Some(t) = grid.tile_mut(col, row) { + t.sulfate_aerosol += aerosol_strength_local; + } + } + } + + 1 +} + +/// Resolve the impact tier config + pick any tile (including water, per GDScript which +/// uses `rng.randi_range(0, w-1)` — not a land-only pick), then apply. +fn dispatch_impact( + grid: &mut mc_core::grid::GridState, + cfg: &EventCategoryConfig, + tier: usize, + turn_seed: f64, + channel: f64, +) -> Option { + let tc = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?; + + // GDScript picks any tile (randi_range over full grid), including water/ocean. + // We use pick_matching_tile with an always-true predicate for internal determinism. + let center = pick_matching_tile(grid, channel, turn_seed, |_b| true)?; + + let crater_radius = tc.get("crater_radius").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let elevation_loss = tc.get("elevation_loss").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + let aerosol_strength = tc.get("aerosol_strength").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + let aerosol_radius = tc.get("aerosol_radius").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let elevation_delta = tc.get("elevation_delta").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + let crater_terrain = tc.get("crater_terrain").and_then(|v| v.as_str()).unwrap_or("mountains"); + let heat_radius = tc.get("heat_radius").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let heat_delta = tc.get("heat_delta").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + + // For T5, aerosol is global (aerosol_global: true in JSON); for T1-T4 it's local. + let is_global_aerosol = tier == 5 && crater_radius > 0; + let (global_aero, local_aero) = if is_global_aerosol { + (aerosol_strength, 0.0_f32) + } else { + (0.0_f32, aerosol_strength) + }; + + let affected = apply_impact( + grid, center, tier, + crater_radius, elevation_loss, + global_aero, local_aero, aerosol_radius, + elevation_delta, crater_terrain, + heat_radius, heat_delta, + ); + Some(FiredEvent { + category: "impact".to_string(), + tier, + center, + affected, + }) +} + +// ── tsunami ────────────────────────────────────────────────────────────────── + +/// Apply a tsunami: floods non-water tiles within `inland_reach` of a coast center. +/// Each non-water tile: moisture += `moisture_gain` (clamped to 1.0), quality set to +/// `quality_reset` (if > 0, clamped to max(1, quality_reset)). Water tiles with +/// `reef_destruction=true` lose 0.5 reef_health. Returns flooded tile count. +/// Mirrors GDScript `process_tsunami` (building/wall/unit damage is event-log-only). +pub fn apply_tsunami( + grid: &mut mc_core::grid::GridState, + center: (i32, i32), + inland_reach: i32, + moisture_gain: f32, + quality_reset: i32, + reef_destruction: bool, +) -> 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 flooded = 0; + for (q, r) in hex_spiral(cq, cr, inland_reach) { + 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) { + if reef_destruction { + t.reef_health = (t.reef_health - 0.5).max(0.0); + } + } else { + if moisture_gain > 0.0 { + t.moisture = (t.moisture + moisture_gain).min(1.0); + } + if quality_reset > 0 { + t.quality = quality_reset.max(1); + } + flooded += 1; + } + } + } + flooded +} + +/// Resolve the tsunami tier config + pick a coast center, then flood inland. +/// GDScript requires the center tile to have `is_coast` tag; non-coast picks return early. +fn dispatch_tsunami( + grid: &mut mc_core::grid::GridState, + cfg: &EventCategoryConfig, + tier: usize, + turn_seed: f64, + channel: f64, +) -> Option { + use mc_core::grid::biome_registry::{has_tag, BiomeTag}; + let tc = cfg.raw.get("tiers").and_then(|t| t.get(tier.to_string()))?; + let inland_reach = tc.get("inland_reach").and_then(|v| v.as_i64()).unwrap_or(1) as i32; + let moisture_gain = tc.get("moisture_gain").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + let quality_reset = tc.get("quality_reset").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let reef_destruction = tc.get("reef_destruction").and_then(|v| v.as_bool()).unwrap_or(false); + + // Must land on a coast tile (GDScript: `if not BiomeRegistry.has_tag(center, "is_coast"): return`). + let center = pick_matching_tile(grid, channel, turn_seed, |b| has_tag(b, BiomeTag::IsCoast))?; + + let affected = apply_tsunami(grid, center, inland_reach, moisture_gain, quality_reset, reef_destruction); + if affected == 0 { + return None; + } + Some(FiredEvent { + category: "tsunami".to_string(), + tier, + center, + affected, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_noise_matches_gdscript_exactly() { + // Authoritative golden: the LIVE GDScript game computes + // hash_noise(10,0,1000) = 0.67791910066535 (verified by running it). The + // headless sim must match the game (not the TS web guide, whose Math.sin + // diverges on these large arguments). Tight epsilon — same f64 libm path. + let v = hash_noise(10.0, 0.0, 1000.0); + assert!( + (v - 0.67791910066535).abs() < 1e-9, + "hash_noise(10,0,1000) = {v}, expected 0.67791910066535 (GDScript)" + ); + assert!((0.0..1.0).contains(&v), "must be in [0,1)"); + } + + #[test] + fn hash_noise_is_deterministic() { + assert_eq!(hash_noise(30.0, 1.0, 5000.0), hash_noise(30.0, 1.0, 5000.0)); + // Different channels give different draws (no aliasing). + assert_ne!(hash_noise(10.0, 0.0, 7.0), hash_noise(20.0, 0.0, 7.0)); + } + + #[test] + fn roll_severity_within_bounds_and_capped() { + let weights = [50, 30, 12, 5, 3, 0, 0, 0, 0, 0]; + for seed in 0..200 { + let tier = roll_severity(&weights, seed as f64, 10.0, 10); + assert!((1..=weights.len()).contains(&tier), "tier {tier} out of range"); + } + // max_tier caps the tier (era severity cap): with cap 2, never exceeds 2. + for seed in 0..200 { + let tier = roll_severity(&weights, seed as f64, 10.0, 2); + assert!(tier <= 2, "max_tier=2 must cap tier (got {tier})"); + } + // Empty / zero-weight → tier 1 (matches GDScript total<=0 guard). + assert_eq!(roll_severity(&[0, 0], 5.0, 10.0, 10), 1); + } + + #[test] + fn category_fires_gate() { + // base_frequency 1.0 always fires; 0.0 never fires. + assert!(category_fires(1.0, 10.0, 1.0)); + 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 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 apply_volcanic_erupts_scorches_and_injects_aerosol() { + 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; + t.sulfate_aerosol = 0.0; + } + // ocean tile in scorch radius stays water. + if let Some(t) = grid.tile_mut(6, 5) { + t.biome_label_id = "ocean".into(); + } + let affected = apply_volcanic(&mut grid, (5, 5), 2, "desert", 0.1, 0.2, 4); + assert!(affected >= 1); + assert_eq!(grid.tile(5, 5).unwrap().biome_label_id, "volcano", "center → volcano"); + assert_eq!(grid.tile(5, 5).unwrap().quality, 1); + // a land tile in radius scorched to desert + assert_eq!(grid.tile(4, 5).unwrap().biome_label_id, "desert", "scorched → desert"); + assert_eq!(grid.tile(6, 5).unwrap().biome_label_id, "ocean", "water not scorched"); + // aerosol injected near center + assert!(grid.tile(5, 5).unwrap().sulfate_aerosol >= 0.2, "aerosol injected"); + } + + #[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")) + .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")); + } + + #[test] + fn apply_seismic_shifts_elevation_with_falloff() { + 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.elevation = 0.5; + } + let affected = apply_seismic(&mut grid, (5, 5), 2, 0.1); + assert!(affected >= 1, "should affect tiles in radius"); + // Center tile (dist=0) gets full delta (falloff = 1 - 0/3 = 1.0). + let center = grid.tile(5, 5).unwrap(); + assert!( + (center.elevation - 0.6).abs() < 1e-4, + "center elevation should be 0.5 + 0.1*1.0 = 0.6, got {}", + center.elevation + ); + // A tile at distance=2 gets partial shift (falloff = 1 - 2/3 ≈ 0.333). + // We just verify the total shift is less than at center. + // (Exact neighbor depends on hex offset math; just verify bounds.) + assert!( + grid.tiles.iter().any(|t| t.elevation > 0.5 && t.elevation < 0.6), + "some tile should have partial elevation shift from falloff" + ); + // Elevation is clamped to [0, 1]. + let mut grid2 = GridState::new(6, 6); + for t in &mut grid2.tiles { + t.biome_label_id = "grassland".into(); + t.elevation = 0.99; + } + apply_seismic(&mut grid2, (3, 3), 1, 0.5); + assert!( + grid2.tiles.iter().all(|t| t.elevation <= 1.0), + "elevation must not exceed 1.0" + ); + } + + #[test] + fn apply_seismic_deterministic() { + use mc_core::grid::GridState; + let make = || { + let mut g = GridState::new(10, 10); + for t in &mut g.tiles { + t.biome_label_id = "grassland".into(); + t.elevation = 0.5; + } + g + }; + let mut g1 = make(); + let mut g2 = make(); + apply_seismic(&mut g1, (4, 4), 2, 0.05); + apply_seismic(&mut g2, (4, 4), 2, 0.05); + let elevations1: Vec = g1.tiles.iter().map(|t| t.elevation).collect(); + let elevations2: Vec = g2.tiles.iter().map(|t| t.elevation).collect(); + assert_eq!(elevations1, elevations2, "seismic must be deterministic"); + } + + #[test] + fn apply_impact_t1_t4_modifies_center_and_heat() { + 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.elevation = 0.5; + t.magic_heat_delta = 0.0; + } + // T1: elevation_delta < 0 → center becomes desert, elevation falls. + let affected = apply_impact( + &mut grid, (5, 5), + /*tier=*/1, + /*crater_radius=*/0, /*elevation_loss=*/0.0, + /*global_aero=*/0.0, /*local_aero=*/0.0, /*aerosol_radius=*/0, + /*elevation_delta=*/-0.1, + /*crater_terrain=*/"mountains", + /*heat_radius=*/1, /*heat_delta=*/0.02, + ); + assert_eq!(affected, 1); + let center = grid.tile(5, 5).unwrap(); + assert_eq!(center.biome_label_id, "desert", "low elev_delta → desert"); + assert!((center.elevation - 0.4).abs() < 1e-4, "elevation lowered"); + assert_eq!(center.quality, 1); + // heat pulse injected within heat_radius + assert!( + grid.tiles.iter().any(|t| t.magic_heat_delta > 0.0), + "heat pulse must propagate" + ); + } + + #[test] + fn apply_impact_t5_extinction_craters_and_injects_global_aerosol() { + use mc_core::grid::GridState; + let mut grid = GridState::new(16, 16); + for t in &mut grid.tiles { + t.biome_label_id = "jungle".into(); + t.elevation = 0.5; + t.moisture = 0.8; + t.quality = 3; + t.sulfate_aerosol = 0.0; + } + let affected = apply_impact( + &mut grid, (7, 7), + /*tier=*/5, + /*crater_radius=*/2, /*elevation_loss=*/0.3, + /*global_aero=*/1.0, /*local_aero=*/0.0, /*aerosol_radius=*/0, + /*elevation_delta=*/0.0, + /*crater_terrain=*/"mountains", + /*heat_radius=*/0, /*heat_delta=*/0.0, + ); + assert!(affected >= 1, "crater must affect some land"); + // Global aerosol on every tile + assert!( + grid.tiles.iter().all(|t| t.sulfate_aerosol >= 1.0), + "global aerosol must reach every tile" + ); + // Biome collapse: jungle → grassland + assert!( + grid.tiles.iter().any(|t| t.biome_label_id == "grassland"), + "jungle must collapse to grassland" + ); + // Impact site → mana_node + assert_eq!( + grid.tile(7, 7).unwrap().biome_label_id, + "mana_node", + "T5 impact site → mana_node" + ); + } + + #[test] + fn apply_tsunami_floods_land_skips_water() { + 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.3; + t.quality = 3; + t.reef_health = 1.0; + } + // Place coast center + an ocean tile in radius + if let Some(t) = grid.tile_mut(5, 5) { + t.biome_label_id = "coast".into(); + } + if let Some(t) = grid.tile_mut(6, 5) { + t.biome_label_id = "ocean".into(); + t.reef_health = 1.0; + } + let flooded = apply_tsunami(&mut grid, (5, 5), 2, 0.2, 1, true); + assert!(flooded >= 1, "should flood some land tiles"); + // Land tile in radius should have boosted moisture + let land = grid.tile(4, 5).unwrap(); + assert!(land.moisture > 0.3, "inland tile moisture boosted"); + // quality_reset = 1 applied to land tiles + assert_eq!(land.quality, 1, "quality reset to 1"); + // water tile reef_health decreased (reef_destruction=true) + let ocean = grid.tile(6, 5).unwrap(); + assert!((ocean.reef_health - 0.5).abs() < 1e-4, "reef health reduced by 0.5"); + } + + #[test] + fn dispatch_seismic_fires_via_process_events() { + 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.elevation = 0.5; + } + let mut configs = std::collections::BTreeMap::new(); + configs.insert( + "seismic".to_string(), + EventCategoryConfig { + base_frequency: 1.0, + severity_weights: vec![100], + raw: serde_json::json!({ + "tiers": { "1": { "radius": 2, "elevation_delta": 0.05 } } + }), + }, + ); + let fired = process_events(&mut grid, &configs, 3, 42, 10); + assert_eq!(fired.len(), 1); + assert_eq!(fired[0].category, "seismic"); + assert!(grid.tiles.iter().any(|t| (t.elevation - 0.5).abs() > 1e-5), + "elevation must have changed after seismic"); + } + + #[test] + fn dispatch_tsunami_requires_coast_center() { + use mc_core::grid::GridState; + // Grid with no coast tiles → tsunami should not fire (None from pick_matching_tile). + let mut grid = GridState::new(8, 8); + for t in &mut grid.tiles { + t.biome_label_id = "grassland".into(); + } + let mut configs = std::collections::BTreeMap::new(); + configs.insert( + "tsunami".to_string(), + EventCategoryConfig { + base_frequency: 1.0, + severity_weights: vec![100], + raw: serde_json::json!({ + "tiers": { "1": { "inland_reach": 1 } } + }), + }, + ); + let fired = process_events(&mut grid, &configs, 1, 1, 10); + assert!(fired.is_empty(), "tsunami must not fire without a coast tile"); + + // Grid with at least one coast tile + surrounding land → fires. + let mut grid2 = GridState::new(8, 8); + for t in &mut grid2.tiles { + t.biome_label_id = "grassland".into(); + } + if let Some(t) = grid2.tile_mut(4, 4) { + t.biome_label_id = "coast".into(); + } + let fired2 = process_events(&mut grid2, &configs, 1, 1, 10); + assert_eq!(fired2.len(), 1, "tsunami should fire when coast tile exists"); + assert_eq!(fired2[0].category, "tsunami"); + } +} diff --git a/src/simulator/crates/mc-climate/src/lib.rs b/src/simulator/crates/mc-climate/src/lib.rs index b3ad542c..f7e52bee 100644 --- a/src/simulator/crates/mc-climate/src/lib.rs +++ b/src/simulator/crates/mc-climate/src/lib.rs @@ -1,4 +1,5 @@ pub mod anomalous; +pub mod events; pub mod atmosphere; pub mod climate_effects; pub mod derive;