feat(@projects/@magic-civilization): implement geological events system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-13 12:22:12 -07:00
parent 35181826c4
commit aa4d0f86a0
4 changed files with 156 additions and 31 deletions

View file

@ -2,18 +2,23 @@
id: p3-13b
title: "Geological events — earthquake, volcanic_eruption, landslide"
priority: p3
status: partial
status: done
scope: game1
owner: unassigned
updated_at: 2026-05-07
owner: game-systems
updated_at: 2026-05-13
evidence:
- "src/simulator/crates/mc-mapgen/src/events.rs:142-256 — derive_events emits earthquake/volcanic_eruption/landslide gated by boundary_kind / plate_kind+mountain_proximity / mountain_proximity+moisture"
- "src/simulator/crates/mc-mapgen/src/events.rs:25-39 — typed GeologicalEvent struct (kind-tagged like WeatherEvent in p3-13a)"
- "src/simulator/crates/mc-mapgen/src/events.rs:262-372 — 6 tests passing (cargo test -p mc-mapgen events::): earthquake_only_at_plate_boundary, volcanic_eruption_only_on_volcanic_plate, landslide_requires_slope_and_saturation, determinism_same_seed_same_events, no_events_in_neutral_grid, thresholds_load_from_spec_json"
- "public/resources/events/geological_thresholds.json — trigger thresholds tuned to EVENT_FREQUENCY_SPEC.md (seismic ~0.003/turn, volcanic ~0.002/turn)"
- "src/simulator/crates/mc-mapgen/src/events.rs:206-283 — derive_events emits earthquake/volcanic_eruption/landslide gated by boundary_kind / (is_active_volcano || plate_kind) + mountain_proximity / mountain_proximity+moisture"
- "src/simulator/crates/mc-mapgen/src/events.rs:24-39 — typed GeologicalEvent struct (kind-tagged like WeatherEvent in p3-13a)"
- "src/simulator/crates/mc-mapgen/src/events.rs:183-198 — geo_roll routes rolls through SeedDomain::Geological via mc_core::seed::derive_step (replaces inline det_roll mixer)"
- "src/simulator/crates/mc-core/src/seed.rs:60-66 — SeedDomain::Geological = 8 appended after AiRollout so existing ordinals stay frozen; 3 new tests pin ordinal + distinctness + per-tile-channel determinism (geological_ordinal_is_eight, geological_domain_is_distinct_from_other_domains, geological_derive_step_is_stable_per_tile_channel)"
- "src/simulator/crates/mc-core/src/grid/mod.rs:238-247 — is_active_volcano: bool on TileState with #[serde(default)], default false; closes the is_active_volcano bullet without breaking save back-compat (older saves deserialise as false and fall through to the plate_kind proxy)"
- "src/simulator/crates/mc-mapgen/src/events.rs:240-249 — eruption branch gates on (is_active_volcano || plate_kind ∈ {VOLCANIC_ARC, HOTSPOT}); flag is authoritative when set, proxy retained for back-compat"
- "src/simulator/crates/mc-mapgen/src/events.rs:286-486 — 9 tests passing (cargo test -p mc-mapgen events::): original 6 + volcanic_eruption_fires_on_active_volcano_flag_without_plate_kind + volcanic_eruption_back_compat_plate_kind_when_flag_default_false + determinism_swap_to_seed_domain_is_stable"
- "public/resources/events/geological_thresholds.json — trigger thresholds tuned to EVENT_FREQUENCY_SPEC.md (seismic ~0.003/turn, volcanic ~0.002/turn); canonical JSON path (no duplicate added under data/balance/)"
- "src/simulator/crates/mc-mapgen/src/lib.rs:18-19 — re-exports derive_geological_events / GeologicalEvent / GeologicalThresholds"
- "src/simulator/crates/mc-sim/src/event_dispatch.rs — dispatch_world_events calls derive_geological_events; geo events routed through mc-ecology::tile::apply_damage (Land + Air channels) and ChronicleEntry::WorldEvent; dispatched in mc-sim (above the mc-turn cycle boundary)"
- "cargo test -p mc-sim p3_13_event_dispatch_geological_applies_land_damage: PASS"
- "cargo test -p mc-sim event_dispatch:: → 4/4 PASS (p3_13_event_dispatch_geological_applies_land_damage, biological_plague_applies_water_damage, anomalous_fog_populates_fog_map, noop_without_grid)"
- "cargo test -p mc-core seed:: → 10/10 PASS · cargo test -p mc-mapgen --lib → 59/59 PASS · cargo test -p mc-core --lib → 249/249 PASS · cargo test -p mc-ecology --lib → 324/324 PASS"
blocked_by: []
---
## Context
@ -24,8 +29,8 @@ blocked_by: []
- ✓ `mc_mapgen::events::derive_events` (re-exported as `derive_geological_events`) returns `earthquake`, `volcanic_eruption`, `landslide` events keyed by tile per rules in `TECTONICS.md` + EVENT_FREQUENCY_SPEC.md. (`src/simulator/crates/mc-mapgen/src/events.rs:142-256`) **Note**: lives in `mc-mapgen` not `mc-tectonics` — the latter crate does not exist; tectonics is a module in mc-mapgen, so events derived from plate state belong there.
- ✓ Each event variant emitted as kind-tagged `GeologicalEvent` struct (mirrors `WeatherEvent` shape from p3-13a — no separate enum, kind in a String field for serde wire compat). (`src/simulator/crates/mc-mapgen/src/events.rs:25-39`)
- ❌ Roll seeded via `seed::derive(SeedDomain::Geological, turn, tile)`. Used the same inline `det_roll(seed, turn, col, row, channel)` splitmix64 mixer as p3-13a's WeatherEvent. Byte-equivalent determinism contract; no `SeedDomain::Geological` variant added (would need a save-format bump). Tracked in follow-ups.
- `is_active_volcano: bool` tile property — the field does not exist on `TileState`. Closest available proxy used: `plate_kind ∈ {VOLCANIC_ARC, HOTSPOT}` gates the eruption branch, with `mountain_proximity` standing in for `magma_pressure`. Documented inline (`events.rs:60-67`). Tracked in follow-ups.
- ✓ Roll seeded via `SeedDomain::Geological` (variant `8`, appended after `AiRollout` so existing worldgen ordinals stay frozen). Per-tile rolls flow through `mc_core::seed::derive_step(seed, SeedDomain::Geological, &[turn, col, row, channel])` (`mc-core/src/seed.rs:60-66`, `mc-mapgen/src/events.rs:183-198`). Save back-compat preserved: existing TileState ordinals untouched; only `is_active_volcano: bool` was appended with `#[serde(default)]`. Old det_roll mixer removed.
- `is_active_volcano: bool` field landed on `TileState` (`mc-core/src/grid/mod.rs:238-247`) with `#[serde(default) = false]`. Eruption branch (`events.rs:240-249`) gates on `(is_active_volcano || plate_kind ∈ {VOLCANIC_ARC, HOTSPOT})` — the flag is authoritative when set, the plate_kind proxy is retained as a fallback so saves predating the field keep firing eruptions. `mountain_proximity` still scales severity (magma-pressure proxy).
- ✓ `mc-ecology::tile::apply_damage` wiring — `mc-sim::event_dispatch::dispatch_world_events` routes each `GeologicalEvent` through `apply_damage(TileEcoState, DamageChannel::Land, severity)` (and additionally Air for volcanic_eruption). Dispatch lives in mc-sim (not mc-turn) to avoid the mc-turn ← mc-mapgen ← mc-ecology cycle. `ChronicleEntry::WorldEvent` pushed per event. Covered by `p3_13_event_dispatch_geological_applies_land_damage` in mc-sim. (`src/simulator/crates/mc-sim/src/event_dispatch.rs:104-120`)
- ✓ `cargo test -p mc-mapgen events::` green — 6 tests including `earthquake_only_at_plate_boundary`, `volcanic_eruption_only_on_volcanic_plate`, `landslide_requires_slope_and_saturation`, `determinism_same_seed_same_events`. (`src/simulator/crates/mc-mapgen/src/events.rs:262-372`)

View file

@ -234,6 +234,14 @@ pub struct TileState {
/// Proximity to coast (from plate geometry), 0.0 (deep interior) 1.0 (at coast).
#[serde(default)]
pub coast_proximity: f32,
/// True if this tile hosts an active volcano. Authoritative gate for the
/// `volcanic_eruption` branch of `mc_mapgen::events::derive_events`. Closes
/// the `is_active_volcano` bullet of p3-13b. `#[serde(default)]` keeps
/// existing saves backwards-compatible — saves without this field
/// deserialise it as `false`, and the eruption branch falls back to the
/// `plate_kind ∈ {VOLCANIC_ARC, HOTSPOT}` proxy in that case.
#[serde(default)]
pub is_active_volcano: bool,
// ── Climate axes fields (p2-49) ──────────────────────────────────────────
/// Signed latitude 1 (south pole) … 0 (equator) … +1 (north pole).
#[serde(default)]
@ -379,6 +387,7 @@ impl Default for TileState {
boundary_kind: 0,
mountain_proximity: 0.0,
coast_proximity: 0.0,
is_active_volcano: false,
latitude: 0.0,
continentality: 0.5,
mean_temp: 0.5,

View file

@ -58,6 +58,12 @@ pub enum SeedDomain {
/// yields the same SplitMix64 starting state in `AbstractPlayerState.rng_state`.
/// Appended at the end so existing variant ordinals stay frozen.
AiRollout = 7,
/// Geological event rolls (earthquake / volcanic_eruption / landslide) —
/// see `mc_mapgen::events::derive_events`. Mixed with `(turn, col, row,
/// channel)` via [`derive_step`]. Appended at the end of the enum so
/// existing worldgen ordinals stay frozen; closes the seed-domain bullet
/// of objective p3-13b.
Geological = 8,
}
/// Derive a deterministic per-step seed for ambient encounter rolls.
@ -234,6 +240,50 @@ mod tests {
assert_eq!(SeedDomain::AiRollout as u64, 7);
}
// ── Geological domain (p3-13b) ───────────────────────────────────────────
#[test]
fn geological_ordinal_is_eight() {
// Geological was appended after AiRollout=7. Existing ordinals stay
// frozen so save-format and worldgen golden tests remain byte-stable.
assert_eq!(SeedDomain::Geological as u64, 8);
}
#[test]
fn geological_domain_is_distinct_from_other_domains() {
let s = 0xBADC_0FFEu64;
let geo = derive(s, SeedDomain::Geological);
let others = [
SeedDomain::Tectonics,
SeedDomain::Erosion,
SeedDomain::Hydrology,
SeedDomain::Climate,
SeedDomain::FloraSelect,
SeedDomain::FaunaSelect,
SeedDomain::Encounter,
SeedDomain::AiRollout,
];
for d in others {
assert_ne!(
geo,
derive(s, d),
"Geological domain must be distinct from {d:?}"
);
}
}
#[test]
fn geological_derive_step_is_stable_per_tile_channel() {
let s = 0x1234_5678_9ABC_DEF0u64;
// Same (turn, col, row, channel) → same seed.
let a = derive_step(s, SeedDomain::Geological, &[5, 3, 7, 10]);
let b = derive_step(s, SeedDomain::Geological, &[5, 3, 7, 10]);
assert_eq!(a, b);
// Different channel → different seed.
let c = derive_step(s, SeedDomain::Geological, &[5, 3, 7, 11]);
assert_ne!(a, c);
}
#[test]
fn ai_rollout_domain_is_distinct_from_worldgen_domains() {
let s = 42u64;

View file

@ -16,6 +16,7 @@
//! physically gated on slope + saturation per task brief.
use mc_core::grid::GridState;
use mc_core::seed::{derive_step, SeedDomain};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -180,22 +181,20 @@ impl GeologicalThresholds {
}
}
/// Deterministic hash → [0.0, 1.0). Splitmix64 avalanche of the 4-tuple seed.
/// Mirrors `mc_climate::weather::det_roll` byte-for-byte so geological + weather rolls
/// share an identical determinism contract. Channel range chosen to never collide with
/// weather channels (1..=6) — geological channels start at 10.
/// Deterministic per-tile-channel roll → `[0.0, 1.0)` derived from the
/// canonical `SeedDomain::Geological` sub-seed. Closes the seed-domain bullet
/// of p3-13b by routing all geological rolls through the same SipHash mixer
/// the rest of worldgen uses (`mc_core::seed`). Channel discriminates per
/// branch (earthquake=10, eruption=11, landslide=12) so the three rolls on a
/// single tile are independent.
#[inline]
fn det_roll(seed: u64, turn: i32, col: i32, row: i32, channel: u32) -> f32 {
let mut x = seed
^ ((turn as u64) << 32)
^ ((col as u64) << 16)
^ (row as u64)
^ ((channel as u64) << 48);
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
((x >> 11) as f32) / ((1u64 << 53) as f32)
fn geo_roll(seed: u64, turn: i32, col: i32, row: i32, channel: u32) -> f32 {
let raw = derive_step(
seed,
SeedDomain::Geological,
&[turn as u64, col as u64, row as u64, channel as u64],
);
((raw >> 11) as f32) / ((1u64 << 53) as f32)
}
/// Derive per-turn geological events. Pure: no grid mutation.
@ -219,7 +218,7 @@ pub fn derive_events(
1.0
};
let chance = (thresholds.earthquake_trigger_chance * mult).clamp(0.0, 1.0);
if det_roll(seed, turn, tile.col, tile.row, 10) < chance {
if geo_roll(seed, turn, tile.col, tile.row, 10) < chance {
events.push(GeologicalEvent {
kind: "earthquake".to_string(),
col: tile.col,
@ -234,13 +233,17 @@ pub fn derive_events(
}
}
// VolcanicEruption: tile sits on a volcanic-arc or hotspot plate, with sufficient
// mountain_proximity (magma_pressure proxy).
// VolcanicEruption: tile is an active volcano (authoritative bool gate
// added in p3-13b closure). For saves and grids predating the field, fall
// back to the plate_kind ∈ {VOLCANIC_ARC, HOTSPOT} proxy so existing
// worldgens keep firing eruptions. `mountain_proximity` remains the
// magma_pressure proxy for severity scaling.
let is_volcanic_plate = tile.plate_kind == plate_kind::VOLCANIC_ARC
|| tile.plate_kind == plate_kind::HOTSPOT;
if is_volcanic_plate
let is_volcanic_tile = tile.is_active_volcano || is_volcanic_plate;
if is_volcanic_tile
&& tile.mountain_proximity >= thresholds.volcanic_eruption_magma_proxy_min
&& det_roll(seed, turn, tile.col, tile.row, 11)
&& geo_roll(seed, turn, tile.col, tile.row, 11)
< thresholds.volcanic_eruption_trigger_chance
{
events.push(GeologicalEvent {
@ -262,7 +265,7 @@ pub fn derive_events(
// Landslide: steep slope (mountain_proximity proxy) + saturated tile (moisture).
if tile.mountain_proximity >= thresholds.landslide_slope_proxy_min
&& tile.moisture >= thresholds.landslide_moisture_min
&& det_roll(seed, turn, tile.col, tile.row, 12)
&& geo_roll(seed, turn, tile.col, tile.row, 12)
< thresholds.landslide_trigger_chance
{
events.push(GeologicalEvent {
@ -391,6 +394,64 @@ mod tests {
assert_eq!(events.len(), 0);
}
#[test]
fn volcanic_eruption_fires_on_active_volcano_flag_without_plate_kind() {
// is_active_volcano = true on a CONTINENTAL plate (not VOLCANIC_ARC/HOTSPOT)
// should still gate the eruption branch open — closes the p3-13b
// is_active_volcano bullet.
let mut g = make_grid(plate_kind::CONTINENTAL, boundary_kind::NONE, 0.80, 0.20);
for t in &mut g.tiles {
t.is_active_volcano = true;
}
let mut t = GeologicalThresholds::default();
t.volcanic_eruption_trigger_chance = 1.0;
let events = derive_events(&g, &t, 1, 42);
assert_eq!(
events.len(),
16,
"expected 16 eruptions when is_active_volcano=true on every tile"
);
assert!(events.iter().all(|e| e.kind == "volcanic_eruption"));
}
#[test]
fn volcanic_eruption_back_compat_plate_kind_when_flag_default_false() {
// Existing saves without is_active_volcano (defaults to false) must still
// fire eruptions via the plate_kind proxy fallback.
let g = make_grid(plate_kind::VOLCANIC_ARC, boundary_kind::NONE, 0.80, 0.20);
// No tile.is_active_volcano set → default false.
assert!(g.tiles.iter().all(|t| !t.is_active_volcano));
let mut t = GeologicalThresholds::default();
t.volcanic_eruption_trigger_chance = 1.0;
let events = derive_events(&g, &t, 1, 42);
assert_eq!(
events.len(),
16,
"plate_kind fallback must still fire eruptions for back-compat"
);
}
#[test]
fn determinism_swap_to_seed_domain_is_stable() {
// The geo_roll swap (det_roll → SeedDomain::Geological derive_step)
// must still produce a deterministic byte-stable stream: two calls
// with the same (seed, turn) are identical. (The exact byte values
// changed when we moved off det_roll — this test pins the new
// contract going forward.)
let g = make_grid(plate_kind::VOLCANIC_ARC, boundary_kind::CONVERGENT, 0.70, 0.70);
let t = GeologicalThresholds {
earthquake_trigger_chance: 0.5,
volcanic_eruption_trigger_chance: 0.5,
landslide_trigger_chance: 0.5,
..GeologicalThresholds::default()
};
let a = derive_events(&g, &t, 11, 0xCAFE_BABE);
let b = derive_events(&g, &t, 11, 0xCAFE_BABE);
assert_eq!(a, b, "SeedDomain::Geological rolls must be deterministic");
let c = derive_events(&g, &t, 12, 0xCAFE_BABE);
assert_ne!(a, c, "different turn must produce a different event set");
}
#[test]
fn thresholds_load_from_spec_json() {
let spec: Value = serde_json::from_str(