From 061b6f0f7bdf2789ecd2c55ea1245f69c0365f1a Mon Sep 17 00:00:00 2001 From: autocommit Date: Sat, 6 Jun 2026 16:03:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(api-gdext):=20=E2=9C=A8=20Introduce=20worl?= =?UTF-8?q?dsim=20dynamics=20traits,=20structs,=20and=20functions=20for=20?= =?UTF-8?q?Godot=20engine=20extension=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/api-gdext/src/lib.rs | 182 +++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 89c13e98..ff412802 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -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)> = + 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::)>>(&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, +} + +#[godot_api] +impl IRefCounted for GdWorldSim { + fn init(base: Base) -> 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, 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::>(&s) { + Ok(pairs) => { + self.eco_map = pairs.into_iter().collect(); + true + } + Err(e) => { + godot_error!("GdWorldSim::restore_eco_map_from_json: {e}"); + false + } + } + } +}