feat(api-gdext): Introduce worldsim dynamics traits, structs, and functions for Godot engine extension API

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-06 16:03:16 -07:00
parent 8bc770d8a0
commit 061b6f0f7b

View file

@ -798,6 +798,62 @@ impl GdFaunaEcology {
}
out
}
/// Serialize the live per-tile fauna population map to a JSON string for
/// save persistence (Increment 2 — closes the `EcologyState.gd` "save not
/// round-tripped" gap). `tile_populations` is a `BTreeMap` keyed by an
/// `(i32, i32)` tuple, so the output is a deterministic JSON array of
/// `[[col, row], [slots…]]` pairs (JSON object keys must be strings).
///
/// Only the populations are persisted — the species registry is rebuilt
/// from the canonical JSON pack on load (`EcologyState.gd::reset` +
/// `_ensure_species_registered`), and the restored slots reference those
/// species by numeric id.
#[func]
fn tile_populations_to_json(&self) -> GString {
let pairs: Vec<(&(i32, i32), &Vec<PopulationSlot>)> =
self.inner.tile_populations.iter().collect();
match serde_json::to_string(&pairs) {
Ok(s) => s.into(),
Err(e) => {
godot_error!("GdFaunaEcology::tile_populations_to_json: {e}");
GString::new()
}
}
}
/// Restore the per-tile fauna population map from a JSON string produced by
/// `tile_populations_to_json`. Replaces the current map. Returns `false` on
/// parse failure (the existing map is left untouched).
///
/// The caller MUST register the species library first (so the restored
/// slots' `species_id`s resolve during `tick_populations`); the standard
/// load path does this via `EcologyState.gd::_ensure_species_registered`.
#[func]
fn restore_tile_populations_from_json(&mut self, json: GString) -> bool {
let s = json.to_string();
if s.is_empty() {
return false;
}
match serde_json::from_str::<Vec<((i32, i32), Vec<PopulationSlot>)>>(&s) {
Ok(pairs) => {
self.inner.tile_populations = pairs.into_iter().collect();
true
}
Err(e) => {
godot_error!("GdFaunaEcology::restore_tile_populations_from_json: {e}");
false
}
}
}
/// Total number of distinct tiles that currently hold any fauna population.
/// Cheap progress probe for tests / HUD (distinct from
/// `population_slot_count`, which sums slots across tiles).
#[func]
fn populated_tile_count(&self) -> i64 {
self.inner.tile_populations.len() as i64
}
}
// ── GdAtmosphericChemistry ──────────────────────────────────────────────
@ -8296,3 +8352,129 @@ impl GdVision {
.unwrap_or(false)
}
}
// ── GdWorldSim ────────────────────────────────────────────────────────────
//
// Continuous-worldsim event bridge (Increment 2). Owns the per-tile
// eco-damage accumulator (`eco_map`), a `Chronicle` of world events, and the
// three event-threshold configs. It does NOT run `TurnProcessor::step` — in
// the playable path GDScript drives turn advancement (`turn_manager.gd`) and
// the per-turn climate + ecology ticks already run via `GdClimatePhysics` and
// `GdFaunaEcology`. This bridge adds the remaining worldsim layer:
// `dispatch_world_events` (geological / biological / anomalous + fog) and the
// owned `eco_map`, both keyed to the *same* `GdGameState` the turn loop owns.
//
// Population migration is NOT here — it is a continuous ecology tick and lives
// inside `mc_ecology::EcologyEngine::process_step`, so it already runs through
// `GdFaunaEcology::tick_populations`.
//
// Save/load: `eco_map` round-trips through `eco_map_to_json` /
// `restore_eco_map_from_json` (a `BTreeMap` → deterministic byte order). The
// live population map round-trips on `GdFaunaEcology` (it owns it).
/// Continuous-worldsim event bridge. Holds the per-tile eco-damage map,
/// a world-event chronicle, and the event thresholds. Default thresholds
/// match the Rust `Default` impls; JSON-loaded configs are a later increment.
#[derive(GodotClass)]
#[class(base=RefCounted)]
pub struct GdWorldSim {
eco_map: std::collections::BTreeMap<(u16, u16), mc_ecology::tile::TileEcoState>,
chronicle: mc_turn::chronicle::Chronicle,
geo_thresholds: mc_mapgen::events::GeologicalThresholds,
bio_thresholds: mc_ecology::biological::BiologicalThresholds,
anomalous_thresholds: mc_climate::anomalous::AnomalousThresholds,
base: Base<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdWorldSim {
fn init(base: Base<RefCounted>) -> Self {
Self {
eco_map: std::collections::BTreeMap::new(),
chronicle: mc_turn::chronicle::Chronicle::new(),
geo_thresholds: mc_mapgen::events::GeologicalThresholds::default(),
bio_thresholds: mc_ecology::biological::BiologicalThresholds::default(),
anomalous_thresholds: mc_climate::anomalous::AnomalousThresholds::default(),
base,
}
}
}
#[godot_api]
impl GdWorldSim {
/// Run one continuous-worldsim event pass against `state` for the current
/// turn: geological / biological / anomalous event derivation, eco-damage
/// accumulation into the owned `eco_map`, fog-bank application, and one
/// chronicle entry per event. Returns the number of events dispatched.
///
/// `seed` is the game master seed (the caller passes `GameState.map_seed`).
/// Deterministic: the same `(state, seed, turn)` always produces the same
/// events and eco-map mutations.
///
/// No-op (returns 0) when `state` has no grid.
#[func]
fn dispatch(&mut self, mut state: Gd<GdGameState>, seed: i64) -> i64 {
let mut bound = state.bind_mut();
let n = mc_worldsim::dispatch_world_events(
&mut bound.inner,
seed as u64,
&self.geo_thresholds,
&self.bio_thresholds,
&self.anomalous_thresholds,
&mut self.eco_map,
&mut self.chronicle,
);
n as i64
}
/// Total number of world-event entries accumulated in the chronicle since
/// construction (or last restore). Cheap progress probe for tests / HUD.
#[func]
fn chronicle_len(&self) -> i64 {
self.chronicle.len() as i64
}
/// Number of tiles that carry any accumulated eco-damage.
#[func]
fn eco_map_len(&self) -> i64 {
self.eco_map.len() as i64
}
/// Serialize the eco-damage map to a JSON string for save persistence.
/// `BTreeMap` iteration is sorted, so the bytes are deterministic. The
/// `(u16, u16)` tuple keys are emitted as a JSON array of `[key, value]`
/// pairs (JSON object keys must be strings).
#[func]
fn eco_map_to_json(&self) -> GString {
let pairs: Vec<(&(u16, u16), &mc_ecology::tile::TileEcoState)> =
self.eco_map.iter().collect();
match serde_json::to_string(&pairs) {
Ok(s) => s.into(),
Err(e) => {
godot_error!("GdWorldSim::eco_map_to_json: {e}");
GString::new()
}
}
}
/// Restore the eco-damage map from a JSON string produced by
/// `eco_map_to_json`. Replaces the current map. Returns `false` on parse
/// failure (the existing map is left untouched).
#[func]
fn restore_eco_map_from_json(&mut self, json: GString) -> bool {
let s = json.to_string();
if s.is_empty() {
return false;
}
match serde_json::from_str::<Vec<((u16, u16), mc_ecology::tile::TileEcoState)>>(&s) {
Ok(pairs) => {
self.eco_map = pairs.into_iter().collect();
true
}
Err(e) => {
godot_error!("GdWorldSim::restore_eco_map_from_json: {e}");
false
}
}
}
}