diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 0d75d937..28e8c877 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -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::::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` + // 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` (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 { + 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` keeps the on-disk save shape simple — the + /// spatial-index migration that would replace this with a + /// `BTreeMap>` 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