From 48bbdce97d1adc3f3be8f4f441f808eda00227d2 Mon Sep 17 00:00:00 2001 From: autocommit Date: Fri, 1 May 2026 18:42:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(api-gdext):=20=E2=9C=A8=20Introduce=20acti?= =?UTF-8?q?on=20traits,=20action=20implementations,=20and=20module=20expor?= =?UTF-8?q?ts=20for=20Godot=20Engine=20integration=20in=20the=20simulator?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/api-gdext/src/action.rs | 11 +- .../api-gdext/src/building_action.rs | 120 +++++ src/simulator/api-gdext/src/lib.rs | 472 +++++++++++++++++- 3 files changed, 592 insertions(+), 11 deletions(-) create mode 100644 src/simulator/api-gdext/src/building_action.rs diff --git a/src/simulator/api-gdext/src/action.rs b/src/simulator/api-gdext/src/action.rs index 3e345776..016b1b13 100644 --- a/src/simulator/api-gdext/src/action.rs +++ b/src/simulator/api-gdext/src/action.rs @@ -1,9 +1,9 @@ //! GDExtension bridge for unit action capability queries. //! //! Exposes `GdUnitActions` to GDScript: -//! - `legal_actions(unit_type, keywords, has_movement, is_fortified)` → +//! - `legal_actions(unit_type, keywords, has_movement, is_fortified, is_sentrying)` → //! `Array[Dictionary]` with `{kind, enabled, disabled_reason}` entries -//! - `invoke(unit_type, keywords, has_movement, is_fortified, kind)` → `bool` +//! - `invoke(unit_type, keywords, has_movement, is_fortified, is_sentrying, kind)` → `bool` //! (validation only; state mutations are applied by the GDScript bridge) use godot::prelude::*; @@ -27,10 +27,11 @@ impl GdUnitActions { /// Return the legal actions for a unit described by its capability parameters. /// /// Parameters match the fields of `UnitCapability`: - /// - `unit_type`: `"military"`, `"support"`, `"civilian"`, etc. + /// - `unit_type`: `"melee"`, `"ranged"`, `"siege"`, `"support"`, `"civilian"`, `"summoned"` /// - `keywords`: space-separated keyword string, e.g. `"ranged"` or `"worker founder"` /// - `has_movement`: true when movement_remaining > 0 /// - `is_fortified`: true when the unit is currently fortified + /// - `is_sentrying`: true when the unit is in sentry posture /// /// Returns `Array[Dictionary]` where each Dictionary has: /// - `kind: String` — the action id, e.g. `"fortify"` @@ -43,6 +44,7 @@ impl GdUnitActions { keywords: GString, has_movement: bool, is_fortified: bool, + is_sentrying: bool, ) -> Array { let cap = UnitCapability { unit_type: unit_type.to_string(), @@ -55,6 +57,7 @@ impl GdUnitActions { has_movement, is_fortified, is_patrolling: false, + is_sentrying, }; let actions = legal_actions(&cap); @@ -75,6 +78,7 @@ impl GdUnitActions { keywords: GString, has_movement: bool, is_fortified: bool, + is_sentrying: bool, kind: GString, ) -> bool { let kind_str = kind.to_string(); @@ -92,6 +96,7 @@ impl GdUnitActions { has_movement, is_fortified, is_patrolling: false, + is_sentrying, }; let actions = legal_actions(&cap); actions diff --git a/src/simulator/api-gdext/src/building_action.rs b/src/simulator/api-gdext/src/building_action.rs new file mode 100644 index 00000000..a5b920a2 --- /dev/null +++ b/src/simulator/api-gdext/src/building_action.rs @@ -0,0 +1,120 @@ +//! GDExtension bridge for building action capability queries. +//! +//! Exposes `GdBuildingActions` to GDScript: +//! - `legal_actions_for(building_type, keywords, current_hp, max_hp, is_active, +//! garrison_count, garrison_capacity, has_rally_target)` → `Array[Dictionary]` +//! - `invoke(state, player_idx, city_idx, building_id, kind)` — queued-request mutation + +use godot::prelude::*; +use mc_core::building_action::{ + BuildingActionAvailability, BuildingActionKind, BuildingCapability, + legal_actions_for_building, +}; +use mc_turn::game_state::BuildingActionRequest; + +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdBuildingActions { + base: Base, +} + +#[godot_api] +impl IRefCounted for GdBuildingActions { + fn init(base: Base) -> Self { + Self { base } + } +} + +#[godot_api] +impl GdBuildingActions { + /// Return the legal actions for a building described by its capability parameters. + /// + /// - `building_type`: `"production"`, `"defensive"`, `"tower"`, `"wonder"`, `"city_center"` + /// - `keywords`: space-separated keyword string, e.g. `"rally garrison"` + /// - `current_hp` / `max_hp`: hit points for repair gating + /// - `is_active`: toggle state + /// - `garrison_count` / `garrison_capacity`: for garrison gating + /// - `has_rally_target`: whether a rally hex is already set + /// + /// Returns `Array[Dictionary]` where each Dictionary has: + /// - `kind: String` — the action id, e.g. `"set_rally"` + /// - `enabled: bool` + /// - `disabled_reason: String` — vocab key or empty string when enabled + #[func] + pub fn legal_actions_for( + &self, + building_type: GString, + keywords: GString, + current_hp: i64, + max_hp: i64, + is_active: bool, + garrison_count: i64, + garrison_capacity: i64, + has_rally_target: bool, + ) -> Array { + let cap = BuildingCapability { + building_type: building_type.to_string(), + keywords: keywords + .to_string() + .split_whitespace() + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(), + current_hp: current_hp.max(0) as u32, + max_hp: max_hp.max(0) as u32, + is_active, + garrison_count: garrison_count.max(0) as u32, + garrison_capacity: garrison_capacity.max(0) as u32, + has_rally_target, + }; + let actions = legal_actions_for_building(&cap); + availability_to_godot_array(&actions) + } + + /// Queue a building action onto `state.pending_building_actions`. + /// Drained at the start of the next turn by the turn processor. + /// + /// `kind` must be a valid `BuildingActionKind::as_str()` value, e.g. `"set_rally"`. + /// Unknown kinds are silently dropped (programmer error — log and investigate). + #[func] + pub fn invoke( + &self, + state: Gd, + player_idx: i64, + city_idx: i64, + building_id: GString, + kind: GString, + ) { + let kind_str = kind.to_string(); + let Some(action_kind) = BuildingActionKind::from_str(&kind_str) else { + godot_error!("GdBuildingActions::invoke: unknown BuildingActionKind {:?}", kind_str); + return; + }; + let mut gs = state.clone(); + gs.bind_mut().inner.pending_building_actions.push(BuildingActionRequest { + player_idx: player_idx.max(0) as usize, + city_idx: city_idx.max(0) as usize, + building_id: building_id.to_string(), + kind: action_kind, + }); + } +} + +fn availability_to_godot_array(actions: &[BuildingActionAvailability]) -> Array { + let mut arr = Array::new(); + for a in actions { + let mut d = Dictionary::new(); + d.set("kind", GString::from(a.kind.as_str())); + d.set("enabled", a.enabled); + d.set( + "disabled_reason", + GString::from( + a.disabled_reason + .map(|r| r.vocab_key()) + .unwrap_or(""), + ), + ); + arr.push(&d); + } + arr +} diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 479c8c06..3ab3fac4 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -10,6 +10,7 @@ pub mod action; pub mod ai; +pub mod building_action; use godot::prelude::*; @@ -151,6 +152,271 @@ impl GdGridState { }) .collect() } + + /// Return tectonic fields for a single tile as a Dictionary. + /// Keys: plate_id (int), plate_kind (int), boundary_kind (int), + /// mountain_proximity (float), coast_proximity (float). + /// Returns an empty Dictionary if the tile coordinates are out of range. + #[func] + fn tile_tectonics(&self, col: i64, row: i64) -> Dictionary { + match self.inner.tile(col as i32, row as i32) { + Some(tile) => { + let mut d = Dictionary::new(); + d.set("plate_id", tile.plate_id as i64); + d.set("plate_kind", tile.plate_kind as i64); + d.set("boundary_kind", tile.boundary_kind as i64); + d.set("mountain_proximity", tile.mountain_proximity as f64); + d.set("coast_proximity", tile.coast_proximity as f64); + d + } + None => Dictionary::new(), + } + } + + /// Return climate axes fields for a single tile as a Dictionary. + /// Keys: latitude, continentality, mean_temp, mean_precip, seasonality, + /// aridity_index (floats), t_band, p_band (ints). + /// Returns an empty Dictionary if the tile coordinates are out of range. + #[func] + fn tile_climate(&self, col: i64, row: i64) -> Dictionary { + match self.inner.tile(col as i32, row as i32) { + Some(tile) => { + let mut d = Dictionary::new(); + d.set("latitude", tile.latitude as f64); + d.set("continentality", tile.continentality as f64); + d.set("mean_temp", tile.mean_temp as f64); + d.set("mean_precip", tile.mean_precip as f64); + d.set("seasonality", tile.seasonality as f64); + d.set("aridity_index", tile.aridity_index as f64); + d.set("t_band", tile.t_band as i64); + d.set("p_band", tile.p_band as i64); + d + } + None => Dictionary::new(), + } + } + + /// Return substrate field for a single tile as a Dictionary. + /// Keys: id (String). + /// Returns an empty Dictionary if the tile coordinates are out of range. + #[func] + fn tile_substrate(&self, col: i64, row: i64) -> Dictionary { + match self.inner.tile(col as i32, row as i32) { + Some(tile) => { + let mut d = Dictionary::new(); + d.set("id", GString::from(tile.substrate_id.as_str())); + d + } + None => Dictionary::new(), + } + } + + /// Return flora-cover field for a single tile as a Dictionary. + /// Keys: id (String), biome_label (String). + /// Returns an empty Dictionary if the tile coordinates are out of range. + #[func] + fn tile_flora_cover(&self, col: i64, row: i64) -> Dictionary { + match self.inner.tile(col as i32, row as i32) { + Some(tile) => { + let mut d = Dictionary::new(); + d.set("id", GString::from(tile.flora_cover_id.as_str())); + d.set("biome_label", GString::from(tile.biome_label_id.as_str())); + d + } + None => Dictionary::new(), + } + } + + /// Return hydrology fields for a single tile as a Dictionary. + /// Keys: flow_out (int, 255=no outflow), drainage_area (int), + /// stream_order (int), lake_id (int, -1=none), riparian_distance (int, 255=beyond range). + /// Returns an empty Dictionary if the tile coordinates are out of range. + #[func] + fn tile_hydrology(&self, col: i64, row: i64) -> Dictionary { + match self.inner.tile(col as i32, row as i32) { + Some(tile) => { + let mut d = Dictionary::new(); + d.set("flow_out", tile.flow_out as i64); + d.set("drainage_area", tile.drainage_area as i64); + d.set("stream_order", tile.stream_order as i64); + d.set("lake_id", tile.lake_id.map(|v| v as i64).unwrap_or(-1)); + d.set("riparian_distance", tile.riparian_distance as i64); + d + } + None => Dictionary::new(), + } + } +} + +// ── GdFloraSelector ───────────────────────────────────────────────────── + +/// Godot class that holds a built `TerrainFloraIndex` and exposes per-tile +/// flora selection. Load once at map-gen time; query per tile. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdFloraSelector { + index: Option, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdFloraSelector { + fn init(base: Base) -> Self { + Self { index: None, base } + } +} + +#[godot_api] +impl GdFloraSelector { + /// Load all flora species from a JSON array of species-file contents. + /// Call once before `tile_flora`. Rebuilds the full index. + #[func] + fn load_species(&mut self, species_jsons: Array) { + let strings: Vec = species_jsons.iter_shared().map(|s| s.to_string()).collect(); + let refs: Vec<&str> = strings.iter().map(|s| s.as_str()).collect(); + self.index = Some(mc_ecology::TerrainFloraIndex::from_jsons( + &refs, + &std::collections::HashMap::new(), + )); + } + + /// Return flora species for a tile. + /// + /// `map_seed` — top-level map seed used for determinism. + /// Returns an `Array` with keys: `id`, `name`, `lineage`, `layer`, `quality_tier`. + #[func] + fn tile_flora( + &self, + biome_id: GString, + t_band: i64, + p_band: i64, + riparian_distance: i64, + map_seed: i64, + col: i64, + row: i64, + ) -> Array { + let index = match &self.index { + Some(idx) => idx, + None => return Array::new(), + }; + let selected = mc_ecology::pick_flora_for_tile( + index, + map_seed as u64, + &biome_id.to_string(), + t_band.clamp(0, 4) as u8, + p_band.clamp(0, 4) as u8, + riparian_distance.clamp(0, 255) as u8, + col as u32, + row as u32, + ); + selected.into_iter().map(|s| { + let mut d = Dictionary::new(); + d.set("id", GString::from(s.id.as_str())); + d.set("name", GString::from(s.name.as_str())); + d.set("lineage", GString::from(s.lineage.as_str())); + d.set("layer", GString::from(s.layer.as_str())); + d.set("quality_tier", s.quality_tier as i64); + d + }).collect() + } +} + +// ── GdFaunaSelector ───────────────────────────────────────────────────── + +/// Godot class that holds a built `TerrainFaunaIndex` and exposes per-tile +/// fauna selection. Load once at map-gen time; query per tile. +#[derive(GodotClass)] +#[class(base=RefCounted)] +pub struct GdFaunaSelector { + index: Option, + base: Base, +} + +#[godot_api] +impl IRefCounted for GdFaunaSelector { + fn init(base: Base) -> Self { + Self { index: None, base } + } +} + +#[godot_api] +impl GdFaunaSelector { + /// Load fauna species from JSON species-file contents and a manifest JSON. + /// `manifest_json` is the content of `fauna.json` (the `species: [...]` array). + /// `species_jsons` is a JSON array of individual species-file contents. + #[func] + fn load_species(&mut self, species_jsons: Array, manifest_json: GString) { + let manifest: mc_ecology::FaunaManifest = + match serde_json::from_str(&manifest_json.to_string()) { + Ok(m) => m, + Err(e) => { + godot_error!("GdFaunaSelector::load_species manifest parse error: {e}"); + return; + } + }; + let strings: Vec = species_jsons.iter_shared().map(|s| s.to_string()).collect(); + let refs: Vec<&str> = strings.iter().map(|s| s.as_str()).collect(); + self.index = Some(mc_ecology::TerrainFaunaIndex::from_jsons( + &refs, + &manifest.species, + &std::collections::HashMap::new(), + )); + } + + /// Return fauna species for a tile. + /// + /// `adjacent_fauna_ids` — comma-separated species IDs from adjacent tiles for prey check. + /// Returns an `Array` with keys: `id`, `name`, `lineage`, `domain`, + /// `trophic_level`, `ecology_tier`, `glyph_cluster`. + #[func] + fn tile_fauna( + &self, + biome_id: GString, + t_band: i64, + p_band: i64, + lake_id: i64, + riparian_distance: i64, + map_seed: i64, + col: i64, + row: i64, + adjacent_fauna_ids: GString, + ) -> Array { + let index = match &self.index { + Some(idx) => idx, + None => return Array::new(), + }; + let adj_str = adjacent_fauna_ids.to_string(); + let adj_ids: Vec<&str> = if adj_str.is_empty() { + vec![] + } else { + adj_str.split(',').collect() + }; + let lake = if lake_id >= 0 { Some(lake_id as u32) } else { None }; + let selected = mc_ecology::pick_fauna_for_tile( + index, + map_seed as u64, + &biome_id.to_string(), + t_band.clamp(0, 4) as u8, + p_band.clamp(0, 4) as u8, + lake, + riparian_distance.clamp(0, 255) as u8, + col as u32, + row as u32, + &adj_ids, + ); + selected.into_iter().map(|s| { + let cluster = mc_ecology::lineage_to_glyph_cluster(&s.lineage); + let mut d = Dictionary::new(); + d.set("id", GString::from(s.id.as_str())); + d.set("name", GString::from(s.name.as_str())); + d.set("lineage", GString::from(s.lineage.as_str())); + d.set("domain", GString::from(s.domain.as_str())); + d.set("trophic_level", GString::from(s.trophic_level.as_str())); + d.set("ecology_tier", s.ecology_tier as i64); + d.set("glyph_cluster", GString::from(cluster.as_str())); + d + }).collect() + } } // ── GdClimatePhysics ──────────────────────────────────────────────────── @@ -242,7 +508,7 @@ impl GdEcologyPhysics { let mut sum: f64 = 0.0; let mut count: u64 = 0; for tile in &g.inner.tiles { - if has_tag(&tile.biome_id, BiomeTag::IsWater) { + if has_tag(&tile.biome_label_id, BiomeTag::IsWater) { continue; } sum += tile.canopy_cover as f64; @@ -442,6 +708,46 @@ impl GdMapGenerator { } } + /// Generate a map with world-shape preset overrides. + /// `landmass`, `climate`, `moisture`, `age`, `sea_level` must be preset ID strings + /// (e.g. "pangaea", "hot", "arid", "young", "low"). Returns an empty grid on error. + #[func] + fn generate_with_shape( + &self, + seed: i64, + map_size: GString, + landmass: GString, + climate: GString, + moisture: GString, + age: GString, + sea_level: GString, + ) -> Gd { + match &self.inner { + Some(gen) => { + match mc_mapgen::WorldShape::from_axes( + &landmass.to_string(), + &climate.to_string(), + &moisture.to_string(), + &age.to_string(), + &sea_level.to_string(), + ) { + Ok(shape) => { + let grid = gen.generate_with_shape(seed as u64, &map_size.to_string(), &shape); + Gd::from_init_fn(|base| GdGridState { inner: grid, base }) + } + Err(e) => { + godot_error!("GdMapGenerator::generate_with_shape invalid preset: {e}"); + Gd::from_init_fn(|base| GdGridState { inner: GridState::new(0, 0), base }) + } + } + } + None => { + godot_error!("GdMapGenerator::generate_with_shape called before initialize()"); + Gd::from_init_fn(|base| GdGridState { inner: GridState::new(0, 0), base }) + } + } + } + /// Guarantee at least one iron_ore tile within 8 hexes of each player start. /// Call after `generate()` and after start positions are selected. /// `player_starts` is an Array[Vector2i] of (col, row) starting hexes. @@ -523,7 +829,7 @@ fn tile_to_dict(tile: &mc_core::grid::TileState) -> Dictionary { 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("biome_id", GString::from(tile.biome_label_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); @@ -561,7 +867,7 @@ 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("biome_id") { tile.biome_label_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; } @@ -1581,8 +1887,8 @@ impl GdCity { culture: f64, #[serde(default)] science: f64, - #[serde(default)] - biome_id: String, + #[serde(default, rename = "biome_id")] + biome_label_id: String, #[serde(default = "default_quality")] tile_quality: u8, #[serde(default)] @@ -1594,7 +1900,7 @@ impl GdCity { let docs: Vec = serde_json::from_str(json).unwrap_or_default(); docs.into_iter() .map(|d| { - let collectibles = if d.biome_id.is_empty() { + let collectibles = if d.biome_label_id.is_empty() { vec![] } else { // Seed derived from (turn_seed, col, row) — determinism contract. @@ -1602,7 +1908,7 @@ impl GdCity { ^ ((d.col as u64) << 32) ^ (d.row as u64 & 0xFFFF_FFFF); let mut rng = SplitMix64::new(seed); - tile_collectibles(&d.biome_id, d.tile_quality.clamp(1, 10), &mut rng) + tile_collectibles(&d.biome_label_id, d.tile_quality.clamp(1, 10), &mut rng) }; mc_city::TileYield { coord: (d.col, d.row), @@ -2387,6 +2693,106 @@ impl GdGameState { } arr } + + // ── Edge slot bridge (HEX_GEOMETRY.md §5, §7) ───────────────────────── + // + // Three Godot-callable primitives exposing the centre + 6 edge slots + // model to GDScript: combat preview asks "is there an interceptor on + // the edge?", movement preview asks "can this unit cross the edge?". + + /// Look up the edge interceptor between two adjacent hex centres. + /// + /// Returns a Dictionary: + /// - `has_interceptor`: bool + /// - `unit_id`: int (only valid when has_interceptor) + /// - `owner_player_id`: int (only valid when has_interceptor) + /// - `aligned_to`: Vector2i (parent hex of the interceptor, only valid when present) + /// + /// Returns `has_interceptor: false` for non-adjacent hexes or vacant edges. + /// Per `HEX_GEOMETRY.md` §5, an edge unit is hit before the defender's + /// centre — combat preview UIs must surface this to the player. + #[func] + pub fn engagement_interceptor( + &self, + atk_q: i64, + atk_r: i64, + def_q: i64, + def_r: i64, + ) -> Dictionary { + let mut d = Dictionary::new(); + let interceptor = self.inner.grid.as_ref().and_then(|g| { + g.engagement_interceptor((atk_q as i32, atk_r as i32), (def_q as i32, def_r as i32)) + }); + match interceptor { + Some(occ) => { + d.set("has_interceptor", true); + d.set("unit_id", occ.unit_id as i64); + d.set("owner_player_id", occ.owner_player_id as i64); + d.set( + "aligned_to", + Vector2i::new(occ.aligned_to.0, occ.aligned_to.1), + ); + } + None => { + d.set("has_interceptor", false); + } + } + d + } + + /// Validate a single-step centre-to-centre move for `player_id`. + /// + /// Returns a Dictionary: + /// - `ok`: bool + /// - `reason`: String — one of `"adjacent_clean"` (when ok=true), + /// `"not_adjacent"`, `"wall_blocks"`, `"edge_occupied"` (when ok=false) + /// + /// Movement preview UIs branch on `reason` to show the appropriate + /// player feedback (greyed path, wall icon, enemy unit at edge). + #[func] + pub fn validate_centre_to_centre_move( + &self, + from_q: i64, + from_r: i64, + to_q: i64, + to_r: i64, + player_id: i64, + ) -> Dictionary { + use mc_core::grid::MoveBlockedReason; + let mut d = Dictionary::new(); + let result = self.inner.grid.as_ref().map(|g| { + g.validate_centre_to_centre_move( + (from_q as i32, from_r as i32), + (to_q as i32, to_r as i32), + player_id as u32, + ) + }); + match result { + Some(Ok(_edge)) => { + d.set("ok", true); + d.set("reason", GString::from("adjacent_clean")); + } + Some(Err(MoveBlockedReason::NotAdjacent)) => { + d.set("ok", false); + d.set("reason", GString::from("not_adjacent")); + } + Some(Err(MoveBlockedReason::WallBlocks)) => { + d.set("ok", false); + d.set("reason", GString::from("wall_blocks")); + } + Some(Err(MoveBlockedReason::EdgeOccupied)) => { + d.set("ok", false); + d.set("reason", GString::from("edge_occupied")); + } + None => { + // No grid loaded — treat as not-adjacent fallback so UI + // doesn't try to draw a movement preview. + d.set("ok", false); + d.set("reason", GString::from("no_grid")); + } + } + d + } } // ── GdTurnProcessor ───────────────────────────────────────────────────── @@ -4870,7 +5276,9 @@ impl IRefCounted for GdCityActions { #[godot_api] impl GdCityActions { /// Set a rally point for a specific building in a city. - /// `command` is "Defend", "Advance", or "Patrol". + /// `command` is one of: "hold", "defend", "fortify", "join_formation", "patrol", "advance". + /// For "patrol", pass the second waypoint in `waypoint_2_col`/`waypoint_2_row`. + /// Old callers (non-Patrol) pass -1/-1 for the waypoint sentinels. #[func] pub fn set_rally_point( &self, @@ -4881,6 +5289,8 @@ impl GdCityActions { col: i64, row: i64, command: GString, + waypoint_2_col: i64, + waypoint_2_row: i64, ) { use mc_core::formation::RallyPointRequest; let mut gs = state.clone(); @@ -4890,6 +5300,8 @@ impl GdCityActions { building_id: building_id.to_string(), hex: Some((col as i32, row as i32)), command: command.to_string(), + waypoint_2_col: waypoint_2_col as i32, + waypoint_2_row: waypoint_2_row as i32, }); } @@ -5135,3 +5547,47 @@ impl GdFormationState { arr } } + +// ── GdSeed ─────────────────────────────────────────────────────────────────── + +/// Godot-visible seed derivation utility. +/// +/// Wraps `mc_mapgen::seed::derive` so GDScript can compute per-domain sub-seeds +/// from a map seed using the same SipHash-2-4 mixing as the WASM bridge. +/// +/// All methods are static (`#[func]` on a `no_init` class) — the class is never +/// instantiated; call as `GdSeed.derive(seed, "Tectonics")` from GDScript. +#[derive(GodotClass)] +#[class(no_init, base = RefCounted)] +pub struct GdSeed; + +#[godot_api] +impl GdSeed { + /// Derive a deterministic sub-seed for `domain` from `map_seed`. + /// + /// `domain` must be the string name of a `SeedDomain` variant: + /// "Tectonics" | "Erosion" | "Hydrology" | "Climate" | "FloraSelect" | "FaunaSelect" + /// + /// Returns the derived value cast to i64 (Godot int). The u64 → i64 cast is + /// intentional: values in the upper half of u64 appear negative in GDScript. + /// This is documented behaviour — saves should validate map seeds before display. + /// Returns -1 for unknown domain strings (programmer error; log and investigate). + #[func] + pub fn derive(map_seed: i64, domain: GString) -> i64 { + use mc_mapgen::seed::{derive, SeedDomain}; + let s = domain.to_string(); + let dom = match s.as_str() { + "Tectonics" => SeedDomain::Tectonics, + "Erosion" => SeedDomain::Erosion, + "Hydrology" => SeedDomain::Hydrology, + "Climate" => SeedDomain::Climate, + "FloraSelect" => SeedDomain::FloraSelect, + "FaunaSelect" => SeedDomain::FaunaSelect, + _ => { + godot_error!("GdSeed::derive: unknown SeedDomain {:?}", s); + return -1; + } + }; + derive(map_seed as u64, dom) as i64 + } +}