diff --git a/src/simulator/api-gdext/Cargo.toml b/src/simulator/api-gdext/Cargo.toml new file mode 100644 index 00000000..02132b28 --- /dev/null +++ b/src/simulator/api-gdext/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "magic-civ-physics-gdext" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +mc-core = { path = "../crates/mc-core" } +mc-climate = { path = "../crates/mc-climate" } +mc-mapgen = { path = "../crates/mc-mapgen" } +godot = "0.2" +serde.workspace = true +serde_json.workspace = true diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs new file mode 100644 index 00000000..63e20413 --- /dev/null +++ b/src/simulator/api-gdext/src/lib.rs @@ -0,0 +1,394 @@ +/// GDExtension API surface — exposes Rust simulation to Godot via godot-rust (gdext v0.2). +/// Registers GdClimatePhysics, GdEcologyPhysics, GdMapGenerator, GdGridState as Godot classes. + +use godot::prelude::*; + +use mc_climate::atmosphere::step_atmospheric_chemistry; +use mc_climate::physics::ClimatePhysics; +use mc_climate::ecology::EcologyPhysics; +use mc_climate::spec; +use mc_core::grid::GridState; +use mc_mapgen::MapGenerator; + +struct MagicCivPhysicsExtension; + +#[gdextension] +unsafe impl ExtensionLibrary for MagicCivPhysicsExtension {} + +// ── GdGridState ───────────────────────────────────────────────────────── + +/// Godot-visible grid wrapper. Holds the flat-array GridState used by all physics. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdGridState { + pub(crate) inner: GridState, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdGridState { + fn init(base: Base) -> Self { + Self { + inner: GridState::new(0, 0), + base, + } + } +} + +#[godot_api] +impl GdGridState { + /// Create a grid of given dimensions with default tile values. + #[func] + fn create(width: i64, height: i64) -> Gd { + Gd::from_init_fn(|base| GdGridState { + inner: GridState::new(width as i32, height as i32), + base, + }) + } + + /// Deserialize a grid from JSON (full GridState). + #[func] + fn from_json(json: GString) -> Gd { + let inner: GridState = serde_json::from_str(&json.to_string()) + .unwrap_or_else(|e| { + godot_error!("GdGridState::from_json parse error: {}", e); + GridState::new(0, 0) + }); + Gd::from_init_fn(|base| GdGridState { inner, base }) + } + + /// Serialize the grid to JSON. + #[func] + fn to_json(&self) -> GString { + match serde_json::to_string(&self.inner) { + Ok(s) => GString::from(s), + Err(e) => { + godot_error!("GdGridState::to_json error: {}", e); + GString::new() + } + } + } + + #[func] + fn get_width(&self) -> i64 { + self.inner.width as i64 + } + + #[func] + fn get_height(&self) -> i64 { + self.inner.height as i64 + } + + #[func] + fn get_global_avg_temp(&self) -> f64 { + self.inner.global_avg_temp as f64 + } + + #[func] + fn get_ocean_dead_fraction(&self) -> f64 { + self.inner.ocean_dead_fraction as f64 + } + + #[func] + fn set_ocean_dead_fraction(&mut self, value: f64) { + self.inner.ocean_dead_fraction = value as f32; + } + + #[func] + fn get_tile_count(&self) -> i64 { + self.inner.tiles.len() as i64 + } + + /// Get a single tile's data as a Dictionary (for GDScript interop). + #[func] + fn get_tile_dict(&self, col: i64, row: i64) -> Dictionary { + match self.inner.tile(col as i32, row as i32) { + Some(tile) => tile_to_dict(tile), + None => Dictionary::new(), + } + } + + /// Set tile fields from a Dictionary. Only writes fields present in the dict. + #[func] + fn set_tile_dict(&mut self, col: i64, row: i64, dict: Dictionary) { + if let Some(tile) = self.inner.tile_mut(col as i32, row as i32) { + dict_to_tile(&dict, tile); + } + } +} + +// ── GdClimatePhysics ──────────────────────────────────────────────────── + +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdClimatePhysics { + inner: Option, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdClimatePhysics { + fn init(base: Base) -> Self { + Self { inner: None, base } + } +} + +#[godot_api] +impl GdClimatePhysics { + /// Initialize with JSON config strings (climate_params, terrain_data, climate_spec). + #[func] + fn initialize(&mut self, params_json: GString, terrain_json: GString, spec_json: GString) { + self.inner = Some(ClimatePhysics::new( + ¶ms_json.to_string(), + &terrain_json.to_string(), + &spec_json.to_string(), + )); + } + + /// Run one turn of climate simulation on the given grid. + #[func] + fn process_step(&mut self, mut grid: Gd, turn: i64, seed: i64) { + match &mut self.inner { + Some(physics) => { + physics.process_step(&mut grid.bind_mut().inner, turn as u32, seed as u64); + } + None => { + godot_error!("GdClimatePhysics::process_step called before initialize()"); + } + } + } +} + +// ── GdEcologyPhysics ──────────────────────────────────────────────────── + +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdEcologyPhysics { + inner: EcologyPhysics, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdEcologyPhysics { + fn init(base: Base) -> Self { + Self { + inner: EcologyPhysics::new(), + base, + } + } +} + +#[godot_api] +impl GdEcologyPhysics { + /// Run one tick of ecology simulation (flora succession + fauna habitat). + #[func] + fn process_step(&mut self, mut grid: Gd) { + self.inner.process_step(&mut grid.bind_mut().inner); + } +} + +// ── GdAtmosphericChemistry ────────────────────────────────────────────── + +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdAtmosphericChemistry { + base: Base, +} + +#[godot_api] +impl IRefCounted for GdAtmosphericChemistry { + fn init(base: Base) -> Self { + Self { base } + } +} + +#[godot_api] +impl GdAtmosphericChemistry { + /// Run one step of atmospheric chemistry (O2/CO2/CH4 tracking). + #[func] + fn step(mut grid: Gd, spec_json: GString) { + let spec_val: serde_json::Value = + serde_json::from_str(&spec_json.to_string()).unwrap_or_default(); + step_atmospheric_chemistry(&mut grid.bind_mut().inner, &spec_val); + } +} + +// ── GdMapGenerator ────────────────────────────────────────────────────── + +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdMapGenerator { + inner: Option, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdMapGenerator { + fn init(base: Base) -> Self { + Self { inner: None, base } + } +} + +#[godot_api] +impl GdMapGenerator { + /// Initialize with map generation params JSON. + #[func] + fn initialize(&mut self, params_json: GString) { + self.inner = Some(MapGenerator::new(¶ms_json.to_string())); + } + + /// Generate a complete map, returning a GdGridState. + #[func] + fn generate(&self, seed: i64, map_size: GString) -> Gd { + match &self.inner { + Some(gen) => { + let grid = gen.generate(seed as u64, &map_size.to_string()); + Gd::from_init_fn(|base| GdGridState { inner: grid, base }) + } + None => { + godot_error!("GdMapGenerator::generate called before initialize()"); + Gd::from_init_fn(|base| GdGridState { + inner: GridState::new(0, 0), + base, + }) + } + } + } +} + +// ── GdClimateSpecEval ─────────────────────────────────────────────────── + +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdClimateSpecEval { + base: Base, +} + +#[godot_api] +impl IRefCounted for GdClimateSpecEval { + fn init(base: Base) -> Self { + Self { base } + } +} + +#[godot_api] +impl GdClimateSpecEval { + /// Evaluate a condition string from climate_spec.json. + #[func] + fn eval_condition( + cond: GString, + temperature: f64, + moisture: f64, + elevation: f64, + canopy: f64, + ) -> bool { + spec::eval_condition( + &cond.to_string(), + temperature as f32, + moisture as f32, + elevation as f32, + canopy as f32, + ) + } + + /// Determine the climate-ideal terrain type for a tile. + /// Takes tile data as a Dictionary and spec as JSON string. + #[func] + fn ideal_terrain(tile_dict: Dictionary, spec_json: GString) -> GString { + let tile = dict_to_tilestate(&tile_dict); + let spec_val: serde_json::Value = + serde_json::from_str(&spec_json.to_string()).unwrap_or_default(); + GString::from(spec::ideal_terrain(&tile, &spec_val)) + } + + /// Corruption spread multiplier based on tile ley state. + #[func] + fn ley_channeling_mult(tile_dict: Dictionary, spec_json: GString) -> f64 { + let tile = dict_to_tilestate(&tile_dict); + let spec_val: serde_json::Value = + serde_json::from_str(&spec_json.to_string()).unwrap_or_default(); + spec::ley_channeling_mult(&tile, &spec_val) as f64 + } +} + +// ── Tile Dictionary conversion helpers ────────────────────────────────── + +fn tile_to_dict(tile: &mc_core::grid::TileState) -> Dictionary { + let mut d: Dictionary = Dictionary::new(); + d.set("col", tile.col as i64); + d.set("row", tile.row as i64); + d.set("temperature", tile.temperature as f64); + d.set("moisture", tile.moisture as f64); + d.set("elevation", tile.elevation as f64); + d.set("biome_id", GString::from(tile.biome_id.as_str())); + d.set("wind_direction", tile.wind_direction as i64); + d.set("wind_speed", tile.wind_speed as f64); + d.set("sulfate_aerosol", tile.sulfate_aerosol as f64); + d.set("quality", tile.quality as i64); + d.set("quality_progress", tile.quality_progress as i64); + d.set("original_biome_id", GString::from(tile.original_biome_id.as_str())); + d.set("ley_line_count", tile.ley_line_count as i64); + d.set("ley_school", GString::from(tile.ley_school.as_str())); + d.set("reef_health", tile.reef_health as f64); + d.set("magic_heat_delta", tile.magic_heat_delta as f64); + d.set("magic_moisture_delta", tile.magic_moisture_delta as f64); + d.set("is_natural_wonder", tile.is_natural_wonder); + d.set("wonder_anchor_strength", tile.wonder_anchor_strength as f64); + d.set("wonder_tier", tile.wonder_tier as i64); + d.set("canopy_cover", tile.canopy_cover as f64); + d.set("undergrowth", tile.undergrowth as f64); + d.set("fungi_network", tile.fungi_network as f64); + d.set("surface_water", tile.surface_water as f64); + d.set("river_source_type", GString::from(tile.river_source_type.as_str())); + d.set("is_coastal", tile.is_coastal); + d.set("habitat_suitability", tile.habitat_suitability as f64); + d.set("aerosol_mitigation", tile.aerosol_mitigation as f64); + d.set("resource_id", GString::from(tile.resource_id.as_str())); + + let mut edges: Array = Array::new(); + for &e in &tile.river_edges { + edges.push(e as i64); + } + d.set("river_edges", edges); + + d +} + +fn dict_to_tile(dict: &Dictionary, tile: &mut mc_core::grid::TileState) { + if let Some(v) = dict.get("temperature") { tile.temperature = v.to::() as f32; } + if let Some(v) = dict.get("moisture") { tile.moisture = v.to::() as f32; } + if let Some(v) = dict.get("elevation") { tile.elevation = v.to::() as f32; } + if let Some(v) = dict.get("biome_id") { tile.biome_id = v.to::().to_string(); } + if let Some(v) = dict.get("wind_direction") { tile.wind_direction = v.to::() as i32; } + if let Some(v) = dict.get("wind_speed") { tile.wind_speed = v.to::() as f32; } + if let Some(v) = dict.get("sulfate_aerosol") { tile.sulfate_aerosol = v.to::() as f32; } + if let Some(v) = dict.get("quality") { tile.quality = v.to::() as i32; } + if let Some(v) = dict.get("quality_progress") { tile.quality_progress = v.to::() as i32; } + if let Some(v) = dict.get("original_biome_id") { tile.original_biome_id = v.to::().to_string(); } + if let Some(v) = dict.get("ley_line_count") { tile.ley_line_count = v.to::() as i32; } + if let Some(v) = dict.get("ley_school") { + tile.ley_school = mc_core::grid::LeySchool::from_str(&v.to::().to_string()); + } + if let Some(v) = dict.get("reef_health") { tile.reef_health = v.to::() as f32; } + if let Some(v) = dict.get("magic_heat_delta") { tile.magic_heat_delta = v.to::() as f32; } + if let Some(v) = dict.get("magic_moisture_delta") { tile.magic_moisture_delta = v.to::() as f32; } + if let Some(v) = dict.get("is_natural_wonder") { tile.is_natural_wonder = v.to::(); } + if let Some(v) = dict.get("wonder_anchor_strength") { tile.wonder_anchor_strength = v.to::() as f32; } + if let Some(v) = dict.get("wonder_tier") { tile.wonder_tier = v.to::() as i32; } + if let Some(v) = dict.get("canopy_cover") { tile.canopy_cover = v.to::() as f32; } + if let Some(v) = dict.get("undergrowth") { tile.undergrowth = v.to::() as f32; } + if let Some(v) = dict.get("fungi_network") { tile.fungi_network = v.to::() as f32; } + if let Some(v) = dict.get("surface_water") { tile.surface_water = v.to::() as f32; } + if let Some(v) = dict.get("river_source_type") { tile.river_source_type = v.to::().to_string(); } + if let Some(v) = dict.get("is_coastal") { tile.is_coastal = v.to::(); } + if let Some(v) = dict.get("habitat_suitability") { tile.habitat_suitability = v.to::() as f32; } + if let Some(v) = dict.get("aerosol_mitigation") { tile.aerosol_mitigation = v.to::() as f32; } + if let Some(v) = dict.get("resource_id") { tile.resource_id = v.to::().to_string(); } +} + +/// Create a TileState from a Dictionary (for spec eval functions that need a full tile). +fn dict_to_tilestate(dict: &Dictionary) -> mc_core::grid::TileState { + let mut tile = mc_core::grid::TileState::default(); + dict_to_tile(dict, &mut tile); + tile +}