From aa4d0f86a0b53201e5dc2de8f35f076afba7dd86 Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 13 May 2026 12:22:12 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20implement=20geological=20events=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../objectives/p3-13b-geological-events.md | 25 +++-- src/simulator/crates/mc-core/src/grid/mod.rs | 9 ++ src/simulator/crates/mc-core/src/seed.rs | 50 +++++++++ src/simulator/crates/mc-mapgen/src/events.rs | 103 ++++++++++++++---- 4 files changed, 156 insertions(+), 31 deletions(-) diff --git a/.project/objectives/p3-13b-geological-events.md b/.project/objectives/p3-13b-geological-events.md index 3fb332dd..01245342 100644 --- a/.project/objectives/p3-13b-geological-events.md +++ b/.project/objectives/p3-13b-geological-events.md @@ -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`) diff --git a/src/simulator/crates/mc-core/src/grid/mod.rs b/src/simulator/crates/mc-core/src/grid/mod.rs index 1cf39ecc..e6ff62d4 100644 --- a/src/simulator/crates/mc-core/src/grid/mod.rs +++ b/src/simulator/crates/mc-core/src/grid/mod.rs @@ -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, diff --git a/src/simulator/crates/mc-core/src/seed.rs b/src/simulator/crates/mc-core/src/seed.rs index 30311e22..709e49fc 100644 --- a/src/simulator/crates/mc-core/src/seed.rs +++ b/src/simulator/crates/mc-core/src/seed.rs @@ -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; diff --git a/src/simulator/crates/mc-mapgen/src/events.rs b/src/simulator/crates/mc-mapgen/src/events.rs index 1eb20111..ab06d9e0 100644 --- a/src/simulator/crates/mc-mapgen/src/events.rs +++ b/src/simulator/crates/mc-mapgen/src/events.rs @@ -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(