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:
parent
1d9e77be52
commit
8bc770d8a0
2 changed files with 957 additions and 0 deletions
456
src/simulator/crates/mc-worldsim/src/event_dispatch.rs
Normal file
456
src/simulator/crates/mc-worldsim/src/event_dispatch.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
501
src/simulator/crates/mc-worldsim/src/lib.rs
Normal file
501
src/simulator/crates/mc-worldsim/src/lib.rs
Normal 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 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<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);
|
||||
|
||||
// 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::<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue