From 9ccc7e10ff970bbbc1ef2f3658e83f7b8551ea60 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 26 Jun 2026 10:35:14 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=8E=B2=20p3-26=20gap=202=20(start)=20=E2=80=94=20determin?= =?UTF-8?q?istic=20event=20core=20ported=20to=20mc-climate::events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../p3-26-complete-headless-simulator.md | 2 +- .../games/age-of-dwarves/data/objectives.json | 6 +- src/simulator/crates/mc-climate/src/events.rs | 98 +++++++++++++++++++ src/simulator/crates/mc-climate/src/lib.rs | 1 + 4 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 src/simulator/crates/mc-climate/src/events.rs diff --git a/.project/objectives/p3-26-complete-headless-simulator.md b/.project/objectives/p3-26-complete-headless-simulator.md index 216d42fe..2ea1aec2 100644 --- a/.project/objectives/p3-26-complete-headless-simulator.md +++ b/.project/objectives/p3-26-complete-headless-simulator.md @@ -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 diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index b6cd4d77..6cc6ecd3 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -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 }, 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..2883c41b --- /dev/null +++ b/src/simulator/crates/mc-climate/src/events.rs @@ -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)); + } +} diff --git a/src/simulator/crates/mc-climate/src/lib.rs b/src/simulator/crates/mc-climate/src/lib.rs index b3ad542c..5254d0ec 100644 --- a/src/simulator/crates/mc-climate/src/lib.rs +++ b/src/simulator/crates/mc-climate/src/lib.rs @@ -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;