feat(@projects/@magic-civilization): 🌍 p3-26 B8 — seismic + impact + tsunami event categories

Three geological natural-event categories (parallel agent port, extracted file-only onto
current main — the agent worktree forked from a stale base, so a branch merge was unsafe;
events.rs is a clean superset verified to compile + test against current HEAD):
- seismic — shifts tile elevation over a radius (with falloff).
- impact (asteroid) — T1-4 crater (biome/elevation/moisture/quality + local heat + aerosol);
  T5 extinction (global aerosol injection). Magic/anchor/resource-spawn deferred (Game-3).
- tsunami — floods coastal land (moisture/quality/reef_health), skips open water.

Each: apply_X + dispatch_X + match arm in process_events + tests. Configs already present
(seismic/impact/tsunami.json) + auto-loaded via the headless harness, so they dispatch in
process_climate_phase with no extra wiring. mc-climate 61/0; mc-turn builds.
6/12 categories live (wildfire/drought/volcanic/seismic/impact/tsunami).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 15:31:19 -04:00
parent 2a360af22a
commit afa7613fd8
3 changed files with 531 additions and 1 deletions

View file

@ -206,7 +206,10 @@ pub fn process_events(
"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),
// TODO(p3-26 gap 2): impact/seismic/tsunami/plague/pandemic/marine/solar/glacial.
"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 {
@ -413,6 +416,313 @@ fn dispatch_volcanic(
})
}
// ── 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<FiredEvent> {
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<FiredEvent> {
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<FiredEvent> {
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::*;
@ -605,4 +915,222 @@ mod tests {
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<f32> = g1.tiles.iter().map(|t| t.elevation).collect();
let elevations2: Vec<f32> = 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");
}
}

@ -0,0 +1 @@
Subproject commit 94affd5ad32e852882e487100a9fb0dae3da7b84

@ -0,0 +1 @@
Subproject commit 474160ef178b5c526b0f3526fb0a9485046ad624