feat(api-gdext): Introduce action traits, action implementations, and module exports for Godot Engine integration in the simulator API

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-01 18:42:20 -07:00
parent c47605984a
commit 48bbdce97d
3 changed files with 592 additions and 11 deletions

View file

@ -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<Dictionary> {
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

View file

@ -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<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdBuildingActions {
fn init(base: Base<RefCounted>) -> 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<Dictionary> {
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<crate::GdGameState>,
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<Dictionary> {
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
}

View file

@ -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<mc_ecology::TerrainFloraIndex>,
base: Base<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdFloraSelector {
fn init(base: Base<RefCounted>) -> 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<GString>) {
let strings: Vec<String> = 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<Dictionary>` 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<Dictionary> {
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<mc_ecology::TerrainFaunaIndex>,
base: Base<RefCounted>,
}
#[godot_api]
impl IRefCounted for GdFaunaSelector {
fn init(base: Base<RefCounted>) -> 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<GString>, 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<String> = 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<Dictionary>` 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<Dictionary> {
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<GdGridState> {
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::<f64>() as f32; }
if let Some(v) = dict.get("moisture") { tile.moisture = v.to::<f64>() as f32; }
if let Some(v) = dict.get("elevation") { tile.elevation = v.to::<f64>() as f32; }
if let Some(v) = dict.get("biome_id") { tile.biome_id = v.to::<GString>().to_string(); }
if let Some(v) = dict.get("biome_id") { tile.biome_label_id = v.to::<GString>().to_string(); }
if let Some(v) = dict.get("wind_direction") { tile.wind_direction = v.to::<i64>() as i32; }
if let Some(v) = dict.get("wind_speed") { tile.wind_speed = v.to::<f64>() as f32; }
if let Some(v) = dict.get("sulfate_aerosol") { tile.sulfate_aerosol = v.to::<f64>() 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<TyDoc> = 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
}
}