diff --git a/api-gdext/Cargo.toml b/api-gdext/Cargo.toml new file mode 100644 index 00000000..d39f1e82 --- /dev/null +++ b/api-gdext/Cargo.toml @@ -0,0 +1,23 @@ +[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" } +mc-economy = { path = "../crates/mc-economy" } +mc-happiness = { path = "../crates/mc-happiness" } +mc-city = { path = "../crates/mc-city" } +mc-combat = { path = "../crates/mc-combat" } +mc-items = { path = "../crates/mc-items" } +mc-turn = { path = "../crates/mc-turn" } +mc-ai = { path = "../crates/mc-ai" } +mc-compute = { path = "../crates/mc-compute", features = ["gpu", "parallel"] } +godot = "0.2" +serde.workspace = true +serde_json.workspace = true diff --git a/api-gdext/src/lib.rs b/api-gdext/src/lib.rs new file mode 100644 index 00000000..319f1a6b --- /dev/null +++ b/api-gdext/src/lib.rs @@ -0,0 +1,2367 @@ +//! GDExtension API surface — exposes Rust simulation to Godot via godot-rust (gdext v0.2). +//! Registers GdClimatePhysics, GdEcologyPhysics, GdMapGenerator, GdGridState as Godot classes. +//! +//! `wrong_self_convention` is allowed crate-wide because the GDExtension binding +//! pattern requires `#[func] fn from_json(&mut self, …)` — Godot calls these as +//! instance methods on a wrapper that owns the underlying Rust struct, so the +//! `from_*` naming refers to "construct state from JSON" not "convert self to T". +#![allow(clippy::wrong_self_convention)] +#![allow(clippy::result_large_err)] + +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, dt: f64) { + match &mut self.inner { + Some(physics) => { + physics.process_step(&mut grid.bind_mut().inner, turn as u32, seed as u64, dt as f32); + } + 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, dt: f64) { + self.inner.process_step(&mut grid.bind_mut().inner, dt as f32); + } +} + +// ── 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_id(&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 +} + +// ── GdStockpile ───────────────────────────────────────────────────────── + +/// Civ-level material stockpile wrapper. Held by Player and consumed when +/// crafting items in producer buildings. Strong-typed errors from Rust are +/// surfaced as Godot return values: success = empty string, failure = the +/// error's display message. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdStockpile { + pub(crate) inner: mc_economy::Stockpile, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdStockpile { + fn init(base: Base) -> Self { + Self { + inner: mc_economy::Stockpile::new(), + base, + } + } +} + +#[godot_api] +impl GdStockpile { + /// Add `amount` of `resource` to the stockpile (saturates at u32::MAX). + /// Negative or zero `amount` is a no-op. + #[func] + fn add(&mut self, resource: GString, amount: i64) { + if amount <= 0 { + return; + } + let amt = amount.min(u32::MAX as i64) as u32; + self.inner.add(&resource.to_string(), amt); + } + + /// Consume `amount` of `resource`. Returns an empty string on success, + /// or a human-readable error message on failure (e.g. insufficient). + #[func] + fn consume(&mut self, resource: GString, amount: i64) -> GString { + if amount <= 0 { + return GString::new(); + } + let amt = amount.min(u32::MAX as i64) as u32; + match self.inner.consume(&resource.to_string(), amt) { + Ok(()) => GString::new(), + Err(e) => GString::from(e.to_string()), + } + } + + /// True if the stockpile holds at least `amount` of `resource`. + #[func] + fn has(&self, resource: GString, amount: i64) -> bool { + if amount <= 0 { + return true; + } + let amt = amount.min(u32::MAX as i64) as u32; + self.inner.has(&resource.to_string(), amt) + } + + /// Current quantity of `resource` (0 if absent). + #[func] + fn available(&self, resource: GString) -> i64 { + self.inner.available(&resource.to_string()) as i64 + } + + /// Snapshot the entire stockpile as a Dictionary { resource_id: amount }. + #[func] + fn to_dict(&self) -> Dictionary { + let mut d = Dictionary::new(); + for (k, v) in self.inner.entries() { + d.set(GString::from(k), *v as i64); + } + d + } + + /// Number of distinct resource kinds tracked. + #[func] + fn len(&self) -> i64 { + self.inner.len() as i64 + } + + /// Serialize to JSON for save files. + #[func] + fn to_json(&self) -> GString { + match serde_json::to_string(&self.inner) { + Ok(s) => GString::from(s), + Err(e) => { + godot_error!("GdStockpile::to_json error: {}", e); + GString::from("{}") + } + } + } + + /// Restore stockpile state from JSON. Returns true on success. + #[func] + fn from_json(&mut self, json: GString) -> bool { + match serde_json::from_str::(&json.to_string()) { + Ok(s) => { + self.inner = s; + true + } + Err(e) => { + godot_error!("GdStockpile::from_json error: {}", e); + false + } + } + } +} + +// ── GdHappiness ───────────────────────────────────────────────────────── + +/// Stateless happiness calculator. Marshals `HappinessInput` and +/// `GoldenAgeState` over JSON (matching the `to_json`/`from_json` pattern +/// used by other bindings) and returns snake_case Dictionaries so the +/// GDScript wrapper can read fields without a second hop through JSON. +/// +/// All simulation math lives in `mc_happiness`; this binding only marshals. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdHappiness { + base: Base, +} + +#[godot_api] +impl IRefCounted for GdHappiness { + fn init(base: Base) -> Self { + Self { base } + } +} + +#[godot_api] +impl GdHappiness { + #[constant] + const UNHAPPY_THRESHOLD: i64 = mc_happiness::UNHAPPY_THRESHOLD as i64; + #[constant] + const REVOLT_THRESHOLD: i64 = mc_happiness::REVOLT_THRESHOLD as i64; + #[constant] + const GOLDEN_AGE_DURATION: i64 = mc_happiness::GOLDEN_AGE_DURATION as i64; + #[constant] + const GOLDEN_AGE_BASE_METER: i64 = mc_happiness::GOLDEN_AGE_BASE_METER as i64; + #[constant] + const BASE_CITY_UNHAPPINESS: i64 = mc_happiness::BASE_CITY_UNHAPPINESS as i64; + #[constant] + const BASE_CITIZEN_UNHAPPINESS: i64 = mc_happiness::BASE_CITIZEN_UNHAPPINESS as i64; + #[constant] + const LUXURY_HAPPINESS: i64 = mc_happiness::LUXURY_HAPPINESS as i64; + + /// Calculate the full happiness breakdown for a player. + /// + /// `input_json` must deserialize to `mc_happiness::HappinessInput`. + /// On success returns a Dictionary with every `HappinessBreakdown` field + /// (snake_case). On parse error returns a Dictionary with a single + /// `"error"` key holding the serde message. + #[func] + fn calculate(&self, input_json: GString) -> Dictionary { + let input: mc_happiness::HappinessInput = + match serde_json::from_str(&input_json.to_string()) { + Ok(i) => i, + Err(e) => { + let mut d = Dictionary::new(); + d.set("error", GString::from(format!("happiness input: {e}"))); + return d; + } + }; + let config = mc_happiness::HappinessConfig::default(); + let b = mc_happiness::calculate_happiness(&input, &config); + let mut d = Dictionary::new(); + d.set("base_unhappiness", b.base_unhappiness as i64); + d.set("city_unhappiness", b.city_unhappiness as i64); + d.set("citizen_unhappiness", b.citizen_unhappiness as i64); + d.set("war_weariness", b.war_weariness as i64); + d.set("ascension_penalty", b.ascension_penalty as i64); + d.set("building_happiness", b.building_happiness as i64); + d.set("luxury_happiness", b.luxury_happiness as i64); + d.set("total", b.total as i64); + d.set("status", GString::from(&b.status)); + d.set("growth_modifier", b.growth_modifier); + d.set("combat_modifier", b.combat_modifier); + d + } + + /// Advance the Golden Age state machine by one turn. + /// + /// `state_json` must deserialize to `mc_happiness::GoldenAgeState`. + /// Returns `{ "triggered": bool, "state": { golden_age_active, + /// golden_age_turns, golden_age_progress, golden_age_count } }`. On + /// parse error returns `{ "error": "" }` — the caller should + /// treat that as "no state change this turn". + #[func] + fn process_golden_age(&self, happiness: i64, state_json: GString) -> Dictionary { + let mut state: mc_happiness::GoldenAgeState = + match serde_json::from_str(&state_json.to_string()) { + Ok(s) => s, + Err(e) => { + let mut d = Dictionary::new(); + d.set("error", GString::from(format!("golden age state: {e}"))); + return d; + } + }; + let config = mc_happiness::HappinessConfig::default(); + let happiness_i32 = happiness.clamp(i32::MIN as i64, i32::MAX as i64) as i32; + let triggered = mc_happiness::process_golden_age(happiness_i32, &mut state, &config); + + let mut state_dict = Dictionary::new(); + state_dict.set("golden_age_active", state.golden_age_active); + state_dict.set("golden_age_turns", state.golden_age_turns as i64); + state_dict.set("golden_age_progress", state.golden_age_progress as i64); + state_dict.set("golden_age_count", state.golden_age_count as i64); + + let mut out = Dictionary::new(); + out.set("triggered", triggered); + out.set("state", state_dict); + out + } +} + +// ── GdTreasury ────────────────────────────────────────────────────────── + +/// Civ-wide finished-item Treasury wrapper. Held by Player. Soft cap of 20 +/// is *informational* — `add()` returns true when the cap is crossed so the +/// GDScript wrapper can relay it to `EventBus.treasury_soft_cap_reached`. +/// The cap NEVER blocks adds. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdTreasury { + inner: mc_economy::Treasury, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdTreasury { + fn init(base: Base) -> Self { + Self { + inner: mc_economy::Treasury::new(), + base, + } + } +} + +#[godot_api] +impl GdTreasury { + /// Soft cap constant for UI display. The Treasury never blocks adds. + #[func] + fn soft_cap() -> i64 { + mc_economy::TREASURY_SOFT_CAP as i64 + } + + /// Add an item to the Treasury. Returns true iff this add crossed the + /// soft cap (caller should emit `treasury_soft_cap_reached`). + #[func] + fn add(&mut self, item_id: GString) -> bool { + matches!( + self.inner.add(&item_id.to_string()), + mc_economy::AddOutcome::SoftCapReached + ) + } + + /// Remove the first matching item by id. Returns empty string on success, + /// error message otherwise. + #[func] + fn remove(&mut self, item_id: GString) -> GString { + match self.inner.remove(&item_id.to_string()) { + Ok(_) => GString::new(), + Err(e) => GString::from(e.to_string()), + } + } + + /// Equip the first unequipped instance of `item_id` onto `unit_id`. + /// Returns empty string on success, error message otherwise. + #[func] + fn equip(&mut self, item_id: GString, unit_id: i64) -> GString { + if unit_id < 0 { + return GString::from("treasury: unit_id must be non-negative"); + } + match self.inner.equip(&item_id.to_string(), unit_id as u32) { + Ok(()) => GString::new(), + Err(e) => GString::from(e.to_string()), + } + } + + /// Unequip an instance of `item_id` currently held by `unit_id`. + #[func] + fn unequip(&mut self, item_id: GString, unit_id: i64) -> GString { + if unit_id < 0 { + return GString::from("treasury: unit_id must be non-negative"); + } + match self.inner.unequip(&item_id.to_string(), unit_id as u32) { + Ok(()) => GString::new(), + Err(e) => GString::from(e.to_string()), + } + } + + /// Unequip every item currently equipped on `unit_id`. Returns the count. + /// Used when a squad is destroyed — the Treasury slots fall back to + /// unequipped, and item_system handles ground-loot for charged items. + #[func] + fn unequip_all_for(&mut self, unit_id: i64) -> i64 { + if unit_id < 0 { + return 0; + } + self.inner.unequip_all_for(unit_id as u32) as i64 + } + + #[func] + fn total_count(&self) -> i64 { + self.inner.total_count() as i64 + } + + #[func] + fn equipped_count(&self) -> i64 { + self.inner.equipped_count() as i64 + } + + #[func] + fn unequipped_count(&self) -> i64 { + self.inner.unequipped_count() as i64 + } + + #[func] + fn contains(&self, item_id: GString) -> bool { + self.inner.contains(&item_id.to_string()) + } + + /// Snapshot every item as an Array of Dictionaries: + /// `[{ id: String, equipped_on: int (-1 if unequipped) }, ...]` + #[func] + fn to_array(&self) -> Array { + let mut arr = Array::::new(); + for item in self.inner.iter() { + let mut d = Dictionary::new(); + d.set("id", GString::from(&item.id)); + d.set( + "equipped_on", + item.equipped_on.map(|u| u as i64).unwrap_or(-1), + ); + arr.push(&d); + } + arr + } + + /// Serialize to JSON for save files. + #[func] + fn to_json(&self) -> GString { + match serde_json::to_string(&self.inner) { + Ok(s) => GString::from(s), + Err(e) => { + godot_error!("GdTreasury::to_json error: {}", e); + GString::from("{}") + } + } + } + + /// Restore Treasury state from JSON. Returns true on success. + #[func] + fn from_json(&mut self, json: GString) -> bool { + match serde_json::from_str::(&json.to_string()) { + Ok(t) => { + self.inner = t; + true + } + Err(e) => { + godot_error!("GdTreasury::from_json error: {}", e); + false + } + } + } +} + +// ── GdCity ────────────────────────────────────────────────────────────── + +/// Full city state model — population, growth, food, production, culture, +/// tiles, defense, focus, citizens, and per-building production queues. +/// One `GdCity` per founded city. The `ItemRegistry` is populated via +/// `load_items_json` so item lookups stay data-driven. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdCity { + inner: mc_city::City, + item_registry: mc_city::ItemRegistry, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdCity { + fn init(base: Base) -> Self { + Self { + inner: mc_city::City::new(""), + item_registry: mc_city::ItemRegistry::new(), + base, + } + } +} + +#[godot_api] +impl GdCity { + /// Initialize a city with id and starting buildings. + #[func] + fn initialize(&mut self, city_id: GString, buildings: PackedStringArray) { + let mut bs = Vec::with_capacity(buildings.len()); + for i in 0..buildings.len() { + bs.push(buildings.get(i).unwrap_or_default().to_string()); + } + self.inner = mc_city::City::with_buildings(city_id.to_string(), bs); + } + + /// Add a building to the city's roster (e.g. when one finishes construction). + #[func] + fn add_building(&mut self, building: GString) { + self.inner.add_building(building.to_string()); + } + + /// True if this city already contains `building`. + #[func] + fn has_building(&self, building: GString) -> bool { + self.inner.has_building(&building.to_string()) + } + + /// Load items from a JSON array `[ { id, production: { building, secondary_building?, + /// production_cost, materials?, requires_tech? }, ... }, ... ]`. The DataLoader passes + /// the merged item list from `public/resources/items/*.json`. Items without a + /// `production` block (apex relics) are skipped. + #[func] + fn load_items_json(&mut self, items_json: GString) -> bool { + #[derive(serde::Deserialize)] + struct ProductionDoc { + building: String, + #[serde(default)] + secondary_building: Option, + production_cost: u32, + #[serde(default)] + materials: Vec, + #[serde(default)] + requires_tech: Option, + } + #[derive(serde::Deserialize)] + struct ItemDoc { + id: String, + #[serde(default)] + production: Option, + } + let docs: Vec = match serde_json::from_str(&items_json.to_string()) { + Ok(d) => d, + Err(e) => { + godot_error!("GdCity::load_items_json parse error: {}", e); + return false; + } + }; + for doc in docs { + let Some(p) = doc.production else { continue }; + self.item_registry.insert(mc_city::ItemDef { + id: doc.id, + production_cost: p.production_cost, + building: p.building, + secondary_building: p.secondary_building, + requires_tech: p.requires_tech, + materials: p.materials, + }); + } + true + } + + /// Number of items registered for production. + #[func] + fn item_registry_len(&self) -> i64 { + self.item_registry.len() as i64 + } + + /// Validate, charge, and enqueue an item into its producer building. + /// Materials are deducted from `stockpile` on success. Returns empty + /// string on success, error message otherwise. The Stockpile is mutated + /// in place via the GdStockpile wrapper. + #[func] + fn enqueue_item( + &mut self, + item_id: GString, + mut stockpile: Gd, + researched_techs: PackedStringArray, + ) -> GString { + let mut techs: std::collections::HashSet = std::collections::HashSet::new(); + for i in 0..researched_techs.len() { + techs.insert(researched_techs.get(i).unwrap_or_default().to_string()); + } + let mut sp_binding = stockpile.bind_mut(); + match self.inner.enqueue_item( + &item_id.to_string(), + &self.item_registry, + &mut sp_binding.inner, + &techs, + ) { + Ok(()) => GString::new(), + Err(e) => GString::from(e.to_string()), + } + } + + /// Apply `production` to one building's queue. Returns an Array of completed + /// item ids (in completion order). The caller should add each to the + /// civ Treasury and emit `EventBus.item_crafted(item_id, city, player)`. + /// Returns an empty array on error and logs to godot_error. + #[func] + fn tick_building(&mut self, building: GString, production: i64) -> PackedStringArray { + let h = production.max(0) as u32; + match self.inner.tick_building(&building.to_string(), h) { + Ok(completed) => { + let mut out = PackedStringArray::new(); + for entry in completed { + let mc_city::Queueable::Item { item_id } = entry.queueable; + out.push(&GString::from(item_id)); + } + out + } + Err(e) => { + godot_error!("GdCity::tick_building error: {}", e); + PackedStringArray::new() + } + } + } + + /// Number of pending entries in `building`'s queue (0 if none). + #[func] + fn queue_len(&self, building: GString) -> i64 { + self.inner + .queue_for(&building.to_string()) + .map(|q| q.len() as i64) + .unwrap_or(0) + } + + /// Snapshot a building's queue as an Array of Dictionaries: + /// `[{ item_id, production_cost, production_invested, progress }, ...]` + #[func] + fn queue_snapshot(&self, building: GString) -> Array { + let mut out = Array::::new(); + let Some(q) = self.inner.queue_for(&building.to_string()) else { + return out; + }; + for entry in q.entries() { + let mut d = Dictionary::new(); + let mc_city::Queueable::Item { item_id } = &entry.queueable; + d.set("item_id", GString::from(item_id)); + d.set("production_cost", entry.production_cost as i64); + d.set("production_invested", entry.production_invested as i64); + d.set("progress", entry.progress() as f64); + out.push(&d); + } + out + } + + /// Serialize the entire city (buildings + queues) to JSON for save files. + #[func] + fn to_json(&self) -> GString { + match serde_json::to_string(&self.inner) { + Ok(s) => GString::from(s), + Err(e) => { + godot_error!("GdCity::to_json error: {}", e); + GString::from("{}") + } + } + } + + /// Restore city state from JSON (does NOT restore the item registry — + /// call `load_items_json` separately after loading a save). + #[func] + fn from_json(&mut self, json: GString) -> bool { + match serde_json::from_str::(&json.to_string()) { + Ok(c) => { + self.inner = c; + true + } + Err(e) => { + godot_error!("GdCity::from_json error: {}", e); + false + } + } + } + + // ── City state: founding & identity ───────────────────────────── + + /// Found a new city. Sets name, position, capital flag, turn, pop=1. + #[func] + fn found(&mut self, name: GString, col: i64, row: i64, is_capital: bool, turn: i64) { + self.inner = mc_city::City::found( + name.to_string(), + (col as i32, row as i32), + is_capital, + turn.max(0) as u32, + ); + } + + #[func] + fn rename(&mut self, new_name: GString) { + self.inner.rename(new_name.to_string()); + } + + // ── Getters ───────────────────────────────────────────────────── + + #[func] + fn get_city_name(&self) -> GString { + GString::from(&self.inner.city_name) + } + + #[func] + fn get_id(&self) -> GString { + GString::from(&self.inner.id) + } + + #[func] + fn get_is_capital(&self) -> bool { + self.inner.is_capital + } + + #[func] + fn get_turn_founded(&self) -> i64 { + self.inner.turn_founded as i64 + } + + #[func] + fn get_position(&self) -> Vector2i { + Vector2i::new(self.inner.position.0, self.inner.position.1) + } + + #[func] + fn get_population(&self) -> i64 { + self.inner.population as i64 + } + + #[func] + fn get_food_stored(&self) -> f64 { + self.inner.food_stored + } + + #[func] + fn get_growth_threshold(&self) -> f64 { + self.inner.growth_threshold() + } + + #[func] + fn get_production_progress(&self) -> f64 { + self.inner.production_progress + } + + #[func] + fn get_culture_stored(&self) -> f64 { + self.inner.culture_stored + } + + #[func] + fn get_culture_expansion_threshold(&self) -> f64 { + self.inner.culture_expansion_threshold() + } + + #[func] + fn get_hp(&self) -> i64 { + self.inner.hp as i64 + } + + #[func] + fn get_max_hp(&self) -> i64 { + self.inner.max_hp as i64 + } + + #[func] + fn get_focus(&self) -> GString { + GString::from(self.inner.focus.as_str()) + } + + #[func] + fn is_destroyed(&self) -> bool { + self.inner.is_destroyed() + } + + #[func] + fn can_expand(&self) -> bool { + self.inner.can_expand() + } + + // ── Setters ───────────────────────────────────────────────────── + + #[func] + fn set_focus(&mut self, focus: GString) { + self.inner.set_focus(mc_city::CityFocus::from_id(&focus.to_string())); + } + + #[func] + fn set_population(&mut self, pop: i64) { + self.inner.population = pop.max(1) as u32; + } + + #[func] + fn set_food_stored(&mut self, food: f64) { + self.inner.food_stored = food; + } + + #[func] + fn set_culture_stored(&mut self, culture: f64) { + self.inner.culture_stored = culture; + } + + #[func] + fn set_hp(&mut self, hp: i64) { + self.inner.hp = (hp.max(0) as u32).min(self.inner.max_hp); + } + + #[func] + fn set_max_hp(&mut self, max_hp: i64) { + self.inner.max_hp = max_hp.max(1) as u32; + } + + // ── Tiles ─────────────────────────────────────────────────────── + + #[func] + fn add_tile(&mut self, col: i64, row: i64) { + self.inner.add_tile((col as i32, row as i32)); + } + + #[func] + fn remove_tile(&mut self, col: i64, row: i64) { + self.inner.remove_tile((col as i32, row as i32)); + } + + /// Returns owned tiles as Array of Vector2i. + #[func] + fn get_owned_tiles(&self) -> Array { + let mut out = Array::::new(); + for (c, r) in &self.inner.owned_tiles { + out.push(Vector2i::new(*c, *r)); + } + out + } + + /// Returns worked tiles as Array of Vector2i. + #[func] + fn get_worked_tiles(&self) -> Array { + let mut out = Array::::new(); + for (c, r) in &self.inner.worked_tiles { + out.push(Vector2i::new(*c, *r)); + } + out + } + + // ── Per-turn processing ───────────────────────────────────────── + + /// Process food growth. Pass tile yields as JSON array: + /// `[{col, row, food, production, gold, culture, science}, ...]` + /// Returns population delta: +1 growth, -1 starvation, 0 neither. + #[func] + fn process_growth(&mut self, tile_yields_json: GString) -> i64 { + let ty = Self::parse_tile_yields(&tile_yields_json.to_string()); + self.inner.process_growth(&ty) as i64 + } + + /// Process culture accumulation. Returns true if border expansion is ready. + #[func] + fn process_culture(&mut self, tile_yields_json: GString) -> bool { + let ty = Self::parse_tile_yields(&tile_yields_json.to_string()); + self.inner.process_culture(&ty) + } + + /// Add production production. + #[func] + fn add_production(&mut self, production: f64) { + self.inner.add_production(production); + } + + /// Get food surplus for this turn (food yield - consumption). + #[func] + fn get_food_surplus(&self, tile_yields_json: GString) -> f64 { + let ty = Self::parse_tile_yields(&tile_yields_json.to_string()); + self.inner.get_food_surplus(&ty) + } + + /// Get city yields as Dictionary {food, production, gold, culture, science}. + #[func] + fn get_yields(&self, tile_yields_json: GString) -> Dictionary { + let ty = Self::parse_tile_yields(&tile_yields_json.to_string()); + let yields = self.inner.get_yields(&ty); + let mut d = Dictionary::new(); + d.set("food", yields.food); + d.set("production", yields.production); + d.set("gold", yields.gold); + d.set("culture", yields.culture); + d.set("science", yields.science); + d + } + + /// Auto-assign citizens to the best tiles based on current focus. + #[func] + fn auto_assign_citizens(&mut self, tile_yields_json: GString) { + let ty = Self::parse_tile_yields(&tile_yields_json.to_string()); + self.inner.auto_assign_citizens(&ty); + } + + /// Expand borders. Pass candidate tiles as JSON array: + /// `[{col, row, value}, ...]` + /// Returns Vector2i of claimed tile, or Vector2i(-1,-1) if none. + #[func] + fn expand_borders(&mut self, candidates_json: GString) -> Vector2i { + let candidates = Self::parse_candidates(&candidates_json.to_string()); + match self.inner.expand_borders(&candidates) { + Some((c, r)) => Vector2i::new(c, r), + None => Vector2i::new(-1, -1), + } + } + + // ── Defense ───────────────────────────────────────────────────── + + #[func] + fn take_damage(&mut self, damage: i64) { + self.inner.take_damage(damage.max(0) as u32); + } + + #[func] + fn heal(&mut self, amount: i64) { + self.inner.heal(amount.max(0) as u32); + } + + /// Heal the city by the standard per-turn amount (20 HP). Skips destroyed cities. + #[func] + fn heal_per_turn(&mut self) { + self.inner.heal_per_turn(); + } +} + +// ── Private helpers for GdCity ────────────────────────────────────────── +impl GdCity { + fn parse_tile_yields(json: &str) -> Vec { + #[derive(serde::Deserialize)] + struct TyDoc { + col: i32, + row: i32, + #[serde(default)] + food: f64, + #[serde(default)] + production: f64, + #[serde(default)] + gold: f64, + #[serde(default)] + culture: f64, + #[serde(default)] + science: f64, + } + let docs: Vec = serde_json::from_str(json).unwrap_or_default(); + docs.into_iter() + .map(|d| mc_city::TileYield { + coord: (d.col, d.row), + food: d.food, + production: d.production, + gold: d.gold, + culture: d.culture, + science: d.science, + }) + .collect() + } + + fn parse_candidates(json: &str) -> Vec<(i32, i32, f64)> { + #[derive(serde::Deserialize)] + struct CandDoc { + col: i32, + row: i32, + value: f64, + } + let docs: Vec = serde_json::from_str(json).unwrap_or_default(); + docs.into_iter().map(|d| (d.col, d.row, d.value)).collect() + } +} + +// ── GdLootRoller ──────────────────────────────────────────────────────── +// +// Stateless wrapper around `mc_combat::loot`. Used by item_system.gd on +// fauna death to roll the killed species' loot_table into the killer's +// civ Stockpile, and any matching apex relic candidates straight into +// the killer's Treasury (bypassing ground loot). +// +// All rolls are derived from a per-kill seed produced by `mix_seed`, which +// callers should compute from `(turn_seed, killer_id, victim_id)`. This is +// the deterministic-PRNG contract from CLAUDE.md "Ecological events require +// a seed". + +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdLootRoller { + base: Base, +} + +#[godot_api] +impl IRefCounted for GdLootRoller { + fn init(base: Base) -> Self { + Self { base } + } +} + +#[godot_api] +impl GdLootRoller { + /// Mix the per-turn seed with killer + victim ids into a per-kill seed. + /// Pass the result into `roll_loot_table` and `roll_apex_relic`. + #[func] + fn mix_seed(turn_seed: i64, killer_id: i64, victim_id: i64) -> i64 { + let s = mc_combat::mix_seed( + turn_seed as u64, + killer_id.max(0) as u32, + victim_id.max(0) as u32, + ); + s as i64 + } + + /// Roll a loot_table JSON array (the `loot_table` field straight off a + /// fauna species JSON) and return the drops as an Array of Dictionaries: + /// `[{ resource: String, amount: int }, ...]`. Empty array on parse + /// error or empty table. + #[func] + fn roll_loot_table(loot_table_json: GString, seed: i64) -> Array { + let entries: Vec = + match serde_json::from_str(&loot_table_json.to_string()) { + Ok(e) => e, + Err(e) => { + godot_error!("GdLootRoller::roll_loot_table parse error: {}", e); + return Array::::new(); + } + }; + let drops = mc_combat::roll_loot_table(&entries, seed as u64); + let mut out = Array::::new(); + for d in drops { + let mut row = Dictionary::new(); + row.set("resource", GString::from(d.resource)); + row.set("amount", d.amount as i64); + out.push(&row); + } + out + } + + /// Roll apex relic drops for `victim_species_id`. Pass the merged item + /// catalog as a JSON array; the wrapper extracts every entry with an + /// `apex_drop` block matching the species and rolls each independently. + /// Returns the item ids that should land in the killer's Treasury. + #[func] + fn roll_apex_relic( + victim_species_id: GString, + items_json: GString, + seed: i64, + ) -> PackedStringArray { + #[derive(serde::Deserialize)] + struct ApexDropDoc { + drops_from: String, + #[serde(default = "one_f32")] + drop_chance: f32, + } + fn one_f32() -> f32 { + 1.0 + } + #[derive(serde::Deserialize)] + struct ItemDoc { + id: String, + #[serde(default)] + apex_drop: Option, + } + let docs: Vec = match serde_json::from_str(&items_json.to_string()) { + Ok(d) => d, + Err(e) => { + godot_error!("GdLootRoller::roll_apex_relic parse error: {}", e); + return PackedStringArray::new(); + } + }; + let candidates: Vec = docs + .into_iter() + .filter_map(|d| { + d.apex_drop.map(|ad| mc_combat::ApexDropCandidate { + item_id: d.id, + drops_from: ad.drops_from, + chance: ad.drop_chance, + }) + }) + .collect(); + let drops = mc_combat::roll_apex_relic( + &victim_species_id.to_string(), + &candidates, + seed as u64, + ); + let mut out = PackedStringArray::new(); + for d in drops { + out.push(&GString::from(d)); + } + out + } +} + +// ── GdItemSystem ──────────────────────────────────────────────────────── +// +// Stateless wrapper around `mc_items::ItemSystem`. Operates on Godot +// `Array` payloads matching the shape `unit.equipped_items` +// and `tile.ground_loot` already use in GDScript: +// +// equipped: { item_id: String, category: String, charges_remaining: int, +// triggers_in_combat: bool } +// ground: { item_id: String, charges_remaining: int, decay_remaining: int } +// +// Each method takes the array, mutates it, and returns the new array — the +// GDScript wrapper writes the result back into the unit/tile property. This +// matches the way climate.gd and city.gd already shuttle Dictionaries across +// the FFI boundary. + +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdItemSystem { + base: Base, +} + +#[godot_api] +impl IRefCounted for GdItemSystem { + fn init(base: Base) -> Self { + Self { base } + } +} + +fn equipped_from_dict(d: &Dictionary) -> mc_items::EquippedItem { + mc_items::EquippedItem { + item_id: d + .get("item_id") + .and_then(|v| v.try_to::().ok()) + .map(|g| g.to_string()) + .unwrap_or_default(), + category: d + .get("category") + .and_then(|v| v.try_to::().ok()) + .map(|g| g.to_string()) + .unwrap_or_default(), + charges_remaining: d + .get("charges_remaining") + .and_then(|v| v.try_to::().ok()) + .unwrap_or(0) as i32, + triggers_in_combat: d + .get("triggers_in_combat") + .and_then(|v| v.try_to::().ok()) + .unwrap_or(false), + } +} + +fn equipped_to_dict(e: &mc_items::EquippedItem) -> Dictionary { + let mut d = Dictionary::new(); + d.set("item_id", GString::from(&e.item_id)); + d.set("category", GString::from(&e.category)); + d.set("charges_remaining", e.charges_remaining as i64); + d.set("triggers_in_combat", e.triggers_in_combat); + d +} + +fn ground_from_dict(d: &Dictionary) -> mc_items::GroundLootEntry { + mc_items::GroundLootEntry { + item_id: d + .get("item_id") + .and_then(|v| v.try_to::().ok()) + .map(|g| g.to_string()) + .unwrap_or_default(), + charges_remaining: d + .get("charges_remaining") + .and_then(|v| v.try_to::().ok()) + .unwrap_or(0) as i32, + decay_remaining: d + .get("decay_remaining") + .and_then(|v| v.try_to::().ok()) + .unwrap_or(0) as i32, + } +} + +fn ground_to_dict(g: &mc_items::GroundLootEntry) -> Dictionary { + let mut d = Dictionary::new(); + d.set("item_id", GString::from(&g.item_id)); + d.set("charges_remaining", g.charges_remaining as i64); + d.set("decay_remaining", g.decay_remaining as i64); + d +} + +fn equipped_array_in(arr: Array) -> Vec { + let mut out = Vec::with_capacity(arr.len()); + for i in 0..arr.len() { + if let Some(d) = arr.get(i) { + out.push(equipped_from_dict(&d)); + } + } + out +} + +fn equipped_array_out(items: &[mc_items::EquippedItem]) -> Array { + let mut out = Array::::new(); + for e in items { + out.push(&equipped_to_dict(e)); + } + out +} + +fn ground_array_in(arr: Array) -> Vec { + let mut out = Vec::with_capacity(arr.len()); + for i in 0..arr.len() { + if let Some(d) = arr.get(i) { + out.push(ground_from_dict(&d)); + } + } + out +} + +fn ground_array_out(items: &[mc_items::GroundLootEntry]) -> Array { + let mut out = Array::::new(); + for g in items { + out.push(&ground_to_dict(g)); + } + out +} + +#[godot_api] +impl GdItemSystem { + #[func] + fn has_active_item(equipped: Array, item_id: GString) -> bool { + let v = equipped_array_in(equipped); + mc_items::ItemSystem::has_active_item(&v, &item_id.to_string()) + } + + /// Returns the updated equipped array. GDScript caller should write it + /// back to `unit.equipped_items`. Second return value (the bool) is + /// surfaced via a separate `consume_charge_ok` query when needed; this + /// method always returns the array. To get the success bool, GDScript + /// can compare lengths or call `has_active_item` first. + #[func] + fn consume_charge( + equipped: Array, + item_id: GString, + ) -> Array { + let mut v = equipped_array_in(equipped); + let _ = mc_items::ItemSystem::consume_charge(&mut v, &item_id.to_string()); + equipped_array_out(&v) + } + + #[func] + fn decrement_combat_charges(equipped: Array) -> Array { + let mut v = equipped_array_in(equipped); + mc_items::ItemSystem::decrement_combat_charges(&mut v); + equipped_array_out(&v) + } + + /// Drop non-permanent items from `equipped` onto `ground_loot`. Returns + /// a Dictionary with the new arrays so the GDScript caller can write + /// both back atomically: `{ equipped: [], ground_loot: [...] }`. + #[func] + fn drop_all_loot( + equipped: Array, + ground_loot: Array, + decay_turns: i64, + ) -> Dictionary { + let mut eq = equipped_array_in(equipped); + let mut ground = ground_array_in(ground_loot); + mc_items::ItemSystem::drop_all_loot(&mut eq, &mut ground, decay_turns as i32); + let mut out = Dictionary::new(); + out.set("equipped", equipped_array_out(&eq)); + out.set("ground_loot", ground_array_out(&ground)); + out + } + + #[func] + fn process_loot_decay(ground_loot: Array) -> Array { + let mut g = ground_array_in(ground_loot); + mc_items::ItemSystem::process_loot_decay(&mut g); + ground_array_out(&g) + } + + #[func] + fn rush_buy_cost(base_production_cost: i64) -> i64 { + mc_items::ItemSystem::rush_buy_cost(base_production_cost as i32) as i64 + } + + #[func] + fn decay_turns_from_balance_json(raw: GString) -> i64 { + mc_items::ItemSystem::decay_turns_from_balance_json(&raw.to_string()) as i64 + } +} + +// ── GdGameState ───────────────────────────────────────────────────────── +// +// Wraps mc_turn::GameState — the full headless game state (players, grid, +// turn counter) — for consumption by Godot. This is the bridge that lets +// the Godot engine use the Rust turn processor as the authoritative +// simulation runtime, rather than running a parallel GDScript turn loop. +// +// The bridge is deliberately minimal: +// - `new()` produces an empty state +// - `add_player_militarist(col, row)` adds a player with the militarist +// strategic profile and a starting city at the given coordinates +// - `stamp_lair(col, row, tier, species_id)` writes a lair directly into +// the underlying grid (used by proof scenes that don't run full +// evolution) +// - getter methods expose turn/players/units/gold for Godot to read back + +/// Headless game state bridge. +/// +/// Owns an `mc_turn::GameState`. Godot scripts create one of these, populate +/// it, hand it to a `GdTurnProcessor`, and read the updated state back after +/// each `step()` call. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdGameState { + inner: mc_turn::GameState, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdGameState { + fn init(base: Base) -> Self { + Self { + inner: mc_turn::GameState { + turn: 0, + players: Vec::new(), + grid: None, + }, + base, + } + } +} + +#[godot_api] +impl GdGameState { + /// Attach a grid of the given size. Must be called before + /// `stamp_lair` or before running turns that touch the world map. + #[func] + fn create_grid(&mut self, width: i64, height: i64) { + self.inner.grid = Some(mc_core::grid::GridState::new(width as i32, height as i32)); + } + + /// Attach a grid by cloning an existing `GdGridState` (e.g. one + /// produced by `GdMapGenerator::generate`). Replaces any currently + /// attached grid. Used by proof scenes and the world-map bridge to + /// hand real mapgen output to the headless turn processor. + #[func] + fn set_grid_from_gridstate(&mut self, grid: Gd) { + let cloned: mc_core::grid::GridState = grid.bind().inner.clone(); + self.inner.grid = Some(cloned); + } + + /// Stamp a lair onto the attached grid at (col, row) with the given + /// tier and a synthetic species id. Returns `false` if the coordinate + /// is out of bounds or no grid is attached. Used by proof scenes that + /// need a lair distribution without running full ecology evolution. + #[func] + fn stamp_lair(&mut self, col: i64, row: i64, tier: i64, species_id: i64) -> bool { + let grid = match &mut self.inner.grid { + Some(g) => g, + None => return false, + }; + if col < 0 || row < 0 || col >= grid.width as i64 || row >= grid.height as i64 { + return false; + } + let idx = (row * grid.width as i64 + col) as usize; + if idx >= grid.tiles.len() { + return false; + } + grid.tiles[idx].lair_tier = tier.clamp(0, 10) as i32; + grid.tiles[idx].lair_species_id = species_id.max(0) as u32; + grid.tiles[idx].lair_population = 10.0; + true + } + + /// Add a player with the militarist strategic profile (expansion=2, + /// production=5, wealth=2, culture=2) and one starter city at + /// `(city_col, city_row)`. The player starts with 3 dwarf_warrior + /// units adjacent to the city. Returns the new player's index. + #[func] + fn add_player_militarist(&mut self, city_col: i64, city_row: i64) -> i64 { + let pi = self.inner.players.len() as u8; + let mut axes: std::collections::HashMap = std::collections::HashMap::new(); + axes.insert("expansion".into(), 2); + axes.insert("production".into(), 5); + axes.insert("wealth".into(), 2); + axes.insert("culture".into(), 2); + + let city_col = city_col as i32; + let city_row = city_row as i32; + let units = (0..3) + .map(|i| mc_turn::MapUnit { + col: city_col + i, + row: city_row, + hp: 60, + max_hp: 60, + attack: 12, + defense: 1, + is_fortified: false, + unit_id: "dwarf_warrior".to_string(), + }) + .collect(); + + self.inner.players.push(mc_turn::PlayerState { + player_index: pi, + gold: 60, + cities: vec![mc_city::CityState::starter()], + unit_upkeep: Vec::new(), + strategic_axes: axes, + scoring_weights: mc_ai::evaluator::ScoringWeights::default(), + expansion_points: 0, + city_buildings: vec![Vec::new()], + city_ecology: vec![Default::default()], + tech_state: None, + science_yield: 0, + units, + city_positions: vec![(city_col, city_row)], + capital_position: Some((city_col, city_row)), + culture_total: 0, + arcane_lore_pop_deducted: false, + }); + pi as i64 + } + + /// Current turn number. + #[func] + fn turn(&self) -> i64 { + self.inner.turn as i64 + } + + /// Number of players attached to this state. + #[func] + fn player_count(&self) -> i64 { + self.inner.players.len() as i64 + } + + /// Number of cities owned by player `pi`. + #[func] + fn city_count(&self, pi: i64) -> i64 { + self.inner.players.get(pi as usize) + .map(|p| p.cities.len() as i64) + .unwrap_or(0) + } + + /// Number of units owned by player `pi`. + #[func] + fn unit_count(&self, pi: i64) -> i64 { + self.inner.players.get(pi as usize) + .map(|p| p.units.len() as i64) + .unwrap_or(0) + } + + /// Current gold treasury for player `pi`. + #[func] + fn gold(&self, pi: i64) -> i64 { + self.inner.players.get(pi as usize) + .map(|p| p.gold as i64) + .unwrap_or(0) + } + + /// Directly set the turn counter. Used by the iter 7k GDScript adapter + /// to seed the deterministic RNG from the live `GameState.turn_number` + /// so each real game turn produces a different encounter roll stream. + /// Without this, a rebuild-state-per-call pattern always starts with + /// `state.turn = 0` and produces identical RNG draws. + #[func] + fn set_turn(&mut self, turn: i64) { + self.inner.turn = turn.clamp(0, u32::MAX as i64) as u32; + } + + /// Total lair count in the attached grid (all tiers). + #[func] + fn lair_count(&self) -> i64 { + self.inner + .grid + .as_ref() + .map(|g| g.tiles.iter().filter(|t| t.lair_tier > 0).count() as i64) + .unwrap_or(0) + } + + // ── Iter 7h: dict-based live state ingestion ─────────────────────── + // + // The Godot game's `Unit` class is currently a 2-line stub in the + // working tree, so the bridge intentionally does NOT bind to a + // specific GDScript class shape. Instead, callers pass `Array[Dictionary]` + // with stable string keys. The translation layer (live `Unit.gd` ↔ + // dict) lives in GDScript, where adding new fields stays a project-side + // concern and doesn't require rebuilding the GDExtension. + + /// Add an empty player with explicit strategic axes. Returns the new + /// player's index. Unlike `add_player_militarist`, this gives the + /// caller full control over the axis weights and starts the player + /// with zero units, zero cities, and zero gold — populate via + /// `set_player_units_from_dicts` / `set_player_cities_from_array`. + /// + /// `axes` keys: "expansion", "production", "wealth", "culture", "magic" + /// (any subset; missing keys default per the turn processor). + #[func] + fn add_empty_player_with_axes(&mut self, axes: Dictionary) -> i64 { + let pi = self.inner.players.len() as u8; + let mut axis_map: std::collections::HashMap = + std::collections::HashMap::new(); + for key in ["expansion", "production", "wealth", "culture", "magic"] { + if let Some(v) = axes.get(key) { + if let Ok(n) = v.try_to::() { + axis_map.insert(key.to_string(), n.clamp(0, 255) as u8); + } + } + } + + self.inner.players.push(mc_turn::PlayerState { + player_index: pi, + gold: 0, + cities: Vec::new(), + unit_upkeep: Vec::new(), + strategic_axes: axis_map, + scoring_weights: mc_ai::evaluator::ScoringWeights::default(), + expansion_points: 0, + city_buildings: Vec::new(), + city_ecology: Vec::new(), + tech_state: None, + science_yield: 0, + units: Vec::new(), + city_positions: Vec::new(), + capital_position: None, + culture_total: 0, + arcane_lore_pop_deducted: false, + }); + pi as i64 + } + + /// Set the gold treasury for player `pi`. Returns `false` if the + /// player index is out of range. + #[func] + fn set_gold(&mut self, pi: i64, gold: i64) -> bool { + let Some(p) = self.inner.players.get_mut(pi as usize) else { + return false; + }; + p.gold = gold.max(i32::MIN as i64).min(i32::MAX as i64) as i32; + true + } + + /// Replace player `pi`'s units list from a Godot Array of Dictionaries. + /// Each dict must contain (any missing keys default to sane values): + /// - col, row: i64 (required, world coordinates) + /// - hp, max_hp: i64 (default 60) + /// - attack, defense: i64 (default 12, 1) + /// - is_fortified: bool (default false) + /// - unit_id: String (default "dwarf_warrior") + /// + /// Returns the number of units actually ingested (may be less than the + /// input array length if entries are malformed). Logs a `godot_error!` + /// for each rejected entry. + #[func] + fn set_player_units_from_dicts(&mut self, pi: i64, units: Array) -> i64 { + let Some(p) = self.inner.players.get_mut(pi as usize) else { + godot_error!( + "GdGameState::set_player_units_from_dicts: player index {} out of range", + pi + ); + return 0; + }; + let mut ingested = Vec::with_capacity(units.len()); + for i in 0..units.len() { + let Some(dict) = units.get(i) else { continue }; + let col = dict.get("col").and_then(|v| v.try_to::().ok()); + let row = dict.get("row").and_then(|v| v.try_to::().ok()); + let (Some(col), Some(row)) = (col, row) else { + godot_error!( + "GdGameState::set_player_units_from_dicts: entry {} missing col/row", + i + ); + continue; + }; + ingested.push(mc_turn::MapUnit { + col: col as i32, + row: row as i32, + hp: dict.get("hp").and_then(|v| v.try_to::().ok()).unwrap_or(60) as i32, + max_hp: dict + .get("max_hp") + .and_then(|v| v.try_to::().ok()) + .unwrap_or(60) as i32, + attack: dict + .get("attack") + .and_then(|v| v.try_to::().ok()) + .unwrap_or(12) as i32, + defense: dict + .get("defense") + .and_then(|v| v.try_to::().ok()) + .unwrap_or(1) as i32, + is_fortified: dict + .get("is_fortified") + .and_then(|v| v.try_to::().ok()) + .unwrap_or(false), + unit_id: dict + .get("unit_id") + .and_then(|v| v.try_to::().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "dwarf_warrior".to_string()), + }); + } + let n = ingested.len() as i64; + p.units = ingested; + // Reset upkeep alignment so the economy phase doesn't deduct stale upkeep. + p.unit_upkeep = vec![0; p.units.len()]; + n + } + + /// Replace player `pi`'s cities list from a Godot Array of Vector2i + /// world positions. Each entry creates a starter city at that position. + /// Returns the number of cities actually ingested. + #[func] + fn set_player_cities_from_array( + &mut self, + pi: i64, + positions: Array, + ) -> i64 { + let Some(p) = self.inner.players.get_mut(pi as usize) else { + godot_error!( + "GdGameState::set_player_cities_from_array: player index {} out of range", + pi + ); + return 0; + }; + p.cities.clear(); + p.city_positions.clear(); + p.city_buildings.clear(); + p.city_ecology.clear(); + for i in 0..positions.len() { + let Some(pos) = positions.get(i) else { continue }; + p.cities.push(mc_city::CityState::starter()); + p.city_positions.push((pos.x, pos.y)); + p.city_buildings.push(Vec::new()); + p.city_ecology.push(Default::default()); + } + p.cities.len() as i64 + } + + /// Read player `pi`'s units back as a Godot Array of Dictionaries with + /// the same key shape as `set_player_units_from_dicts`. Used by GDScript + /// adapters to detect which units survived a turn. + #[func] + fn get_player_units_as_dicts(&self, pi: i64) -> Array { + let mut result: Array = Array::new(); + let Some(p) = self.inner.players.get(pi as usize) else { + return result; + }; + for u in &p.units { + let mut d = Dictionary::new(); + d.set("col", u.col as i64); + d.set("row", u.row as i64); + d.set("hp", u.hp as i64); + d.set("max_hp", u.max_hp as i64); + d.set("attack", u.attack as i64); + d.set("defense", u.defense as i64); + d.set("is_fortified", u.is_fortified); + d.set("unit_id", GString::from(u.unit_id.as_str())); + result.push(&d); + } + result + } +} + +// ── GdTurnProcessor ───────────────────────────────────────────────────── +// +// Wraps mc_turn::TurnProcessor — the headless turn-advancement engine. A +// single processor instance is shared across all `step()` calls; the +// balance config lives on the processor so Godot can tweak it between +// turns if needed. + +/// Headless turn-processor bridge. Holds one `mc_turn::TurnProcessor` +/// configured with default Phase-7-calibrated balance. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdTurnProcessor { + inner: mc_turn::TurnProcessor, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdTurnProcessor { + fn init(base: Base) -> Self { + Self { + inner: mc_turn::TurnProcessor::new(u32::MAX), + base, + } + } +} + +#[godot_api] +impl GdTurnProcessor { + /// Set the maximum turn budget for the advisory victory check. The + /// turn loop is externally driven, so this is typically only used to + /// disable premature winners (`u32::MAX`) or to set a specific cap. + #[func] + fn set_max_turns(&mut self, max_turns: i64) { + self.inner.max_turns = max_turns.max(0) as u32; + } + + /// Set the minimum city count for the advisory victory trigger. + #[func] + fn set_victory_city_count(&mut self, threshold: i64) { + self.inner.victory_city_count = threshold.clamp(0, 255) as u8; + } + + /// Advance `state` by one turn. Returns a dictionary describing the + /// turn result: + /// { + /// "turn": , + /// "winner": , + /// "units_lost_to_fauna": , + /// "cities_harassed": , + /// "encounters": , + /// "deaths": , // subset of `encounters` + /// "t4_t6_encounters": , + /// "t4_t6_deaths": , + /// "t7_t10_encounters": , + /// "t7_t10_deaths": , + /// "fauna_combat_log": Array[Dictionary] of per-event records (iter 7h) + /// } + /// + /// The `fauna_combat_log` entries each look like: + /// { + /// "turn": , + /// "lair_tier": , + /// "lair_col": , + /// "lair_row": , + /// "unit_col": , + /// "unit_row": , + /// "player_index": , + /// "unit_survived": , + /// } + /// Used by GDScript adapters to map encounters back onto live game-side + /// `Unit` instances and re-emit the existing `EventBus` death signals. + #[func] + fn step(&self, mut state: Gd) -> Dictionary { + let result = { + let mut bound = state.bind_mut(); + self.inner.step(&mut bound.inner) + }; + turn_result_to_dict(&result, state.bind().inner.turn) + } + + // ── Balance knob setters for live tuning from Godot ──────────────── + + #[func] + fn set_base_kill_rate(&mut self, v: f32) { + self.inner.lair_combat_config.base_kill_rate = v; + } + + #[func] + fn set_tier_kill_slope(&mut self, v: f32) { + self.inner.lair_combat_config.tier_kill_slope = v; + } + + #[func] + fn set_tier_kill_exponent(&mut self, v: f32) { + self.inner.lair_combat_config.tier_kill_exponent = v; + } + + #[func] + fn set_encounter_probability_per_turn(&mut self, v: f32) { + self.inner.lair_combat_config.encounter_probability_per_turn = v; + } + + // ── Whole-config JSON roundtrip (save/load surface) ──────────────── + + /// Serialize the current `LairCombatConfig` to a JSON string. Intended for + /// save-game snapshots and for debug tooling that wants to dump the full + /// balance config in one call rather than reading every setter. + #[func] + fn config_to_json(&self) -> GString { + match serde_json::to_string(&self.inner.lair_combat_config) { + Ok(s) => s.into(), + Err(e) => { + godot_error!("GdTurnProcessor::config_to_json error: {}", e); + GString::new() + } + } + } + + /// Parse a JSON string into a `LairCombatConfig` and overwrite the live + /// config. Returns `false` on parse failure (logged via `godot_error!`) + /// and leaves the existing config untouched. + #[func] + fn config_from_json(&mut self, json: GString) -> bool { + match serde_json::from_str::(&json.to_string()) { + Ok(cfg) => { + self.inner.lair_combat_config = cfg; + true + } + Err(e) => { + godot_error!("GdTurnProcessor::config_from_json parse error: {}", e); + false + } + } + } + + // ── Iter 7j: encounters-only step for the GDScript adapter ───────── + + /// Run **only** the fauna encounter resolution phase. Skips economy, + /// city production, founding, unit spawn, and victory check. + /// + /// This is the iter 7j entry point for the GDScript adapter pattern: when + /// the real game's `turn_processor.gd` is in charge of all the other + /// phases, calling `step()` would cause Rust to spawn ghost units that + /// shadow the live `Unit.gd` instances. `step_encounters_only` runs only + /// the encounter loop so the bridge stays a pure read-from-state / + /// write-to-state operation. + /// + /// Returns the same Dictionary shape as `step()`, but `winner` is always + /// `-1` and the `encounters` / `deaths` / `t4_t6_*` / `t7_t10_*` / + /// `fauna_combat_log` fields are the only ones with non-zero values. + /// `units_lost_to_fauna` and `cities_harassed` are populated. + #[func] + fn step_encounters_only(&self, mut state: Gd) -> Dictionary { + let result = { + let mut bound = state.bind_mut(); + self.inner.step_encounters_only(&mut bound.inner) + }; + turn_result_to_dict(&result, state.bind().inner.turn) + } +} + +/// Pack a `mc_turn::TurnResult` into the Godot `Dictionary` shape both +/// `step()` and `step_encounters_only()` return. Extracted in iter 7j to +/// avoid duplicating the per-event log walk + bracket counting in two +/// `#[func]` methods that otherwise diverge only in which Rust method +/// they call on the underlying `TurnProcessor`. +fn turn_result_to_dict(result: &mc_turn::TurnResult, post_turn: u32) -> Dictionary { + let mut d = Dictionary::new(); + d.set("turn", post_turn as i64); + d.set("winner", result.winner.map(|(w, _)| w as i64).unwrap_or(-1)); + d.set("victory_type", result.winner.map(|(_, vt)| vt.as_str().to_string()).unwrap_or_default()); + d.set("units_lost_to_fauna", result.units_lost_to_fauna as i64); + d.set("cities_harassed", result.cities_harassed_by_fauna as i64); + + let mut encounters = 0_i64; + let mut deaths = 0_i64; + let mut t4_t6_enc = 0_i64; + let mut t4_t6_deaths = 0_i64; + let mut t7_t10_enc = 0_i64; + let mut t7_t10_deaths = 0_i64; + let mut log: Array = Array::new(); + for ev in &result.fauna_combat_log { + encounters += 1; + if !ev.unit_survived { + deaths += 1; + } + match ev.lair_tier { + 4..=6 => { + t4_t6_enc += 1; + if !ev.unit_survived { + t4_t6_deaths += 1; + } + } + 7..=10 => { + t7_t10_enc += 1; + if !ev.unit_survived { + t7_t10_deaths += 1; + } + } + _ => {} + } + let mut entry = Dictionary::new(); + entry.set("turn", ev.turn as i64); + entry.set("lair_tier", ev.lair_tier as i64); + entry.set("lair_col", ev.lair_col as i64); + entry.set("lair_row", ev.lair_row as i64); + entry.set("unit_col", ev.unit_col as i64); + entry.set("unit_row", ev.unit_row as i64); + entry.set("player_index", ev.player_index as i64); + entry.set("unit_survived", ev.unit_survived); + log.push(&entry); + } + d.set("encounters", encounters); + d.set("deaths", deaths); + d.set("t4_t6_encounters", t4_t6_enc); + d.set("t4_t6_deaths", t4_t6_deaths); + d.set("t7_t10_encounters", t7_t10_enc); + d.set("t7_t10_deaths", t7_t10_deaths); + d.set("fauna_combat_log", log); + d +} + +// ── GdCombatResolver ──────────────────────────────────────────────────── +// +// Stateless wrapper around `mc_combat::CombatResolver`. Accepts attacker/ +// defender stat dicts + optional city context, returns a result dict. The +// GDScript `combat_resolver.gd` wrapper converts Unit objects into these +// dicts and writes the result back to the units. +// +// Input dict shape (attacker/defender): +// { hp: int, max_hp: int, attack: int, defense: int, +// ranged_attack: int, range: int, movement: int, +// keywords: Array[String] } +// +// Params dict shape: +// { combat_type: String ("melee"|"ranged"|"siege"), +// attacker_flanking_allies: int, defender_flanking_allies: int, +// defender_terrain_defense: float, defender_fortification: float, +// city_hp: int (or -1 for no city), city_wall_tier: int, +// city_has_garrison: bool, attacker_is_siege: bool } +// +// Result dict shape matches `mc_combat::CombatResult` field-by-field. + +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdCombatResolver { + base: Base, +} + +#[godot_api] +impl IRefCounted for GdCombatResolver { + fn init(base: Base) -> Self { + Self { base } + } +} + +fn dict_get_i64(d: &Dictionary, key: &str, default: i64) -> i64 { + d.get(key) + .and_then(|v| v.try_to::().ok()) + .unwrap_or(default) +} + +fn dict_get_f64(d: &Dictionary, key: &str, default: f64) -> f64 { + d.get(key) + .and_then(|v| v.try_to::().ok()) + .unwrap_or(default) +} + +fn dict_get_bool(d: &Dictionary, key: &str, default: bool) -> bool { + d.get(key) + .and_then(|v| v.try_to::().ok()) + .unwrap_or(default) +} + +fn dict_get_string(d: &Dictionary, key: &str, default: &str) -> String { + d.get(key) + .and_then(|v| v.try_to::().ok()) + .map(|g| g.to_string()) + .unwrap_or_else(|| default.to_string()) +} + +fn unit_stats_from_dict(d: &Dictionary) -> mc_combat::UnitStats { + mc_combat::UnitStats { + hp: dict_get_i64(d, "hp", 0) as i32, + max_hp: dict_get_i64(d, "max_hp", 1) as i32, + attack: dict_get_i64(d, "attack", 0) as i32, + defense: dict_get_i64(d, "defense", 0) as i32, + ranged_attack: dict_get_i64(d, "ranged_attack", 0) as i32, + range: dict_get_i64(d, "range", 0) as i32, + movement: dict_get_i64(d, "movement", 0) as i32, + } +} + +fn keywords_from_dict(d: &Dictionary) -> Vec { + let Some(v) = d.get("keywords") else { + return Vec::new(); + }; + let Ok(arr) = v.try_to::>() else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(arr.len()); + for i in 0..arr.len() { + if let Some(s) = arr.get(i) { + if let Some(kw) = mc_combat::Keyword::from_str(&s.to_string()) { + out.push(kw); + } + } + } + out +} + +fn combat_type_from_str(s: &str) -> mc_combat::CombatType { + match s { + "ranged" => mc_combat::CombatType::Ranged, + "siege" => mc_combat::CombatType::Siege, + _ => mc_combat::CombatType::Melee, + } +} + +#[godot_api] +impl GdCombatResolver { + /// Resolve a combat engagement. See module docs for dict shapes. + #[func] + fn resolve( + attacker: Dictionary, + defender: Dictionary, + params: Dictionary, + ) -> Dictionary { + let attacker_stats = unit_stats_from_dict(&attacker); + let defender_stats = unit_stats_from_dict(&defender); + let attacker_keywords = keywords_from_dict(&attacker); + let defender_keywords = keywords_from_dict(&defender); + + let combat_type_s = dict_get_string(¶ms, "combat_type", "melee"); + let combat_type = combat_type_from_str(&combat_type_s); + + let attacker_bonuses = mc_combat::CombatBonuses { + flanking_allies: dict_get_i64(¶ms, "attacker_flanking_allies", 0) as i32, + ..Default::default() + }; + let defender_bonuses = mc_combat::CombatBonuses { + flanking_allies: dict_get_i64(¶ms, "defender_flanking_allies", 0) as i32, + terrain_defense: dict_get_f64(¶ms, "defender_terrain_defense", 0.0) as f32, + fortification: dict_get_f64(¶ms, "defender_fortification", 0.0) as f32, + ..Default::default() + }; + + let city_hp_raw = dict_get_i64(¶ms, "city_hp", -1); + let city_hp = if city_hp_raw >= 0 { + Some(city_hp_raw as i32) + } else { + None + }; + + let combat_params = mc_combat::CombatParams { + attacker: attacker_stats, + defender: defender_stats, + combat_type, + attacker_keywords, + defender_keywords, + attacker_bonuses, + defender_bonuses, + city_hp, + city_wall_tier: dict_get_i64(¶ms, "city_wall_tier", 0) as i32, + city_has_garrison: dict_get_bool(¶ms, "city_has_garrison", false), + attacker_is_siege: dict_get_bool(¶ms, "attacker_is_siege", false), + }; + + let result = mc_combat::CombatResolver::resolve(&combat_params); + + let mut d = Dictionary::new(); + d.set("defender_damage", result.defender_damage as i64); + d.set("attacker_damage", result.attacker_damage as i64); + d.set( + "attacker_killed", + matches!(result.attacker_outcome, mc_combat::CombatOutcome::Killed), + ); + d.set( + "defender_killed", + matches!(result.defender_outcome, mc_combat::CombatOutcome::Killed), + ); + d.set("attacker_hp", result.attacker_hp as i64); + d.set("defender_hp", result.defender_hp as i64); + d.set("city_damage", result.city_damage as i64); + d.set("city_hp_remaining", result.city_hp_remaining as i64); + d.set("attacker_xp", result.attacker_xp as i64); + d.set("defender_xp", result.defender_xp as i64); + d.set("life_drain_heal", result.life_drain_heal as i64); + d + } + + /// XP threshold for a given promotion level. Returns -1 if beyond max. + #[func] + fn xp_threshold(level: i64) -> i64 { + mc_combat::xp_threshold(level as i32) + .map(|x| x as i64) + .unwrap_or(-1) + } + + /// HP healed when a unit promotes. + #[func] + fn heal_on_promote(max_hp: i64) -> i64 { + mc_combat::heal_on_promote(max_hp as i32) as i64 + } + + /// Derive wild creature stats (tier, size, diet) — same formula as the resolver test. + #[func] + fn wild_combat_stats(tier: i64, size: GString, diet: GString) -> Dictionary { + let stats = + mc_combat::wild_combat_stats(tier as i32, &size.to_string(), &diet.to_string()); + let mut d = Dictionary::new(); + d.set("hp", stats.hp as i64); + d.set("max_hp", stats.max_hp as i64); + d.set("attack", stats.attack as i64); + d.set("defense", stats.defense as i64); + d.set("ranged_attack", stats.ranged_attack as i64); + d.set("range", stats.range as i64); + d.set("movement", stats.movement as i64); + d + } +} diff --git a/api-wasm/Cargo.toml b/api-wasm/Cargo.toml new file mode 100644 index 00000000..1b0de4b2 --- /dev/null +++ b/api-wasm/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "magic-civ-physics" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +mc-core = { path = "../crates/mc-core" } +mc-climate = { path = "../crates/mc-climate" } +mc-mapgen = { path = "../crates/mc-mapgen" } +mc-compute = { path = "../crates/mc-compute", features = ["gpu"] } +wasm-bindgen = "0.2" +js-sys = "0.3" +serde-wasm-bindgen = "0.6" +getrandom = { version = "0.2", features = ["js"] } +serde.workspace = true +serde_json.workspace = true diff --git a/api-wasm/src/lib.rs b/api-wasm/src/lib.rs new file mode 100644 index 00000000..98a46efc --- /dev/null +++ b/api-wasm/src/lib.rs @@ -0,0 +1,307 @@ +//! WASM API surface — exposes Rust simulation to the web guide via wasm-bindgen. +//! All public functions match the signatures specified in Task #2. + +use wasm_bindgen::prelude::*; + +use mc_core::grid::{GridState, biome_registry::{has_tag, BiomeTag}}; +use mc_climate::{ClimatePhysics, EcologyPhysics, step_atmospheric_chemistry}; +use mc_mapgen::MapGenerator; + +/// WASM-exposed grid handle wrapping GridState. +#[wasm_bindgen] +pub struct WasmGrid { + inner: GridState, +} + +#[wasm_bindgen] +impl WasmGrid { + /// Create a new empty grid of given dimensions. + #[wasm_bindgen(constructor)] + pub fn new(width: i32, height: i32) -> Self { + Self { + inner: GridState::new(width, height), + } + } + + /// Serialize the grid to JSON. + #[wasm_bindgen(js_name = "toJSON")] + pub fn to_json(&self) -> Result { + serde_wasm_bindgen::to_value(&self.inner).map_err(|e| JsError::new(&e.to_string())) + } + + /// Deserialize a grid from JSON. + #[wasm_bindgen(js_name = "fromJSON")] + pub fn from_json(val: JsValue) -> Result { + let inner: GridState = serde_wasm_bindgen::from_value(val).map_err(|e| JsError::new(&e.to_string()))?; + Ok(Self { inner }) + } + + #[wasm_bindgen(getter)] + pub fn width(&self) -> i32 { + self.inner.width + } + + #[wasm_bindgen(getter)] + pub fn height(&self) -> i32 { + self.inner.height + } + + #[wasm_bindgen(getter)] + pub fn global_avg_temp(&self) -> f32 { + self.inner.global_avg_temp + } + + #[wasm_bindgen(getter)] + pub fn ocean_dead_fraction(&self) -> f32 { + self.inner.ocean_dead_fraction + } + + #[wasm_bindgen(getter)] + pub fn tile_count(&self) -> usize { + self.inner.tiles.len() + } + + /// Compute aggregate turn stats in Rust and return as a JSON string. + /// This replaces the JS `computeTurnStats(wasmGridToJs(wg))` pattern — avoids + /// serializing all tile data to JS just to aggregate it. + #[wasm_bindgen(js_name = "computeStatsJson")] + pub fn compute_stats_json(&self, prev_avg_temp: f32, prev_avg_moisture: f32) -> String { + let grid = &self.inner; + let tiles = &grid.tiles; + let n = tiles.len(); + let height = grid.height as f32; + + let mut temp_sum = 0.0f32; + let mut moist_sum = 0.0f32; + let mut surface_water_sum = 0.0f32; + let mut albedo_sum = 0.0f32; + let mut solar_sum = 0.0f32; + let mut land_canopy_sum = 0.0f32; + let mut land_undergrowth_sum = 0.0f32; + let mut land_fungi_sum = 0.0f32; + let mut land_habitat_sum = 0.0f32; + let mut land_quality_sum = 0.0f32; + let mut water_reef_sum = 0.0f32; + let mut water_fish_sum = 0.0f32; + let mut water_quality_sum = 0.0f32; + let mut water_count = 0usize; + let mut aerosol_sum = 0.0f32; + let mut et_sum = 0.0f32; + let mut wind_speed_sum = 0.0f32; + let mut land_count = 0usize; + let mut terrain_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); + let mut substrate_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); + let mut elevation_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); + + for tile in tiles { + *terrain_counts.entry(tile.biome_id.as_str()).or_insert(0) += 1; + if !tile.substrate_id.is_empty() { + *substrate_counts.entry(tile.substrate_id.as_str()).or_insert(0) += 1; + } + + let elev_band = if tile.elevation < 0.15 { "abyss" } + else if tile.elevation < 0.30 { "deep_water" } + else if tile.elevation < 0.45 { "shallow_water" } + else if tile.elevation < 0.55 { "lowland" } + else if tile.elevation < 0.70 { "midland" } + else if tile.elevation < 0.85 { "highland" } + else { "peak" }; + *elevation_counts.entry(elev_band).or_insert(0) += 1; + + let is_water = has_tag(&tile.biome_id, BiomeTag::IsWater); + + // Albedo calculation matching computeTileAlbedo() in runner.ts + let albedo = if tile.temperature < 0.1 { + let t = (tile.temperature / 0.1).clamp(0.0, 1.0); + let base = if is_water { 0.06 } else { 0.25 }; + 0.80 + t * (base - 0.80) + } else if is_water { + 0.06 + } else { + let vegetation = tile.canopy_cover * 0.7 + tile.undergrowth * 0.3; + 0.35 + vegetation * (0.12 - 0.35) + }; + + // Solar by row matching solarByRow() in runner.ts + let row_frac = tile.row as f32 / height; + let solar = 1.0 - (row_frac - 0.5).abs() * 0.7; + + albedo_sum += albedo; + solar_sum += solar * (1.0 - albedo); + aerosol_sum += tile.sulfate_aerosol; + wind_speed_sum += tile.wind_speed; + surface_water_sum += tile.surface_water; + + if is_water { + water_count += 1; + water_quality_sum += tile.quality as f32; + water_reef_sum += tile.reef_health; + water_fish_sum += tile.fish_stock; + } else { + land_count += 1; + temp_sum += tile.temperature; + moist_sum += tile.moisture; + land_canopy_sum += tile.canopy_cover; + land_undergrowth_sum += tile.undergrowth; + land_fungi_sum += tile.fungi_network; + land_habitat_sum += tile.habitat_suitability; + land_quality_sum += tile.quality as f32; + let veg = tile.canopy_cover * 0.6 + tile.undergrowth * 0.4; + et_sum += veg * 0.15; + } + } + + let nf = n.max(1) as f32; + let lf = land_count.max(1) as f32; + let wf = water_count.max(1) as f32; + let avg_temp = temp_sum / lf; + let avg_moist = moist_sum / lf; + + // Build terrain/substrate/elevation JSON fragments + let terrain_json = terrain_counts.iter() + .map(|(k, v)| format!("\"{}\":{}", k, v)) + .collect::>().join(","); + let substrate_json = substrate_counts.iter() + .map(|(k, v)| format!("\"{}\":{}", k, v)) + .collect::>().join(","); + let elevation_json = elevation_counts.iter() + .map(|(k, v)| format!("\"{}\":{}", k, v)) + .collect::>().join(","); + + format!( + r#"{{"avg_temp":{avg_temp},"avg_moisture":{avg_moist},"total_ley_strength":0,"dominant_ley_school":"","ley_school_strengths":{{"death":0,"life":0,"nature":0,"aether":0,"chaos":0}},"ley_land_coverage":{{"death":0,"life":0,"nature":0,"aether":0,"chaos":0}},"ocean_pct":{ocean_pct},"ocean_dead_pct":{ocean_dead_pct},"sea_level":{sea_level},"avg_albedo":{avg_albedo},"avg_solar":{avg_solar},"avg_land_canopy":{avg_land_canopy},"avg_land_undergrowth":{avg_land_undergrowth},"avg_land_fungi":{avg_land_fungi},"avg_land_habitat":{avg_land_habitat},"avg_water_reef":{avg_water_reef},"avg_water_fish":{avg_water_fish},"avg_land_quality":{avg_land_quality},"avg_water_quality":{avg_water_quality},"avg_aerosol":{avg_aerosol},"avg_wind_speed":{avg_wind_speed},"avg_evapotranspiration":{avg_et},"net_energy":{net_energy},"net_hydro":{net_hydro},"avg_surface_water":{avg_surface_water},"total_ocean_water":{total_ocean_water},"terrain_counts":{{{terrain_json}}},"substrate_counts":{{{substrate_json}}},"elevation_counts":{{{elevation_json}}},"o2_fraction":{o2_fraction},"co2_ppm":{co2_ppm},"ch4_ppb":{ch4_ppb},"ecological_collapse":{ecological_collapse},"ocean_toxic":{ocean_toxic},"ocean_anoxic":{ocean_anoxic},"dead_ocean":{dead_ocean},"canfield_ocean":{canfield_ocean},"ocean_toxicity":{ocean_toxicity},"ocean_o2_contribution":{ocean_o2_contribution},"global_fish_stock":{global_fish_stock},"trophic_cascade_active":{trophic_cascade_active}}}"#, + avg_temp = avg_temp, + avg_moist = avg_moist, + ocean_pct = if n > 0 { (n - land_count) as f32 / n as f32 } else { 0.0 }, + ocean_dead_pct = grid.ocean_dead_fraction, + sea_level = grid.sea_level, + avg_albedo = albedo_sum / nf, + avg_solar = solar_sum / nf, + avg_land_canopy = land_canopy_sum / lf, + avg_land_undergrowth = land_undergrowth_sum / lf, + avg_land_fungi = land_fungi_sum / lf, + avg_land_habitat = land_habitat_sum / lf, + avg_water_reef = water_reef_sum / wf, + avg_water_fish = water_fish_sum / wf, + avg_land_quality = land_quality_sum / lf, + avg_water_quality = water_quality_sum / wf, + avg_aerosol = aerosol_sum / nf, + avg_wind_speed = wind_speed_sum / nf, + avg_et = et_sum / lf, + net_energy = avg_temp - prev_avg_temp, + net_hydro = avg_moist - prev_avg_moisture, + avg_surface_water = surface_water_sum / nf, + total_ocean_water = grid.total_ocean_water, + terrain_json = terrain_json, + substrate_json = substrate_json, + elevation_json = elevation_json, + o2_fraction = grid.o2_fraction, + co2_ppm = grid.co2_ppm, + ch4_ppb = grid.ch4_ppb, + ecological_collapse = grid.ecological_collapse, + ocean_toxic = grid.ocean_toxic, + ocean_anoxic = grid.ocean_anoxic, + dead_ocean = grid.dead_ocean, + canfield_ocean = grid.canfield_ocean, + ocean_toxicity = grid.ocean_toxicity, + ocean_o2_contribution = grid.ocean_o2_contribution, + global_fish_stock = grid.global_fish_stock, + trophic_cascade_active = grid.trophic_cascade_active, + ) + } +} + +/// WASM-exposed climate physics engine. +#[wasm_bindgen] +pub struct WasmClimatePhysics { + inner: ClimatePhysics, +} + +#[wasm_bindgen] +impl WasmClimatePhysics { + #[wasm_bindgen(constructor)] + pub fn new(params_json: &str, terrain_json: &str, spec_json: &str) -> Self { + Self { + inner: ClimatePhysics::new(params_json, terrain_json, spec_json), + } + } + + /// Run one turn of climate simulation. + #[wasm_bindgen(js_name = "processStep")] + pub fn process_step(&mut self, grid: &mut WasmGrid, turn: u32, seed: u32, dt: f32) { + self.inner.process_step(&mut grid.inner, turn, seed as u64, dt); + } + + /// Run atmospheric chemistry using the spec stored at construction time. + /// Avoids reparsing spec_json on every turn — use this instead of the standalone + /// stepAtmosphericChemistry() WASM function for the hot simulation loop. + #[wasm_bindgen(js_name = "stepAtmosphericChemistry")] + pub fn step_atmospheric_chemistry(&self, grid: &mut WasmGrid) { + self.inner.step_atmospheric_chemistry(&mut grid.inner); + } + + /// Write per-tile data into Float32Array slices for GPU rendering. + /// Layout matches encodeSnapshot() in runner.ts exactly. + #[wasm_bindgen(js_name = "writeFrameBuffers")] + pub fn write_frame_buffers(&self, grid: &WasmGrid, tex_a: &mut [f32], tex_b: &mut [f32], tex_c: &mut [f32]) { + self.inner.write_frame_buffers(&grid.inner, tex_a, tex_b, tex_c); + } +} + +/// WASM-exposed ecology physics engine. +#[wasm_bindgen] +pub struct WasmEcologyPhysics { + inner: EcologyPhysics, +} + +impl Default for WasmEcologyPhysics { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen] +impl WasmEcologyPhysics { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + inner: EcologyPhysics::new(), + } + } + + /// Run one tick of ecology simulation. + #[wasm_bindgen(js_name = "processStep")] + pub fn process_step(&mut self, grid: &mut WasmGrid, dt: f32) { + self.inner.process_step(&mut grid.inner, dt); + } +} + +/// WASM-exposed atmospheric chemistry step. +#[wasm_bindgen(js_name = "stepAtmosphericChemistry")] +pub fn wasm_step_atmospheric_chemistry(grid: &mut WasmGrid, spec_json: &str) { + let spec: serde_json::Value = serde_json::from_str(spec_json).unwrap_or_default(); + step_atmospheric_chemistry(&mut grid.inner, &spec); +} + +/// WASM-exposed map generator. +#[wasm_bindgen] +pub struct WasmMapGenerator { + inner: MapGenerator, +} + +#[wasm_bindgen] +impl WasmMapGenerator { + #[wasm_bindgen(constructor)] + pub fn new(params_json: &str) -> Self { + Self { + inner: MapGenerator::new(params_json), + } + } + + /// Generate a map and return a WasmGrid. + pub fn generate(&self, seed: u32, map_size: &str) -> WasmGrid { + WasmGrid { + inner: self.inner.generate(seed as u64, map_size), + } + } +}