feat(worldsim): Add core event dispatch and simulation loop for Minecraft-like world simulator

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-06 16:03:16 -07:00
parent 1d9e77be52
commit 8bc770d8a0
2 changed files with 957 additions and 0 deletions

View file

@ -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::<Vec<_>>()
);
}
#[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");
}
}

View file

@ -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 24 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<mc_ecology::population::PopulationSlot>>,
) {
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);
// 24. 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::<Species>` would fail. Files are
/// found relative to the crate manifest via the `src/simulator/public`
/// symlink.
fn load_sample_species() -> Vec<mc_ecology::species::Species> {
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<String> = ["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<u32>, 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<u32> = 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<mc_ecology::population::PopulationSlot>)> =
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<mc_ecology::population::PopulationSlot>)> =
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<mc_ecology::population::PopulationSlot>)> =
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"
);
}
}