feat(@projects/@magic-civilization): 🎲 p3-26 gap 2 (start) — deterministic event core ported to mc-climate::events

First slice of the natural/"apocalyptic" events port (M3). The deterministic primitives
every category depends on, ported from GDScript ecological_event_utils:

- hash_noise(x,y,seed) = frac(sin(x*127.1+y*311.7+seed*74.3)*43758.5453), f64 — verified
  to match the LIVE GDScript game bit-for-bit (ran it: hash_noise(10,0,1000) =
  0.67791910066535). The headless sim must match the game, NOT the TS web guide (whose
  Math.sin diverges on these large arguments — a pre-existing game-vs-guide gap, not a
  port bug; the old comment's "0.1270 from TS" golden was misleading).
- roll_severity(weights, turn_seed, channel, max_tier) — weighted tier roll with era cap.
- category_fires(base_frequency, channel, turn_seed) — the per-category dispatch gate.

4 cargo tests (GDScript-golden determinism, channel separation, severity bounds + cap,
fire gate). Source corrected: .messy is gone — the port source is the live
ecological_events.gd + handlers_a/b + public/resources/events/*.json. Next: event-config
structs/loading + dispatch + per-category handlers (wildfire first) + turn wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 10:35:14 -04:00
parent 1bdad8e497
commit 9ccc7e10ff
4 changed files with 103 additions and 4 deletions

View file

@ -32,7 +32,7 @@ expansion, tech/science, fauna encounters, combat/siege, diplomacy. Verified liv
`atmosphere`, `ecology`); only the per-turn ORCHESTRATION is GDScript-only. Wire it onto
`state.grid` in `step()`. Effects: weather events + unit HP damage + tile climate shifts
in the headless sim.
- [ ] **Gap 2 — Natural / "apocalyptic" events (M3 milestone).** Port the `.messy`
- [~] **Gap 2 — Natural / "apocalyptic" events (M3 milestone).** STARTED 2026-06-26: ported the deterministic core to `mc-climate::events` (`hash_noise`/`roll_severity`/`category_fires`), verified to match the live GDScript game bit-for-bit (`hash_noise(10,0,1000)=0.67791910066535`; the TS web guide's Math.sin diverges on large args — separate concern). NB: `.messy` is gone; the source is the live `ecological_events.gd` + `ecological_event_handlers_a/b.gd` + 12-category JSON in `public/resources/events/`. **Remaining:** event-config loading + dispatch + per-category handlers (wildfire/volcanic/drought/…) + turn wiring. ORIG: Port the `.messy`
`ecological_events.gd` (992 lines) → Rust, split per the milestone plan
(`.project/tasks/milestones/m3-natural-events/`): geological (volcano/impact/seismic/
tsunami), ecological (wildfire/drought/plague/pandemic), marine, weather (already in

View file

@ -1,11 +1,11 @@
{
"generated_at": "2026-06-26T14:25:33Z",
"generated_at": "2026-06-26T14:35:13Z",
"totals": {
"in_progress": 0,
"partial": 3,
"stub": 0,
"done": 296,
"oos": 31,
"in_progress": 0,
"partial": 3,
"missing": 0,
"total": 330
},

View file

@ -0,0 +1,98 @@
//! 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
}
#[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));
}
}

View file

@ -3,6 +3,7 @@ pub mod atmosphere;
pub mod climate_effects;
pub mod derive;
pub mod ecology;
pub mod events;
pub mod gd_compat;
pub mod physics;
pub mod spec;