From 8bc770d8a0f20ee7586c478293c5b5ae17e4a349 Mon Sep 17 00:00:00 2001 From: autocommit Date: Sat, 6 Jun 2026 16:03:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(worldsim):=20=E2=9C=A8=20Add=20core=20even?= =?UTF-8?q?t=20dispatch=20and=20simulation=20loop=20for=20Minecraft-like?= =?UTF-8?q?=20world=20simulator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-worldsim/src/event_dispatch.rs | 456 ++++++++++++++++ src/simulator/crates/mc-worldsim/src/lib.rs | 501 ++++++++++++++++++ 2 files changed, 957 insertions(+) create mode 100644 src/simulator/crates/mc-worldsim/src/event_dispatch.rs create mode 100644 src/simulator/crates/mc-worldsim/src/lib.rs diff --git a/src/simulator/crates/mc-worldsim/src/event_dispatch.rs b/src/simulator/crates/mc-worldsim/src/event_dispatch.rs new file mode 100644 index 00000000..d0433df7 --- /dev/null +++ b/src/simulator/crates/mc-worldsim/src/event_dispatch.rs @@ -0,0 +1,456 @@ +//! p3-13: world event dispatch — geological, biological, anomalous. +//! +//! Lives in `mc-worldsim` rather than `mc-turn` because the three derive crates +//! (mc-mapgen, mc-ecology, mc-climate) cannot be depended on by mc-turn without +//! creating a cycle: mc-mapgen → mc-turn already exists for the spawn-box +//! prologue contract, and mc-ecology → mc-mapgen, so both chains return to +//! mc-turn. mc-worldsim sits above all of them and is the correct orchestration +//! layer — it owns the per-turn `WorldSim::step` wiring and calls this function +//! after `TurnProcessor::step` returns. +//! +//! ## Call site +//! +//! Call `dispatch_world_events` *after* `TurnProcessor::step` returns, passing +//! the same `GameState`. The function is a no-op when `state.grid` is `None` +//! (headless unit-test path). +//! +//! ## Damage store +//! +//! `TileState` (mc-core) does not embed `TileEcoState` (mc-ecology) because +//! mc-core cannot depend on mc-ecology. The caller owns a `BTreeMap<(u16,u16), +//! TileEcoState>` keyed by `(col as u16, row as u16)` and passes it as +//! `eco_map`. A `BTreeMap` (not `HashMap`) gives the accumulator a deterministic +//! serialized byte order, which the save-persistence layer relies on. The +//! dispatch function accumulates damage hits there; the caller +//! persists the map alongside the game save. +//! +//! ## Event → damage channel mapping +//! +//! | Category | Kind | DamageChannel | +//! |-------------|-------------------|---------------| +//! | geological | earthquake | Land | +//! | geological | volcanic_eruption | Land + Air | +//! | geological | landslide | Land | +//! | biological | plague | Water | +//! | biological | bloom | — (positive) | +//! | biological | migration_pulse | — (neutral) | +//! | anomalous | aurora | — (cosmetic) | +//! | anomalous | fog_bank | Air (stub 0.1)| +//! | anomalous | thermal_anomaly | Air (stub) | +//! +//! ## FogBank → apply_fog +//! +//! For each `AnomalousEvent::FogBank`, the flat tile index is computed from +//! `(col, row)` and the grid width, then forwarded to +//! `mc_observation::fog::apply_fog` on `state.fog_map`. +//! +//! ## Chronicle +//! +//! One `ChronicleEntry::WorldEvent` is pushed per emitted event so the history +//! panel and balance scrapers have a complete turn record. + +use std::collections::BTreeMap; + +use mc_climate::anomalous::{derive_anomalous_events, AnomalousEvent, AnomalousThresholds}; +use mc_core::DamageChannel; +use mc_ecology::biological::{ + advance_bloom_streak, derive_biological_events, BiologicalEvent, BiologicalThresholds, +}; +use mc_ecology::tile::{apply_damage, TileEcoState}; +use mc_mapgen::events::{derive_events as derive_geological_events, GeologicalThresholds}; +use mc_observation::apply_fog; +use mc_turn::chronicle::{Chronicle, ChronicleEntry}; +use mc_state::game_state::GameState; + +/// Run all three world-event derivation passes for this turn and apply their +/// side-effects to `state` and `eco_map`. +/// +/// Returns the total number of events dispatched (useful for tests / balance +/// logging). Returns `0` immediately when `state.grid` is `None`. +/// +/// `seed` should be the game's master seed. The caller owns seed derivation +/// so tests can pass arbitrary values. +/// +/// `eco_map` is a sparse per-tile ecological damage accumulator, keyed by +/// `(col as u16, row as u16)`. The caller persists this alongside the game +/// save. Entries are never removed by this function — saturation is via +/// `u16::MAX` within `apply_damage`. +pub fn dispatch_world_events( + state: &mut GameState, + seed: u64, + geo_thresholds: &GeologicalThresholds, + bio_thresholds: &BiologicalThresholds, + anomalous_thresholds: &AnomalousThresholds, + eco_map: &mut BTreeMap<(u16, u16), TileEcoState>, + chronicle: &mut Chronicle, +) -> usize { + // Advance the bloom streak counter once per turn BEFORE derivation so the + // "N consecutive turns of favourable climate" gate (p3-13c) observes the + // freshly-advanced streak. Mutable borrow scoped tightly so the immutable + // grid ref below is valid for the rest of the pass. + if let Some(g_mut) = state.grid.as_mut() { + advance_bloom_streak(g_mut, bio_thresholds); + } + + let grid = match state.grid.as_ref() { + Some(g) => g, + None => return 0, + }; + + let turn_i32 = state.turn as i32; + let turn_u32 = state.turn; + + // ── Geological events ──────────────────────────────────────────────────── + let geo_events = derive_geological_events(grid, geo_thresholds, turn_i32, seed); + + // ── Biological events ──────────────────────────────────────────────────── + let bio_events = derive_biological_events(grid, bio_thresholds, turn_i32, seed); + + // ── Anomalous events ───────────────────────────────────────────────────── + // No temp_history plumbed yet — ThermalAnomaly is skipped per design when + // the slice is None. + let anom_events = derive_anomalous_events(grid, anomalous_thresholds, turn_i32, seed, None); + + let total = geo_events.len() + bio_events.len() + anom_events.len(); + + // ── Apply geological damage + chronicle ────────────────────────────────── + for ev in &geo_events { + let key = (ev.col as u16, ev.row as u16); + let eco = eco_map.entry(key).or_insert_with(TileEcoState::clean); + apply_damage(eco, DamageChannel::Land, ev.severity); + if ev.kind == "volcanic_eruption" { + apply_damage(eco, DamageChannel::Air, ev.severity * 0.5); + } + + chronicle.push(ChronicleEntry::WorldEvent { + turn: turn_u32, + category: "geological".to_string(), + kind: ev.kind.clone(), + col: ev.col, + row: ev.row, + severity_milli: (ev.severity * 1000.0) as i32, + }); + } + + // ── Apply biological damage + chronicle ────────────────────────────────── + for ev in &bio_events { + let (col, row, severity, kind) = match ev { + BiologicalEvent::Plague { col, row, severity } => { + let key = (*col as u16, *row as u16); + let eco = eco_map.entry(key).or_insert_with(TileEcoState::clean); + apply_damage(eco, DamageChannel::Water, *severity); + (*col, *row, *severity, "plague") + } + BiologicalEvent::Bloom { col, row, intensity } => { + // Positive event — no damage counters. + (*col, *row, *intensity, "bloom") + } + BiologicalEvent::MigrationPulse { from_col, from_row, magnitude, .. } => { + // Neutral event — no damage counters. + (*from_col, *from_row, *magnitude, "migration_pulse") + } + }; + + chronicle.push(ChronicleEntry::WorldEvent { + turn: turn_u32, + category: "biological".to_string(), + kind: kind.to_string(), + col, + row, + severity_milli: (severity * 1000.0) as i32, + }); + } + + // ── Apply anomalous effects + chronicle ─────────────────────────────────── + let current_turn_u16 = (state.turn as u16).min(u16::MAX); + for ev in &anom_events { + let (col, row, severity, kind) = match ev { + AnomalousEvent::Aurora { col, row } => { + // Cosmetic — no counters. + (*col, *row, 0.0_f32, "aurora") + } + AnomalousEvent::FogBank { col, row, duration } => { + // Apply fog to the game state fog map (flat tile index). + // The grid width is safe to access here because we already + // confirmed grid is Some above; re-borrow briefly. + let grid_width = state.grid.as_ref().map(|g| g.width).unwrap_or(1); + let flat_idx = ((*row * grid_width) + *col) as u16; + apply_fog(flat_idx, *duration, current_turn_u16, &mut state.fog_map); + + // Stub Air accumulation (historical only — 0.1 hit). + let key = (*col as u16, *row as u16); + let eco = eco_map.entry(key).or_insert_with(TileEcoState::clean); + apply_damage(eco, DamageChannel::Air, 0.1); + + (*col, *row, *duration as f32, "fog_bank") + } + AnomalousEvent::ThermalAnomaly { col, row, z_score } => { + let key = (*col as u16, *row as u16); + let eco = eco_map.entry(key).or_insert_with(TileEcoState::clean); + apply_damage(eco, DamageChannel::Air, z_score.abs() * 0.1); + (*col, *row, *z_score, "thermal_anomaly") + } + }; + + chronicle.push(ChronicleEntry::WorldEvent { + turn: turn_u32, + category: "anomalous".to_string(), + kind: kind.to_string(), + col, + row, + severity_milli: (severity * 1000.0) as i32, + }); + } + + total +} + +#[cfg(test)] +mod tests { + use super::*; + use mc_core::grid::GridState; + use mc_mapgen::tectonics::{boundary_kind, plate_kind}; + + /// Build a minimal 4×3 grid with tiles configured to fire at least one + /// event per category under the low-threshold configs below. + fn seeded_grid() -> GridState { + let mut grid = GridState::new(4, 3); + + // ── Geological: earthquake (convergent boundary) ────────────────────── + let idx00 = grid.idx(0, 0); + grid.tiles[idx00].boundary_kind = boundary_kind::CONVERGENT; + grid.tiles[idx00].mountain_proximity = 0.9; + grid.tiles[idx00].col = 0; + grid.tiles[idx00].row = 0; + + // ── Geological: volcanic eruption ───────────────────────────────────── + let idx10 = grid.idx(1, 0); + grid.tiles[idx10].plate_kind = plate_kind::VOLCANIC_ARC; + grid.tiles[idx10].mountain_proximity = 0.8; + grid.tiles[idx10].col = 1; + grid.tiles[idx10].row = 0; + + // ── Geological: landslide (slope+saturation proxy) ─────────────────── + let idx20 = grid.idx(2, 0); + grid.tiles[idx20].mountain_proximity = 0.85; + grid.tiles[idx20].moisture = 0.9; + grid.tiles[idx20].col = 2; + grid.tiles[idx20].row = 0; + + // ── Biological: plague (high civ-presence, low quality) ─────────────── + let idx01 = grid.idx(0, 1); + grid.tiles[idx01].civilization_presence = 0.9; + grid.tiles[idx01].quality = 0; + grid.tiles[idx01].col = 0; + grid.tiles[idx01].row = 1; + + // ── Biological: bloom (warm + wet + flora-rich) ─────────────────────── + let idx11 = grid.idx(1, 1); + grid.tiles[idx11].mean_temp = 0.7; // normalised 0-1 (post-climate-derive) + grid.tiles[idx11].mean_precip = 0.7; + grid.tiles[idx11].canopy_cover = 0.7; + grid.tiles[idx11].undergrowth = 0.6; + grid.tiles[idx11].col = 1; + grid.tiles[idx11].row = 1; + + // ── Anomalous: aurora (high |latitude|, low humidity) ───────────────── + let idx02 = grid.idx(0, 2); + grid.tiles[idx02].latitude = 0.85; + grid.tiles[idx02].humidity = 0.05; + grid.tiles[idx02].col = 0; + grid.tiles[idx02].row = 2; + + // ── Anomalous: fog_bank (high humidity, low temperature) ───────────── + let idx12 = grid.idx(1, 2); + grid.tiles[idx12].humidity = 0.95; + grid.tiles[idx12].temperature = 0.05; + grid.tiles[idx12].col = 1; + grid.tiles[idx12].row = 2; + + grid + } + + fn make_state_with_grid() -> GameState { + let mut state = mc_state::game_state::GameState::default(); + state.grid = Some(seeded_grid()); + state.turn = 1; + state + } + + fn low_geo_thresholds() -> GeologicalThresholds { + GeologicalThresholds { + earthquake_trigger_chance: 1.0, + // multiplier=4 → severity = (4*0.5).clamp(0.1, 1.0) = 1.0 → 1 hit in apply_damage + earthquake_convergent_multiplier: 4.0, + earthquake_radius: 1, + earthquake_elevation_delta: -0.05, + earthquake_movement_penalty: 0.2, + earthquake_unit_damage: 5, + volcanic_eruption_trigger_chance: 1.0, + volcanic_eruption_magma_proxy_min: 0.0, + volcanic_eruption_radius: 2, + volcanic_eruption_elevation_delta: 0.1, + volcanic_eruption_movement_penalty: 0.5, + volcanic_eruption_unit_damage: 15, + landslide_slope_proxy_min: 0.5, + landslide_moisture_min: 0.5, + landslide_trigger_chance: 1.0, + landslide_radius: 1, + landslide_elevation_delta: -0.1, + landslide_movement_penalty: 0.3, + landslide_unit_damage: 8, + } + } + + fn low_bio_thresholds() -> BiologicalThresholds { + BiologicalThresholds { + // plague_civ_min=0.0 → severity = ((1.0-0.0)*2.0).clamp=1.0 → 1 hit + plague_civ_min: 0.0, + plague_quality_max: 4, // catch all quality levels + plague_trigger_chance: 1.0, + plague_spread_factor: 0.5, + plague_spread_severity_scale: 0.5, + bloom_temp_min: 0.5, + bloom_temp_max: 0.9, + bloom_precip_min: 0.5, + bloom_canopy_min: 0.5, + bloom_undergrowth_min: 0.4, + bloom_trigger_chance: 1.0, + bloom_streak_min: 0, + migration_source_min: 5.0, + migration_neighbour_max: 50.0, + migration_differential_min: 3.0, + migration_trigger_chance: 1.0, + migration_max_hops: 3, + } + } + + fn low_anomalous_thresholds() -> AnomalousThresholds { + AnomalousThresholds { + aurora_latitude_min: 0.75, + aurora_humidity_max: 0.2, + aurora_trigger_chance: 1.0, + fog_bank_humidity_min: 0.8, + fog_bank_temperature_max: 0.3, + fog_bank_trigger_chance: 1.0, + fog_bank_cooldown_turns: 1, + fog_bank_duration_turns: 3, + thermal_anomaly_z_threshold: 2.5, + thermal_anomaly_base_chance: 0.001, + thermal_anomaly_volcanic_bonus: 0.02, + } + } + + // ── p3_13_event_dispatch ────────────────────────────────────────────────── + + #[test] + fn p3_13_event_dispatch_geological_applies_land_damage() { + let mut state = make_state_with_grid(); + let mut eco_map: BTreeMap<(u16, u16), TileEcoState> = BTreeMap::new(); + let mut chronicle = Chronicle::new(); + + dispatch_world_events( + &mut state, + 42, + &low_geo_thresholds(), + &low_bio_thresholds(), + &low_anomalous_thresholds(), + &mut eco_map, + &mut chronicle, + ); + + // Chronicle has at least one geological entry — primary coverage check. + let has_geo = chronicle.entries().iter().any(|e| { + matches!(e, ChronicleEntry::WorldEvent { category, .. } if category == "geological") + }); + assert!(has_geo, "expected at least one geological ChronicleEntry::WorldEvent"); + + // At least one eco_map entry carries non-zero Land damage from geo events. + let has_land_damage = eco_map.values().any(|eco| eco.land_pollution_count > 0); + assert!( + has_land_damage, + "expected Land channel damage from geological events; eco_map keys = {:?}", + eco_map.keys().collect::>() + ); + } + + #[test] + fn p3_13_event_dispatch_biological_plague_applies_water_damage() { + let mut state = make_state_with_grid(); + let mut eco_map: BTreeMap<(u16, u16), TileEcoState> = BTreeMap::new(); + let mut chronicle = Chronicle::new(); + + dispatch_world_events( + &mut state, + 42, + &low_geo_thresholds(), + &low_bio_thresholds(), + &low_anomalous_thresholds(), + &mut eco_map, + &mut chronicle, + ); + + // Chronicle must have at least one biological entry. + let has_bio = chronicle.entries().iter().any(|e| { + matches!(e, ChronicleEntry::WorldEvent { category, .. } if category == "biological") + }); + assert!(has_bio, "expected at least one biological ChronicleEntry::WorldEvent"); + + // At least one eco_map entry carries non-zero Water damage from plague. + let has_water_damage = eco_map.values().any(|eco| eco.water_pollution_count > 0); + assert!( + has_water_damage, + "expected Water channel damage from plague in eco_map" + ); + } + + #[test] + fn p3_13_event_dispatch_anomalous_fog_populates_fog_map() { + let mut state = make_state_with_grid(); + let mut eco_map: BTreeMap<(u16, u16), TileEcoState> = BTreeMap::new(); + let mut chronicle = Chronicle::new(); + + dispatch_world_events( + &mut state, + 42, + &low_geo_thresholds(), + &low_bio_thresholds(), + &low_anomalous_thresholds(), + &mut eco_map, + &mut chronicle, + ); + + // state.fog_map should have at least one entry for the FogBank tile. + assert!( + !state.fog_map.is_empty(), + "expected fog_map to have at least one entry after FogBank dispatch" + ); + + let has_anom = chronicle.entries().iter().any(|e| { + matches!(e, ChronicleEntry::WorldEvent { category, .. } if category == "anomalous") + }); + assert!(has_anom, "expected at least one anomalous ChronicleEntry::WorldEvent"); + } + + #[test] + fn p3_13_event_dispatch_noop_without_grid() { + let mut state = mc_state::game_state::GameState::default(); + // state.grid is None by default — headless unit-test path. + let mut eco_map: BTreeMap<(u16, u16), TileEcoState> = BTreeMap::new(); + let mut chronicle = Chronicle::new(); + + let n = dispatch_world_events( + &mut state, + 42, + &low_geo_thresholds(), + &low_bio_thresholds(), + &low_anomalous_thresholds(), + &mut eco_map, + &mut chronicle, + ); + + assert_eq!(n, 0, "should be no-op when grid is None"); + assert!(chronicle.is_empty(), "chronicle should be untouched when grid is None"); + assert!(eco_map.is_empty(), "eco_map should be untouched when grid is None"); + } +} diff --git a/src/simulator/crates/mc-worldsim/src/lib.rs b/src/simulator/crates/mc-worldsim/src/lib.rs new file mode 100644 index 00000000..439ab2fe --- /dev/null +++ b/src/simulator/crates/mc-worldsim/src/lib.rs @@ -0,0 +1,501 @@ +//! `mc-worldsim` — per-turn world-simulation orchestrator. +//! +//! This crate sits ABOVE `mc-turn` and is the first crate in the dependency +//! graph allowed to depend on all three "derive" crates (`mc-mapgen`, +//! `mc-ecology`, `mc-climate`) at once. `mc-turn` cannot, because each of those +//! chains back to `mc-turn` (the spawn-box prologue contract: `mc-mapgen → +//! mc-turn`, and `mc-ecology → mc-mapgen`), which would form a cycle. The +//! continuous worldsim ticks therefore cannot live inside `TurnProcessor`; +//! they live here and run immediately after it. +//! +//! ## What `WorldSim::step` does, in order +//! +//! 1. `TurnProcessor::step(state)` — the discrete game turn (cities, units, +//! combat, victory). +//! 2. Climate `step_remaining` — moisture/rivers/precipitation/runoff and the +//! terrain-evolution check, against `state.grid`. +//! 3. Ecology `process_step` — Lotka-Volterra population dynamics, dispersal, +//! ecosystem-tier advancement, and fish stocks, against `state.grid` and the +//! `EcologyEngine`'s owned per-tile population map. +//! 4. `dispatch_world_events` — geological / biological / anomalous events, +//! accumulating per-tile damage into the owned `eco_map`. +//! +//! Steps 2–4 are no-ops when `state.grid` is `None` (headless balance-sim path). +//! +//! ## Determinism (worldgen RNG contract) +//! +//! The ecology tick's RNG stream is derived deterministically from the game's +//! master `seed` via [`mc_core::seed::derive_step`] under the +//! [`SeedDomain::WorldsimDynamics`] domain, mixed with the current turn. Two +//! `WorldSim` instances built from the same seed + configs and stepped the same +//! number of turns produce byte-identical serialized eco-state. No wall-clock +//! time, no thread-RNG. See `docs/terrain/WORLDGEN_RNG.md`. +//! +//! ## Ownership / persistence +//! +//! `WorldSim` owns the side-structures that `GameState` cannot embed (mc-core +//! cannot depend on mc-ecology): +//! - `eco_map: BTreeMap<(u16, u16), TileEcoState>` — sparse per-tile damage. +//! A `BTreeMap` (not `HashMap`) so its serialized byte order is deterministic +//! — the save-persistence increment depends on this. +//! - `ecology: EcologyEngine` — owns the per-tile population map + species +//! registry + config. +//! - `chronicle: Chronicle` — one `WorldEvent` entry per dispatched event. +//! +//! A later increment persists these alongside the game save and wires +//! `WorldSim::step` into the api-gdext per-turn bridge. + +pub mod event_dispatch; + +use std::collections::BTreeMap; + +use mc_climate::anomalous::AnomalousThresholds; +use mc_climate::ClimatePhysics; +use mc_core::seed::{derive_step, SeedDomain}; +use mc_ecology::biological::BiologicalThresholds; +use mc_ecology::tile::TileEcoState; +use mc_ecology::EcologyEngine; +use mc_mapgen::events::GeologicalThresholds; +use mc_state::game_state::GameState; +use mc_turn::chronicle::Chronicle; +use mc_turn::{TurnProcessor, TurnResult}; + +pub use event_dispatch::dispatch_world_events; + +/// Per-turn simulation timestep handed to the continuous worldsim engines. +/// One game turn advances the continuous sim by `dt = 1.0`. +const TURN_DT: f32 = 1.0; + +/// Outcome of a single [`WorldSim::step`]. +#[derive(Debug, Clone)] +pub struct StepResult { + /// The discrete-turn result from `TurnProcessor::step` (winner, etc.). + pub turn: TurnResult, + /// Number of world events dispatched this turn (geological + biological + + /// anomalous). `0` on the headless / grid-less path. + pub world_events: usize, +} + +/// Per-turn world-simulation orchestrator. Owns the continuous-sim engines and +/// the persistent side-structures (`eco_map`, ecology populations, chronicle) +/// that `GameState` cannot embed without creating a crate cycle. +pub struct WorldSim { + processor: TurnProcessor, + climate: ClimatePhysics, + ecology: EcologyEngine, + + geo_thresholds: GeologicalThresholds, + bio_thresholds: BiologicalThresholds, + anomalous_thresholds: AnomalousThresholds, + + /// Game master seed — drives all deterministic continuous-tick RNG. + seed: u64, + + /// Sparse per-tile ecological damage accumulator, keyed by + /// `(col as u16, row as u16)`. `BTreeMap` for deterministic serialization. + pub eco_map: BTreeMap<(u16, u16), TileEcoState>, + /// Turn-by-turn world-event history (geological / biological / anomalous). + pub chronicle: Chronicle, +} + +impl WorldSim { + /// Construct a `WorldSim` from caller-loaded engines, thresholds, and seed. + /// + /// Per Rail 2 (JSON game packs are canonical content) the caller loads + /// `ClimatePhysics` from its params/terrain/spec JSON, builds the + /// `EcologyEngine` (optionally `with_species_library` from JSON), and passes + /// the three event-threshold configs (also JSON-sourced). Tests pass the + /// engines with empty `"{}"` JSON and `Default` thresholds. + #[must_use] + pub fn new( + max_turns: u32, + climate: ClimatePhysics, + ecology: EcologyEngine, + geo_thresholds: GeologicalThresholds, + bio_thresholds: BiologicalThresholds, + anomalous_thresholds: AnomalousThresholds, + seed: u64, + ) -> Self { + Self { + processor: TurnProcessor::new(max_turns), + climate, + ecology, + geo_thresholds, + bio_thresholds, + anomalous_thresholds, + seed, + eco_map: BTreeMap::new(), + chronicle: Chronicle::new(), + } + } + + /// Read-only view of the owned ecology engine (population map, registry). + #[must_use] + pub fn ecology(&self) -> &EcologyEngine { + &self.ecology + } + + /// Mutable view of the owned ecology engine — used by save-restore to + /// overwrite `tile_populations` after the species registry is rebuilt. + pub fn ecology_mut(&mut self) -> &mut EcologyEngine { + &mut self.ecology + } + + /// Overwrite the persisted worldsim side-state on a freshly-constructed + /// `WorldSim` after a load: the per-tile eco-damage accumulator and the + /// live fauna population map. The species registry must already be + /// populated (e.g. via `EcologyEngine::with_species_library` + + /// re-registration) before calling, so the restored slots' `species_id`s + /// resolve on the next `tick_populations`. This is the in-Rust mirror of + /// the api-gdext `GdWorldSim::restore_eco_map_from_json` + + /// `GdFaunaEcology::restore_tile_populations_from_json` pair. + pub fn restore_state( + &mut self, + eco_map: BTreeMap<(u16, u16), TileEcoState>, + tile_populations: BTreeMap<(i32, i32), Vec>, + ) { + self.eco_map = eco_map; + self.ecology.tile_populations = tile_populations; + } + + /// Advance the whole world by one turn: discrete game turn, then the + /// continuous climate + ecology ticks, then world-event dispatch. + /// + /// The continuous ticks and dispatch are no-ops when `state.grid` is `None`. + pub fn step(&mut self, state: &mut GameState) -> StepResult { + // 1. Discrete game turn (cities, units, combat, victory). + let turn = self.processor.step(state); + + // 2–4. Continuous worldsim, only when a real map is present. + let turn_idx = state.turn; + let world_events = if let Some(grid) = state.grid.as_mut() { + // 2. Climate continuous step. `process_step` is the complete CPU + // climate turn — it rebuilds the per-tile work cache (albedo / + // evapotranspiration / flags) before running the sub-steps, then + // runs the same `step_remaining` pass (moisture, rivers, + // precipitation, runoff, terrain evolution). Climate is + // deterministic from grid state; the `seed` arg is unused by it. + self.climate.process_step(grid, turn_idx, self.seed, TURN_DT); + + // 3. Ecology continuous step. Deterministic per-turn RNG stream: + // SeedDomain::WorldsimDynamics mixed with the turn index. + let eco_seed = + derive_step(self.seed, SeedDomain::WorldsimDynamics, &[u64::from(turn_idx)]); + self.ecology.process_step(grid, TURN_DT, eco_seed); + + // 4. World-event dispatch (geological / biological / anomalous). + dispatch_world_events( + state, + self.seed, + &self.geo_thresholds, + &self.bio_thresholds, + &self.anomalous_thresholds, + &mut self.eco_map, + &mut self.chronicle, + ) + } else { + 0 + }; + + StepResult { turn, world_events } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mc_core::algorithms::hex; + use mc_core::grid::GridState; + + const MAP: i32 = 16; + const SEED: u64 = 0xC0FF_EE42; + + /// Build a deterministic terrain grid identical to the one used by the + /// ecology/climate proof binaries (tier_timeline pattern). + fn build_grid() -> GridState { + let mut grid = GridState::new(MAP, MAP); + grid.o2_fraction = 0.21; + for tile in &mut grid.tiles { + let noise = hex::hash_noise(f64::from(tile.col), f64::from(tile.row), SEED as f64) as f32; + let lat = + 1.0 - ((tile.row as f32 - MAP as f32 / 2.0) / (MAP as f32 / 2.0)).abs(); + tile.temperature = 0.20 + lat * 0.50 + noise * 0.10; + tile.moisture = 0.30 + noise * 0.40; + tile.elevation = 0.20 + noise * 0.30; + tile.habitat_suitability = 0.3 + noise * 0.4; + tile.quality = 2 + (noise * 4.0) as i32; + tile.biome_label_id = hex::classify_terrain( + tile.temperature, + tile.moisture, + tile.elevation, + if noise > 0.3 { 0.5 } else { 0.0 }, + ) + .into(); + } + grid.stamp_terrain_tier_caps(); + grid + } + + fn make_state() -> GameState { + let mut state = GameState::default(); + state.grid = Some(build_grid()); + state.turn = 0; + state + } + + fn make_worldsim() -> WorldSim { + make_worldsim_seeded(SEED) + } + + fn make_worldsim_seeded(seed: u64) -> WorldSim { + WorldSim::new( + 10_000, + ClimatePhysics::new("{}", "[]", "{}"), + EcologyEngine::new(), + GeologicalThresholds::default(), + BiologicalThresholds::default(), + AnomalousThresholds::default(), + seed, + ) + } + + /// A few real species loaded from the canonical JSON pack (Rail 2) via the + /// `load_species_library` loader — the same entry point the `tier_timeline` + /// binary uses. The loader resolves trait-derived fields (`growth_rate`, + /// `species_key`, `starvation_rate`, …) that are NOT present as raw JSON + /// keys, so a bare `serde_json::from_str::` would fail. Files are + /// found relative to the crate manifest via the `src/simulator/public` + /// symlink. + fn load_sample_species() -> Vec { + let species_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("workspace root") + .join("public/resources/ecology/fauna/species"); + let jsons: Vec = ["grey_wolf", "abalone", "red_deer"] + .iter() + .filter_map(|name| std::fs::read_to_string(species_dir.join(format!("{name}.json"))).ok()) + .collect(); + let refs: Vec<&str> = jsons.iter().map(String::as_str).collect(); + let library = + mc_ecology::species::load_species_library(&refs).expect("load sample species library"); + // `HashMap::into_values` iterates in a randomized order that differs per + // map construction (std `RandomState` advances a thread-local seed) — + // even within one process. Sort by stable id so the registration / + // seeding order is identical across runs; otherwise the determinism test + // is confounded by harness ordering, not the sim. + let mut species: Vec<_> = library.into_values().collect(); + species.sort_by_key(|s| s.id); + species + } + + /// Build a `WorldSim` whose ecology engine has real species registered and + /// live populations seeded on a handful of tiles — so the seeded + /// Lotka-Volterra path in `tick_populations` actually runs and the + /// `SeedDomain::WorldsimDynamics` derivation is load-bearing. + fn make_worldsim_with_live_ecology(seed: u64) -> WorldSim { + let mut sim = make_worldsim_seeded(seed); + let species = load_sample_species(); + assert!( + !species.is_empty(), + "expected at least one real species JSON to load from the canonical pack" + ); + for sp in &species { + sim.ecology.register_species(sp.clone()); + // Seed a cluster of tiles so LV dynamics + dispersal have something + // to evolve. Coordinates are inside the 16×16 grid. + for (col, row) in [(4, 4), (5, 4), (4, 5), (8, 8)] { + sim.ecology.seed_population( + col, + row, + mc_ecology::population::PopulationSlot::new(sp.id, 25.0), + ); + } + } + sim + } + + /// A `WorldSim` with a grid advances turns without panicking and the + /// continuous engines run (eco-state may or may not accumulate, but the + /// discrete turn must always advance). + #[test] + fn step_advances_turn_with_grid() { + let mut sim = make_worldsim(); + let mut state = make_state(); + for _ in 0..5 { + sim.step(&mut state); + } + assert_eq!(state.turn, 5, "five steps should advance the turn counter to 5"); + } + + /// The continuous ticks and dispatch are a no-op when there is no grid. + #[test] + fn step_noop_without_grid() { + let mut sim = make_worldsim(); + let mut state = GameState::default(); + let r = sim.step(&mut state); + assert_eq!(r.world_events, 0, "no world events without a grid"); + assert!(sim.eco_map.is_empty(), "eco_map untouched without a grid"); + assert!(sim.chronicle.is_empty(), "chronicle untouched without a grid"); + } + + /// Run a live-ecology `WorldSim` for `n` turns and return the serialized + /// state that the save layer will persist: eco-state, grid continuous + /// fields (bit-exact — same process, so FP is reproducible), and the + /// population map. Comparing the *serialized* form (not in-memory `==`) is + /// the gate the save-persistence increment relies on. + fn run_capture(seed: u64, n: usize) -> (String, Vec, String) { + let mut sim = make_worldsim_with_live_ecology(seed); + let mut state = make_state(); + for _ in 0..n { + sim.step(&mut state); + } + let eco_json = serde_json::to_string(&sim.eco_map).expect("serialize eco_map"); + let grid = state.grid.as_ref().expect("grid present"); + let mut grid_bits: Vec = Vec::with_capacity(grid.tiles.len() * 3); + for t in &grid.tiles { + grid_bits.push(t.moisture.to_bits()); + grid_bits.push(t.temperature.to_bits()); + grid_bits.push(t.habitat_suitability.to_bits()); + } + // `tile_populations` is keyed by an `(i32, i32)` tuple; JSON object keys + // must be strings, so serialize the BTreeMap as an ordered sequence of + // (key, value) pairs. BTreeMap iteration is sorted → deterministic order. + let pop_pairs: Vec<(&(i32, i32), &Vec)> = + sim.ecology().tile_populations.iter().collect(); + let pop_json = serde_json::to_string(&pop_pairs).expect("serialize tile_populations"); + (eco_json, grid_bits, pop_json) + } + + /// DETERMINISM (same seed → identical state). Two `WorldSim` instances built + /// from the same seed + configs and stepped the same number of turns produce + /// byte-identical serialized eco-state, population map, and grid fields. + /// + /// This exercises the seeded Lotka-Volterra path: real species are + /// registered and populations seeded, so `tick_populations` runs under the + /// `SeedDomain::WorldsimDynamics` stream derived in `WorldSim::step`. + #[test] + fn determinism_same_seed_byte_identical() { + const N: usize = 12; + + let (eco_a, grid_a, pop_a) = run_capture(SEED, N); + let (eco_b, grid_b, pop_b) = run_capture(SEED, N); + + // Guard against a vacuous test: the seeded ecology path MUST have run, + // i.e. populations survived and serialized to more than an empty map. + assert_ne!( + pop_a, "{}", + "ecology RNG path never produced populations — test would be vacuous" + ); + + assert_eq!(eco_a, eco_b, "serialized eco_map must be byte-identical across runs"); + assert_eq!(grid_a, grid_b, "grid continuous fields must be bit-identical across runs"); + assert_eq!(pop_a, pop_b, "serialized population map must be byte-identical across runs"); + } + + /// DETERMINISM (seed is load-bearing). A different master seed must drive a + /// different `WorldsimDynamics` RNG stream and therefore a different + /// population trajectory. Without this, an unwired seed could pass the + /// same-seed test trivially. + #[test] + fn determinism_different_seed_diverges() { + const N: usize = 12; + + let (_, _, pop_a) = run_capture(SEED, N); + let (_, _, pop_b) = run_capture(SEED ^ 0xABCD_1234_5678_9F01, N); + + assert_ne!( + pop_a, pop_b, + "different master seeds must drive different ecology trajectories — \ + the WorldsimDynamics seed derivation is not wired" + ); + } + + /// Build a `WorldSim` with the sample species **registered** but NO + /// populations seeded — the post-load shape: the registry is rebuilt from + /// the canonical pack, then `restore_state` injects the saved populations. + fn make_worldsim_registry_only(seed: u64) -> WorldSim { + let mut sim = make_worldsim_seeded(seed); + for sp in &load_sample_species() { + sim.ecology_mut().register_species(sp.clone()); + } + sim + } + + /// SAVE/LOAD DETERMINISM-TRANSPARENCY. A run that is saved at turn K, + /// restored into a fresh `WorldSim` (registry rebuilt, side-state injected), + /// and continued to turn N must end byte-identical to a control run that + /// never saved. This proves save/load is determinism-transparent and that + /// the persisted side-state (`eco_map` + `tile_populations`, both + /// `BTreeMap`s) is complete — exactly what the api-gdext `*_to_json` / + /// `restore_*_from_json` pair round-trips in the game. + #[test] + fn save_load_is_determinism_transparent() { + const N: usize = 12; + const SAVE_AT: usize = 5; + + // ── Control: run straight through to N, no save. ────────────────────── + let mut control = make_worldsim_with_live_ecology(SEED); + let mut control_state = make_state(); + for _ in 0..N { + control.step(&mut control_state); + } + + // ── Saved variant: run to SAVE_AT, serialize side-state, then restore + // into a fresh WorldSim and continue to N. ─────────────────────────── + let mut saved = make_worldsim_with_live_ecology(SEED); + let mut saved_state = make_state(); + for _ in 0..SAVE_AT { + saved.step(&mut saved_state); + } + + // Serialize exactly what the game persists: eco_map + tile_populations + // (the opaque-JSON `worldsim_state` payload). BTreeMap → deterministic. + let eco_json = serde_json::to_string(&saved.eco_map).expect("ser eco_map"); + let pop_pairs: Vec<(&(i32, i32), &Vec)> = + saved.ecology().tile_populations.iter().collect(); + let pop_json = serde_json::to_string(&pop_pairs).expect("ser tile_populations"); + // The grid is part of the game save too (mc-save `grid` field); carry it. + let grid_snapshot = saved_state.grid.clone(); + let turn_snapshot = saved_state.turn; + + // Fresh instance: registry rebuilt from the pack, then side-state injected. + let mut restored = make_worldsim_registry_only(SEED); + let eco_map: BTreeMap<(u16, u16), TileEcoState> = + serde_json::from_str(&eco_json).expect("de eco_map"); + let pop_pairs: Vec<((i32, i32), Vec)> = + serde_json::from_str(&pop_json).expect("de tile_populations"); + restored.restore_state(eco_map, pop_pairs.into_iter().collect()); + let mut restored_state = make_state(); + restored_state.grid = grid_snapshot; + restored_state.turn = turn_snapshot; + + // Continue the restored run to N. + for _ in SAVE_AT..N { + restored.step(&mut restored_state); + } + + // ── Compare final serialized state. ────────────────────────────────── + let control_eco = serde_json::to_string(&control.eco_map).expect("ser"); + let restored_eco = serde_json::to_string(&restored.eco_map).expect("ser"); + let control_pop = { + let p: Vec<_> = control.ecology().tile_populations.iter().collect(); + serde_json::to_string(&p).expect("ser") + }; + let restored_pop = { + let p: Vec<_> = restored.ecology().tile_populations.iter().collect(); + serde_json::to_string(&p).expect("ser") + }; + + assert_ne!(control_pop, "[]", "control produced no populations — vacuous"); + assert_eq!( + control_eco, restored_eco, + "eco_map after save/load must match the never-saved control" + ); + assert_eq!( + control_pop, restored_pop, + "tile_populations after save/load must match the never-saved control — \ + save/load is not determinism-transparent" + ); + } +}