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:
parent
c47605984a
commit
48bbdce97d
3 changed files with 592 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
120
src/simulator/api-gdext/src/building_action.rs
Normal file
120
src/simulator/api-gdext/src/building_action.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue