feat(@projects/@magic-civilization): add building entity dictionary conversion helpers

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-12 03:33:06 -07:00
parent 86c6eea794
commit 05b10be080

View file

@ -965,6 +965,34 @@ impl GdClimateSpecEval {
}
}
// ── BuildingEntity Dictionary conversion helper ─────────────────────────
//
// Shape mirrors `Building.gd::to_dict()` exactly — same key set, same
// value types, same `position: [col, row]` array — so renderers and
// encyclopedia panels can keep consuming the dict shape during the
// Stage 2b → Stage 3 transition. The inverse path (Dictionary →
// BuildingEntity) is not needed today; spawn callers pass the field
// set positionally to `GdGameState::spawn_npc_building`.
fn building_entity_to_dict(entity: &mc_core::BuildingEntity) -> Dictionary {
let mut d: Dictionary = Dictionary::new();
d.set("id", GString::from(entity.id.as_str()));
d.set("type_id", GString::from(entity.type_id.as_str()));
d.set("name", GString::from(entity.name.as_str()));
let placement_str = match entity.placement {
mc_core::Placement::Map => "map",
mc_core::Placement::City => "city",
};
d.set("placement", GString::from(placement_str));
d.set("owner", entity.owner as i64);
let mut position = Array::<i64>::new();
position.push(entity.position.0 as i64);
position.push(entity.position.1 as i64);
d.set("position", position);
d.set("visited", entity.visited);
d
}
// ── Tile Dictionary conversion helpers ──────────────────────────────────
fn tile_to_dict(tile: &mc_core::grid::TileState) -> Dictionary {
@ -2981,6 +3009,159 @@ impl GdGameState {
true
}
// ── p2-72a Stage 2b: NPC-buildings accessor surface ─────────────────
//
// Stage 2b makes the Rust-side `npc_buildings: Vec<BuildingEntity>`
// mirror of `GameState.npc_buildings` (GDScript) authoritative for
// save-format purposes. The full read-path migration (renderer / AI
// / encyclopedia / fauna) lands in Stage 3+ — for now GDScript
// continues to maintain its own `BuildingScript` array as a view
// alongside the Rust mirror, and every spawn / mutate / remove path
// routes through these accessors so the mirror stays in lockstep.
/// Number of NPC buildings currently in the Rust mirror.
#[func]
fn npc_building_count(&self) -> i64 {
self.inner.npc_buildings.len() as i64
}
/// Return the building at `idx` as a `Dictionary` shaped to match
/// `Building.gd::to_dict()`. Returns an empty `Dictionary` if `idx`
/// is out of range (logged via `godot_error!`). Renderers and
/// encyclopedia panels consume this shape today.
#[func]
fn npc_building_dict(&self, idx: i64) -> Dictionary {
if idx < 0 || (idx as usize) >= self.inner.npc_buildings.len() {
godot_error!(
"GdGameState::npc_building_dict: idx {idx} out of range (len {})",
self.inner.npc_buildings.len()
);
return Dictionary::new();
}
building_entity_to_dict(&self.inner.npc_buildings[idx as usize])
}
/// Return every NPC building as an `Array<Dictionary>` (one entry
/// per building, ordered by insertion). Used by Stage 3 read-path
/// migration to drain the Rust mirror into renderer/encyclopedia
/// data structures.
#[func]
fn npc_buildings_all(&self) -> Array<Dictionary> {
let mut arr = Array::new();
for b in &self.inner.npc_buildings {
arr.push(&building_entity_to_dict(b));
}
arr
}
/// Append a new NPC building to the Rust mirror. Called by every
/// GDScript spawn path (`VillageLairPlacer._create_npc_building`)
/// in lieu of allocating a `BuildingScript` and appending it to
/// `GameState.npc_buildings` directly. Returns the index of the
/// newly inserted entity.
///
/// Parameters mirror `BuildingEntity` field-for-field; `placement`
/// accepts the GDScript-shaped lowercase strings `"map"` /
/// `"city"` and falls back to `"map"` on any other value (with a
/// `godot_error!`).
#[func]
fn spawn_npc_building(
&mut self,
id: GString,
type_id: GString,
name: GString,
placement: GString,
owner: i64,
col: i64,
row: i64,
visited: bool,
) -> i64 {
let placement = match placement.to_string().as_str() {
"map" => mc_core::Placement::Map,
"city" => mc_core::Placement::City,
other => {
godot_error!(
"GdGameState::spawn_npc_building: unknown placement '{other}', defaulting to 'map'"
);
mc_core::Placement::Map
}
};
let entity = mc_core::BuildingEntity {
id: id.to_string(),
type_id: type_id.to_string(),
name: name.to_string(),
placement,
owner: owner as i32,
position: (col as i32, row as i32),
visited,
};
let idx = self.inner.npc_buildings.len() as i64;
self.inner.npc_buildings.push(entity);
idx
}
/// Drop an NPC building from the mirror by instance id. Returns
/// `true` if a matching entity was removed. Used by Stage 3+
/// remove-paths (raid-clear, lair-destroyed). Linear scan because
/// `Vec<BuildingEntity>` keeps the on-disk save shape simple — the
/// spatial-index migration that would replace this with a
/// `BTreeMap<AxialPos, Vec<usize>>` is Stage 3 scope.
#[func]
fn remove_npc_building_by_id(&mut self, id: GString) -> bool {
let target = id.to_string();
if let Some(pos) = self.inner.npc_buildings.iter().position(|b| b.id == target) {
self.inner.npc_buildings.remove(pos);
true
} else {
false
}
}
/// Mark an NPC building as visited / unvisited by instance id.
/// Returns `true` if a matching entity was found and updated.
#[func]
fn set_npc_building_visited(&mut self, id: GString, visited: bool) -> bool {
let target = id.to_string();
if let Some(b) = self.inner.npc_buildings.iter_mut().find(|b| b.id == target) {
b.visited = visited;
true
} else {
false
}
}
/// Convert an NPC building's `type_id` (and `name`) in-place by
/// instance id. Used by the lair-abandonment / ruin-conversion
/// paths in `ecological_event_handlers_b.gd::_abandon_lair` and
/// `fauna.gd::_abandon_lair`, which today mutate the GDScript
/// `BuildingScript` instance directly. The Rust mirror tracks the
/// same mutation so the save round-trip emits the post-conversion
/// state. Returns `true` if a matching entity was found.
#[func]
fn convert_npc_building_type(
&mut self,
id: GString,
new_type_id: GString,
new_name: GString,
) -> bool {
let target = id.to_string();
if let Some(b) = self.inner.npc_buildings.iter_mut().find(|b| b.id == target) {
b.type_id = new_type_id.to_string();
b.name = new_name.to_string();
true
} else {
false
}
}
/// Reset the NPC-buildings mirror to empty. Called by
/// `GameState.initialize_game` to clear stale entities between
/// games / save loads before fresh spawn paths run.
#[func]
fn clear_npc_buildings(&mut self) {
self.inner.npc_buildings.clear();
}
/// p2-71c — load the runtime `UnitsCatalog` (id → `UnitStats`) from a
/// JSON array harvested from `public/resources/units/*.json`. GDScript
/// reads every per-unit file, collects them into an `Array`, and