fix(@projects/@magic-civilization): 🐛 remove npc_buildings array and rebuild logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-08 07:37:54 -07:00
parent 23d65225a7
commit 1bca207c79
13 changed files with 521 additions and 189 deletions

View file

@ -11,6 +11,46 @@ updated_at: 2026-06-04
blocks: [p2-72a-save-format-migration, p2-72a, p2-72]
---
## STATUS — 2026-06-08 (array-removal increment EXECUTED — K≈8/11, stays `partial`)
The npc_buildings-array-removal increment LANDED. The Rust
`GdGameState::npc_buildings` mirror is now the SINGLE store; the parallel
GDScript `npc_buildings: Array` + `_npc_buildings_by_tile` spatial index +
`_rebuild_npc_buildings_view()` + `add_npc_building()` are DELETED. `Building.gd`
stays a thin view but is now built ephemerally on demand (no persistent list).
**What changed (12 files):**
- `game_state.gd` — deleted the array/index/rebuild/add-shim; `spawn_npc_building`
returns `BuildingScript.new(self, idx)`; `get_npc_buildings_at` /
`get_npc_building_at` / `get_all_npc_buildings_of_type` build views by scanning
the Rust mirror; added `npc_buildings_all_views()` + `_dict_position()`;
`_serialize_npc_buildings()` reads `npc_building_dict(i)` directly (byte-identical);
`_deserialize_npc_buildings()` drops the rebuild call; removed the unused
`SerializationHelpers` preload.
- `game_state_serialization_helpers.gd` — removed orphaned `serialize_npc_buildings`.
- `entities/building.gd` — doc updated (ephemeral views; index-staleness note).
- 4 pure readers → dict path (`wild_creature_ai.gd`, `rust_fauna_integration.gd`,
`lair_overlay_renderer.gd`, `entity_finder.gd`).
- `village_lair_placer.gd` — occupancy snapshot (`_occupied_tiles()` once) to avoid
per-tile boundary-crossing scans; dropped unused `BuildingScript` preload.
- Tests: new `test_npc_building_mirror_equivalence.gd` (equivalence pin);
`test_wild_creature_ai.gd` fixtures migrated to `spawn_npc_building`;
`test_save_manager.gd` + `test_save_load_round_trip.gd` dropped the
now-invalid `GameState.npc_buildings = []` / `_npc_buildings_by_tile = {}`
seed lines.
**KEY DEVIATION from the brief/recipe:** the assumed Rust `npc_building_at(col,row)`
spatial accessor does NOT exist (only count/dict/all + spawn/remove/mutate/clear).
Per-tile lookup is a GDScript filter over `npc_buildings_all()`; relocating the
spatial index into Rust stays Stage-3-deferred (the `lib.rs:3468-3470` comment
scopes the `BTreeMap<AxialPos,…>` index there). No Rust change made — only the
cdylib rebuilt (`build-gdext.sh`, clean, exit 0). Not a Rail-1 violation: an
ephemeral query over the Rust-owned Vec keeps zero persistent parallel state.
**Verification:** zero new GUT failures vs clean-HEAD baseline (unit 17/10 scripts,
integration 18/8 scripts identical pre/post; +5 passing from the new test).
Visual proof stays operator-gated (☐, PENDING). See Acceptance.
## STATUS — 2026-06-03 (bridge-cse lane audit; NOT advanced — resume map only)
The bridge-cse lane did **not** execute the npc_buildings-array-removal
@ -239,23 +279,41 @@ fn building_entity_round_trip_is_byte_identical() {
`set_npc_building_visited`, `convert_npc_building_type`,
`clear_npc_buildings` — see
`src/simulator/api-gdext/src/lib.rs` Stage 2b block).
- ☐ Every Game 1 spawn path routes through `GdGameState::spawn_npc_building`
**deferred to Stage 4** (no singleton; dual-writes into
per-call `GdGameState` instances would be ephemeral).
- ☐ Every Game 1 read path routes through `GdGameState::npc_building_at`
/ `npc_buildings_all` / `set_npc_building_visited` /
`remove_npc_building` — **Stage 3+ (save-format migration) /
Stage 4 (canonical-render-source) scope, not Stage 2b.**
- ☐ `Building.gd` deleted (option b) or thinned to a view-only wrapper
(option c) — **Stage 4 scope**. Stage 2b adds a marker comment
flagging the upcoming Stage 4 conversion.
- ☐ `GameState.npc_buildings` and `GameState._npc_buildings_by_tile`
deleted from `game_state.gd`. Spatial index lives in Rust only.
**Stage 4 scope.**
- ☐ Hex renderer / unit movement / AI tactical / city borders /
encyclopedia / siege flow all continue to work; visual + GUT
verification — Stage 2b doesn't touch read paths, so existing GDScript
flow is unaltered; Stage 4 acceptance.
- ✓ Every Game 1 spawn path routes through `GdGameState::spawn_npc_building`
`VillageLairPlacer._create_npc_building` and `GameState.spawn_npc_building`
write straight into the Rust mirror; no GDScript-side store is appended to.
Verified by the array-removal increment (2026-06-08).
- ✓ Every Game 1 read path routes through the Rust accessors. The 4 pure
readers (`wild_creature_ai.gd`, `rust_fauna_integration.gd`,
`lair_overlay_renderer.gd`, `entity_finder.gd`) iterate
`_gd_state.npc_buildings_all()` dicts with `[col,row]`→Vector2i adaptation;
the mutation/spatial readers (`fauna.gd`, `ecological_event_handlers_b.gd`,
`world_map.gd`, `world_map_units.gd`, `village_lair_placer.gd`) consume
on-demand `BuildingScript` views via `get_npc_buildings_at` /
`get_npc_building_at` / `get_all_npc_buildings_of_type`.
**Deviation:** the brief assumed a Rust `npc_building_at(col,row)` spatial
accessor; it does NOT exist (only count/dict/all + spawn/remove/mutate). The
per-tile lookup is therefore a GDScript filter over `npc_buildings_all()`;
the spatial-index relocation into Rust stays Stage-3 deferred (the Rust
`lib.rs:3468-3470` comment scopes the `BTreeMap<AxialPos,…>` index there).
- ✓ `Building.gd` thinned to a view-only wrapper (option c). It holds no
persistent state (`_state_ref` + `_idx`), proxies all reads to
`_gd_state.npc_building_dict(_idx)`, and is now built EPHEMERALLY on demand
(no parallel view list).
- ✓ `GameState.npc_buildings` and `GameState._npc_buildings_by_tile` DELETED
from `game_state.gd`, along with `_rebuild_npc_buildings_view()`,
`add_npc_building()`, and their reset/refresh callsites. The Rust mirror is
the single store. (Spatial index relocation into Rust remains Stage-3 work
— see deviation note above.) `_serialize_npc_buildings()` re-pointed to read
`npc_building_dict(i)` directly; byte-identity proven by
`test_npc_building_mirror_equivalence::test_serialize_round_trip_is_byte_identical`.
- ✓ Hex renderer / wild-creature AI / fauna / entity finder / lair overlay /
village+lair placement / save round-trip all continue to work via GUT
(visual proof PENDING-operator). The mandatory equivalence test
(`test_npc_building_mirror_equivalence.gd`, 5/5, 38 asserts) pins position /
type_id / visited / per-tile lookup / view-vs-dict agreement; the
`test_wild_creature_ai.gd` lair fixtures (13/13) migrated to seed the Rust
mirror via `spawn_npc_building`.
- ✓ `cargo check --workspace` green (only pre-existing
`unsafe_op_in_unsafe_fn` / `missing-docs` warnings).
- ✓ `cargo test -p mc-core -p mc-turn` green
@ -266,12 +324,27 @@ fn building_entity_round_trip_is_byte_identical() {
`AbstractPlayerState._pad_fr/_pad_rel` missing-field errors); these
reproduce on the unmodified pre-Stage-2b tree (verified via
`git stash && cargo check --workspace --tests`).
- ☐ GUT headless run green — Stage 2b changes only Rust + a doc
comment in `building.gd`; GUT unchanged. Re-run at Stage 4 close.
- ✓ GUT headless run shows ZERO new failures vs a clean-HEAD baseline
(verified on apricot via flatpak, `.so` rebuilt clean by `build-gdext.sh`).
Baseline (HEAD, my Rust-unchanged `.so`): unit 17 fail / 10 scripts,
integration 18 fail / 8 scripts. Post-change: identical failing-script sets
and counts, PLUS the new equivalence test (+1 script, +5 tests, +5 passing).
Targeted suites fully green: `test_npc_building_mirror_equivalence` 5/5,
`test_wild_creature_ai` 13/13, `test_save_manager` 21/21, `test_overlay_renderer`
5/5. The pre-existing failures are sibling-work breakage (PlayerScript
`traded_luxuries`/diplomacy view-assignment rejection, `GdGridState.create_grid`,
`EndGameSummary.setup` typed-array, save schema_version bump, dangling tech
data refs, flaky sprite-dir/units-dir filesystem tests) — none reference
npc_buildings.
- ☐ World-map visual proof (lair/village/ruin overlays render correctly) —
**PENDING-operator-review.** Operator-gated per phase-gate-protocol; not
self-approved. The equivalence test pins the render DATA (position, type_id)
so a dict-shape bug fails a test rather than only the screenshot.
- ☐ `p2-72a-save-format-migration` `blocked_by:` updated to remove this
objective once it lands — **partial unblock**: Stage 3 can begin
consuming the `BuildingEntity` type and `GameState.npc_buildings`
field, but full unblock awaits Stage 4 wiring.
objective once it lands — **partial unblock**: the array-removal increment
is done (Rust mirror is the single store; serialize byte-identical), but the
full save-format-migration GDScript side (PlayerScript/CityScript views,
schema v2) is separate and still owns the `blocked_by` clear.
## Stage 2b decision log — 2026-05-12

View file

@ -12,9 +12,6 @@ const BuildingScript: GDScript = preload("res://engine/src/entities/building.gd"
const PersonalityAssignerScript: GDScript = preload(
"res://engine/src/modules/ai/personality_assigner.gd"
)
const SerializationHelpers: GDScript = preload(
"res://engine/src/autoloads/game_state_serialization_helpers.gd"
)
const DEFAULT_SETTINGS: Dictionary = {
"map_size": "small",
@ -109,10 +106,10 @@ var ai_per_player_research_mult: Dictionary = {}
## Key: "min_idx_max_idx", value: "neutral" | "war" | "peace" | "alliance".
var diplomacy: Dictionary = {}
## NPC buildings on the world map (lairs, villages, ruins). Array of Building.
var npc_buildings: Array = []
## Spatial index: "col,row" -> Array[Building] for quick tile lookups.
var _npc_buildings_by_tile: Dictionary = {}
## NPC buildings on the world map (lairs, villages, ruins) live ONLY in the
## Rust mirror (`_gd_state.npc_buildings`). No parallel GDScript store. Reads
## go through `npc_buildings_all_views()` / `get_npc_buildings_at()`, which
## build ephemeral `BuildingScript` views (or dicts) on demand from the mirror.
## p2-72a Stage 4 — canonical Rust state handle. The long-lived GdGameState
## that renderers / UI / save-load read through. Created in `initialize_game`
@ -260,8 +257,6 @@ func initialize_game(settings: Dictionary) -> void:
layers = []
transit_nodes = []
diplomacy = {}
npc_buildings = []
_npc_buildings_by_tile = {}
_ensure_gd_state()
if _gd_state != null:
@ -464,13 +459,14 @@ func get_game_map() -> RefCounted: # Returns GameMap
## -- NPC building management --
##
## p2-72a Stage 4 Wave 2: `npc_buildings` is now a *view list* — the
## Rust `GdGameState::npc_buildings` mirror is authoritative. Spawn /
## remove paths call into `_gd_state` and then `_rebuild_npc_buildings_view`
## refreshes the GDScript-side array of `BuildingScript` views + the
## spatial index in lockstep. Consumers iterating `npc_buildings`
## continue to receive `BuildingScript` instances and read `.type_id`,
## `.position`, etc. unchanged.
## p2-72a Stage 4 Wave 2: the Rust `GdGameState::npc_buildings` mirror is the
## SINGLE store. No parallel GDScript array / spatial index. Spawn / remove /
## mutate paths call straight into `_gd_state`; reads build ephemeral
## `BuildingScript` views (mutation/spatial accessors) or hand back dicts
## (`npc_buildings_all`) on demand. Views are short-lived within one function
## call — they hold an `_idx` into the Rust Vec, so a `remove_npc_building_by_id`
## that shifts indices must not be interleaved with a stale view's reads.
## (All current callers mutate by id and consume views within one frame.)
func spawn_npc_building(
@ -489,31 +485,10 @@ func spawn_npc_building(
var idx: int = _gd_state.spawn_npc_building(
id, type_id, display_name, placement, owner, position.x, position.y, visited
)
_rebuild_npc_buildings_view()
state_changed.emit()
if idx < 0 or idx >= npc_buildings.size():
if idx < 0:
return null
return npc_buildings[idx]
func add_npc_building(building: RefCounted) -> void:
## Compatibility shim: accept a partially populated `BuildingScript`
## (legacy callers built one with `BuildingScript.new()` and set
## fields). Re-route through `spawn_npc_building` so the Rust mirror
## stays canonical, then discard the throwaway instance.
if building == null:
return
# Pull fields via `_get` proxy if it's already a view; else use the
# bare property fallback that GDScript synthesizes when reading from
# a duck-typed RefCounted with matching member names.
var bid: String = String(building.get("id"))
var btype: String = String(building.get("type_id"))
var bname: String = String(building.get("name"))
var bplace: String = String(building.get("placement"))
var bowner: int = int(building.get("owner"))
var bpos: Vector2i = building.get("position") as Vector2i
var bvis: bool = bool(building.get("visited"))
spawn_npc_building(bid, btype, bname, bplace, bowner, bpos, bvis)
return BuildingScript.new(self, idx)
func remove_npc_building(building: RefCounted) -> void:
@ -523,51 +498,74 @@ func remove_npc_building(building: RefCounted) -> void:
if bid.is_empty():
return
_gd_state.remove_npc_building_by_id(bid)
_rebuild_npc_buildings_view()
state_changed.emit()
func _rebuild_npc_buildings_view() -> void:
## Drop the previous view list + spatial index and rebuild from the
## Rust mirror. Cheap (low N) and called after every spawn / remove
## / deserialize.
npc_buildings = []
_npc_buildings_by_tile = {}
func npc_buildings_all_views() -> Array:
## Build one ephemeral `BuildingScript` view per entity in the Rust
## mirror, in insertion order. Returned views proxy reads/mutations
## back through `_gd_state`; no persistent GDScript store is kept.
var result: Array = []
if _gd_state == null:
return
return result
var count: int = int(_gd_state.npc_building_count())
for i: int in count:
var view: RefCounted = BuildingScript.new(self, i)
npc_buildings.append(view)
var pos: Vector2i = view.get("position") as Vector2i
var key: String = "%d,%d" % [pos.x, pos.y]
if not _npc_buildings_by_tile.has(key):
_npc_buildings_by_tile[key] = []
_npc_buildings_by_tile[key].append(view)
result.append(BuildingScript.new(self, i))
return result
func get_npc_buildings_at(pos: Vector2i) -> Array:
var key: String = "%d,%d" % [pos.x, pos.y]
return _npc_buildings_by_tile.get(key, [])
## All NPC building views at `pos`. Linear scan over the Rust mirror —
## the spatial-index relocation into Rust is Stage-3 deferred work.
var result: Array = []
if _gd_state == null:
return result
var count: int = int(_gd_state.npc_building_count())
for i: int in count:
var d: Dictionary = _gd_state.npc_building_dict(i)
if _dict_position(d) == pos:
result.append(BuildingScript.new(self, i))
return result
func get_npc_building_at(pos: Vector2i, type_filter: String = "") -> Variant:
## Returns the first NPC building at pos, optionally filtered by type_id. Null if none.
var buildings: Array = get_npc_buildings_at(pos)
for b: Variant in buildings:
if type_filter == "" or b.type_id == type_filter:
return b
## Returns the first NPC building view at pos, optionally filtered by
## type_id. Null if none.
if _gd_state == null:
return null
var count: int = int(_gd_state.npc_building_count())
for i: int in count:
var d: Dictionary = _gd_state.npc_building_dict(i)
if _dict_position(d) != pos:
continue
if type_filter == "" or String(d.get("type_id", "")) == type_filter:
return BuildingScript.new(self, i)
return null
func get_all_npc_buildings_of_type(type_id: String) -> Array:
var result: Array = []
for b: Variant in npc_buildings:
if b.type_id == type_id:
result.append(b)
if _gd_state == null:
return result
var count: int = int(_gd_state.npc_building_count())
for i: int in count:
var d: Dictionary = _gd_state.npc_building_dict(i)
if String(d.get("type_id", "")) == type_id:
result.append(BuildingScript.new(self, i))
return result
static func _dict_position(d: Dictionary) -> Vector2i:
## Rehydrate the `[col, row]` Array shape the Rust mirror emits into a
## Vector2i for tile comparison.
if not d.has("position"):
return Vector2i.ZERO
var raw_pos: Array = d["position"] as Array
if raw_pos == null or raw_pos.size() < 2:
return Vector2i.ZERO
return Vector2i(int(raw_pos[0]), int(raw_pos[1]))
func serialize() -> Dictionary:
var data: Dictionary = {
"current_theme": current_theme,
@ -696,13 +694,22 @@ func get_player_era(_player_index: int) -> int:
func _serialize_npc_buildings() -> Array:
return SerializationHelpers.serialize_npc_buildings(npc_buildings)
## Emit one dict per entity straight from the Rust mirror. Byte-identical
## to the previous view-array path (the views already proxied `to_dict()`
## to `_gd_state.npc_building_dict(i)`); only the redundant GDScript
## array indirection is dropped.
var result: Array = []
if _gd_state == null:
return result
var count: int = int(_gd_state.npc_building_count())
for i: int in count:
result.append(_gd_state.npc_building_dict(i))
return result
func _deserialize_npc_buildings(raw: Array) -> void:
## Restore the Rust mirror from saved dicts, then rebuild the view
## list. No GDScript-side `BuildingScript.from_dict` path remains —
## Rust is the source of truth.
## Restore the Rust mirror from saved dicts. Rust is the source of
## truth — no GDScript-side store to rebuild.
_ensure_gd_state()
if _gd_state == null:
push_error("GameState._deserialize_npc_buildings: _gd_state unavailable")
@ -717,16 +724,9 @@ func _deserialize_npc_buildings(raw: Array) -> void:
var bname: String = String(d.get("name", ""))
var bplace: String = String(d.get("placement", "city"))
var bowner: int = int(d.get("owner", -1))
var col: int = 0
var row: int = 0
if d.has("position"):
var raw_pos: Array = d["position"] as Array
if raw_pos != null and raw_pos.size() >= 2:
col = int(raw_pos[0])
row = int(raw_pos[1])
var pos: Vector2i = _dict_position(d)
var bvis: bool = bool(d.get("visited", false))
_gd_state.spawn_npc_building(bid, btype, bname, bplace, bowner, col, row, bvis)
_rebuild_npc_buildings_view()
_gd_state.spawn_npc_building(bid, btype, bname, bplace, bowner, pos.x, pos.y, bvis)
func rebuild_layer_references() -> void:

View file

@ -2,18 +2,9 @@ extends RefCounted
## Stateless serialization helpers for GameState.
## Called by game_state.gd; not instanced — all methods are static.
const BuildingScript: GDScript = preload("res://engine/src/entities/building.gd")
const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd")
static func serialize_npc_buildings(npc_buildings: Array) -> Array:
var result: Array[Dictionary] = []
for b: RefCounted in npc_buildings:
if b is BuildingScript:
result.append(b.to_dict())
return result
static func serialize_layer(layer: Dictionary) -> Dictionary:
var serialized: Dictionary = {"id": layer.get("id", "")}
var map_ref: RefCounted = layer.get("map")

View file

@ -7,14 +7,21 @@ extends RefCounted
## through the matching `GdGameState` accessor and emits
## `GameState.state_changed` so renderers redraw.
##
## p2-72a Stage 4 Wave 2: `BuildingScript` is the first GDScript entity
## class to collapse to a view. The public API (`b.type_id`, `b.position`,
## `to_dict()`, etc.) is preserved via `_get` so the eight consumer files
## (lair overlay, wild-creature AI, fauna, entity finder, rust fauna
## integration, village/lair placer, the two ecological event handlers)
## need no churn. Mutators (`set_visited`, `convert_type`) are explicit —
## the two lair→ruin in-place mutation sites in `fauna.gd` and
## `ecological_event_handlers_b.gd` route through `convert_type`.
## p2-72a array-removal increment: the parallel GDScript `npc_buildings`
## view list + spatial index are GONE — the Rust mirror is the single store.
## Views are now EPHEMERAL: built on demand by `GameState.spawn_npc_building`,
## `get_npc_buildings_at`, `get_npc_building_at`, `get_all_npc_buildings_of_type`,
## and `npc_buildings_all_views`, then discarded once the caller is done. The
## mutation/spatial readers (`fauna.gd`, `ecological_event_handlers_b.gd`,
## `world_map_units.gd`, `world_map.gd`) still receive views and read
## `b.type_id`, `b.position`, call `convert_type` / `set_visited` unchanged.
## Pure readers (lair overlay, wild-creature AI, entity finder, rust fauna
## integration) now consume dicts directly via `_gd_state.npc_buildings_all()`.
##
## INDEX STALENESS: a view holds `_idx` into the Rust Vec. A
## `remove_npc_building_by_id` that shifts indices invalidates any still-held
## view. Safe for all current callers — mutations route by stable id, and
## views are consumed within one function before any structural mutation.
var _state_ref: Node = null # GameState autoload
var _idx: int = -1 # Index into `_state_ref._gd_state.npc_buildings`

View file

@ -5,7 +5,6 @@ extends RefCounted
## Buildings are stored in GameState.npc_buildings, NOT on tile fields.
const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd")
const BuildingScript = preload("res://engine/src/entities/building.gd")
var _rng: RandomNumberGenerator
var _next_id: int = 0
@ -41,16 +40,21 @@ func place_villages(
var land_count: int = game_map.count_land_tiles()
var target_count: int = roundi(land_count * density * multiplier)
# Snapshot occupied tiles ONCE (the candidate loop spawns nothing) so the
# per-tile occupancy check is an O(1) set membership, not an O(buildings)
# scan across the GDExtension boundary on every tile.
var occupied: Dictionary = _occupied_tiles()
var candidates: Array[Vector2i] = []
var weights: Array[float] = []
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
var tile: Resource = game_map.tiles[axial]
if tile.biome_id in forbidden:
continue
if tile.is_natural_wonder():
continue
# Skip tiles that already have an NPC building
if not GameState.get_npc_buildings_at(axial).is_empty():
if occupied.has(_tile_key(axial)):
continue
var too_close: bool = false
@ -130,13 +134,18 @@ func place_lairs(
var land_count: int = game_map.count_land_tiles()
var target_count: int = roundi(land_count * density * multiplier)
# Snapshot occupied tiles ONCE (includes villages placed in a prior
# `place_villages` pass) — the candidate loop spawns nothing, so an O(1)
# set membership replaces a per-tile boundary-crossing scan.
var occupied: Dictionary = _occupied_tiles()
var candidates: Array[Dictionary] = []
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
var tile: Resource = game_map.tiles[axial]
if tile.is_water() or tile.is_natural_wonder():
continue
# Skip tiles with existing NPC buildings
if not GameState.get_npc_buildings_at(axial).is_empty():
if occupied.has(_tile_key(axial)):
continue
var too_close: bool = false
@ -207,13 +216,31 @@ func place_lairs(
func _create_npc_building(type_id: String, display_name: String, pos: Vector2i) -> void:
## Route through `GameState.spawn_npc_building` so the Rust mirror
## (`mc_core::BuildingEntity`) is canonical and the GDScript view
## list refreshes in lockstep.
## (`mc_core::BuildingEntity`) is the single canonical store.
var id: String = "npc_%s_%d" % [type_id, _next_id]
_next_id += 1
GameState.spawn_npc_building(id, type_id, display_name, "map", -1, pos, false)
static func _tile_key(pos: Vector2i) -> String:
return "%d,%d" % [pos.x, pos.y]
func _occupied_tiles() -> Dictionary:
## Build a `{ "col,row": true }` occupancy set from the Rust mirror once,
## so candidate-collection loops avoid a per-tile linear scan.
var occupied: Dictionary = {}
var gd_state: RefCounted = GameState.get_gd_state()
if gd_state == null:
return occupied
for b: Dictionary in gd_state.npc_buildings_all():
var raw_pos: Array = b.get("position", []) as Array
if raw_pos == null or raw_pos.size() < 2:
continue
occupied[_tile_key(Vector2i(int(raw_pos[0]), int(raw_pos[1])))] = true
return occupied
func _get_village_data() -> Dictionary:
var data: Dictionary = DataLoader.get_villages_config()
if not data.is_empty():

View file

@ -157,15 +157,18 @@ static func _collect_improvements(
static func _collect_villages(filter: String, out: Array[Vector2i]) -> void:
## Villages are NPC buildings with type_id "village" stored in GameState.
for building: RefCounted in GameState.npc_buildings:
if building == null:
continue
var type_id: String = building.get("type_id") if building.get("type_id") != null else ""
## Villages are NPC buildings with type_id "village" in the Rust mirror.
var gd_state: RefCounted = GameState.get_gd_state()
if gd_state == null:
return
for building: Dictionary in gd_state.npc_buildings_all():
var type_id: String = String(building.get("type_id", ""))
if type_id != "village":
continue
var has_pos: bool = building.get("position") != null
var pos: Vector2i = building.get("position") as Vector2i if has_pos else Vector2i.ZERO
var raw_pos: Array = building.get("position", []) as Array
if raw_pos == null or raw_pos.size() < 2:
continue
var pos: Vector2i = Vector2i(int(raw_pos[0]), int(raw_pos[1]))
if filter.is_empty() or type_id == filter:
out.append(pos)

View file

@ -53,8 +53,11 @@ func spawn_initial_creatures(game_map: RefCounted) -> void:
if primary_layer.is_empty():
return
for b: Variant in GameState.npc_buildings:
var type_id: String = b.type_id
var gd_state: RefCounted = GameState.get_gd_state()
if gd_state == null:
return
for b: Dictionary in gd_state.npc_buildings_all():
var type_id: String = String(b.get("type_id", ""))
# Skip non-lair buildings (villages, ruins)
if type_id == "village" or type_id == "ruin":
continue
@ -68,18 +71,19 @@ func spawn_initial_creatures(game_map: RefCounted) -> void:
if unit_data.is_empty():
continue
var b_pos: Vector2i = _building_pos(b)
var unit: RefCounted = UnitScript.new()
unit.id = "wild_%d" % _rng.randi()
unit.type_id = unit_type_id
unit.owner = -1
unit.position = b.position
unit.position = b_pos
unit.max_hp = unit_data.get("hp", 8)
unit.hp = unit.max_hp
unit.movement_remaining = unit_data.get("movement", 2)
unit.has_attacked = false
primary_layer.get("units", []).append(unit)
EventBus.wild_creature_spawned.emit(unit, b.position)
EventBus.wild_creature_spawned.emit(unit, b_pos)
func _act(
@ -141,21 +145,37 @@ func _find_attack_target(
func _find_nearest_lair(from_pos: Vector2i, search_radius: int) -> Vector2i:
## Find nearest lair NPC building within search_radius via GameState.
## Find nearest lair NPC building within search_radius via the Rust mirror.
var best_pos: Vector2i = from_pos
var best_dist: int = 999999
for b: Variant in GameState.npc_buildings:
if b.type_id == "village" or b.type_id == "ruin":
var gd_state: RefCounted = GameState.get_gd_state()
if gd_state == null:
return best_pos
for b: Dictionary in gd_state.npc_buildings_all():
var type_id: String = String(b.get("type_id", ""))
if type_id == "village" or type_id == "ruin":
continue
var dist: int = HexUtilsScript.hex_distance(from_pos, b.position)
var b_pos: Vector2i = _building_pos(b)
var dist: int = HexUtilsScript.hex_distance(from_pos, b_pos)
if dist <= search_radius and dist < best_dist:
best_dist = dist
best_pos = b.position
best_pos = b_pos
return best_pos
static func _building_pos(b: Dictionary) -> Vector2i:
## Rehydrate the `[col, row]` Array shape the Rust mirror emits into a
## Vector2i.
if not b.has("position"):
return Vector2i.ZERO
var raw_pos: Array = b["position"] as Array
if raw_pos == null or raw_pos.size() < 2:
return Vector2i.ZERO
return Vector2i(int(raw_pos[0]), int(raw_pos[1]))
func _is_outside_leash(
unit_pos: Vector2i, home_pos: Vector2i, leash_radius: int
) -> bool:

View file

@ -73,13 +73,20 @@ static func _build_lair_list() -> Array:
var lairs: Array = []
var species_seed: int = 1
var tier_by_type: Dictionary = _build_tier_lookup()
for b: Variant in GameState.npc_buildings:
var type_id: String = b.type_id
var gd_state: RefCounted = GameState.get_gd_state()
if gd_state == null:
return lairs
for b: Dictionary in gd_state.npc_buildings_all():
var type_id: String = String(b.get("type_id", ""))
if type_id == "village" or type_id == "ruin":
continue
var pos: Vector2i = b.position
var raw_pos: Array = b.get("position", []) as Array
if raw_pos == null or raw_pos.size() < 2:
continue
var col: int = int(raw_pos[0])
var row: int = int(raw_pos[1])
var tier: int = int(tier_by_type.get(type_id, DEFAULT_LAIR_TIER))
lairs.append([pos.x, pos.y, tier, species_seed])
lairs.append([col, row, tier, species_seed])
species_seed += 1
return lairs

View file

@ -4,7 +4,6 @@ extends Node2D
## Subscribes to lair-related EventBus signals and redraws on state changes.
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const BuildingScript: GDScript = preload("res://engine/src/entities/building.gd")
## Lair marker geometry
const MARKER_RADIUS: float = 18.0
@ -40,21 +39,29 @@ func refresh() -> void:
_lairs.clear()
var game_map: RefCounted = GameState.get_game_map()
for b: Variant in GameState.npc_buildings:
var building: BuildingScript = b as BuildingScript
if building == null:
var gd_state: RefCounted = GameState.get_gd_state()
if gd_state == null:
queue_redraw()
return
for b: Dictionary in gd_state.npc_buildings_all():
var type_id: String = String(b.get("type_id", ""))
if type_id in EXCLUDED_TYPES:
continue
if building.type_id in EXCLUDED_TYPES:
var raw_pos: Array = b.get("position", []) as Array
if raw_pos == null or raw_pos.size() < 2:
continue
var pos: Vector2i = Vector2i(int(raw_pos[0]), int(raw_pos[1]))
var tier: int = 1
if game_map != null:
var tile: RefCounted = game_map.get_tile(building.position)
var tile: RefCounted = game_map.get_tile(pos)
if tile != null and "quality" in tile:
tier = clampi(tile.quality, 1, 10)
_lairs[building.position] = {
"type_id": building.type_id,
_lairs[pos] = {
"type_id": type_id,
"tier": tier,
}

View file

@ -0,0 +1,191 @@
extends GutTest
## p2-72a array-removal increment — behavioral-equivalence pin.
##
## The parallel GDScript `npc_buildings` view list + `_npc_buildings_by_tile`
## spatial index were deleted; the Rust `GdGameState::npc_buildings` mirror is
## now the SINGLE store. Every reader was re-pointed onto the Rust accessors
## (`npc_buildings_all`, `npc_building_dict`) or the on-demand view builders
## (`get_npc_buildings_at`, `get_npc_building_at`, `get_all_npc_buildings_of_type`).
##
## This test seeds a lair, a village, and a ruin via `spawn_npc_building`, then
## asserts the data each reader relies on — position, type_id, visited, and
## per-tile lookup — comes back identical through BOTH the dict accessors and
## the BuildingScript views. The dict shape is the trap: `position` is a
## `[col, row]` Array (NOT a Vector2i), so a silent shape mismatch in any reader
## fails an assertion here instead of only manifesting in the world-map render.
##
## Headless: requires the api-gdext .so (GdGameState). When the extension is
## not loaded `_gd_state` is null; the test pending-skips with a clear message
## rather than producing a misleading green.
const BuildingScript: GDScript = preload("res://engine/src/entities/building.gd")
func before_each() -> void:
var gd_state: RefCounted = GameState.get_gd_state()
if gd_state != null:
gd_state.clear_npc_buildings()
func after_each() -> void:
var gd_state: RefCounted = GameState.get_gd_state()
if gd_state != null:
gd_state.clear_npc_buildings()
func _gd_state_or_skip() -> RefCounted:
var gd_state: RefCounted = GameState.get_gd_state()
if gd_state == null:
pending(
"GdGameState not registered — api-gdext .so not loaded; cannot exercise the Rust mirror"
)
return gd_state
func _seed_canonical_set() -> void:
## A lair (beast_den), a village, and a ruin at distinct tiles — one of
## each NPC-building kind the readers branch on.
GameState.spawn_npc_building(
"eq_lair", "beast_den", "Beast Den", "map", -1, Vector2i(3, 4), false
)
GameState.spawn_npc_building(
"eq_village", "village", "Village", "map", -1, Vector2i(7, 2), false
)
GameState.spawn_npc_building("eq_ruin", "ruin", "Ruin", "map", -1, Vector2i(5, 9), true)
func test_npc_buildings_all_returns_seeded_dicts() -> void:
var gd_state: RefCounted = _gd_state_or_skip()
if gd_state == null:
return
_seed_canonical_set()
assert_eq(
int(gd_state.npc_building_count()), 3, "Rust mirror holds exactly the 3 seeded buildings"
)
var by_id: Dictionary = {}
for b: Dictionary in gd_state.npc_buildings_all():
by_id[String(b.get("id", ""))] = b
assert_true(by_id.has("eq_lair"), "lair present in npc_buildings_all")
assert_true(by_id.has("eq_village"), "village present in npc_buildings_all")
assert_true(by_id.has("eq_ruin"), "ruin present in npc_buildings_all")
# type_id
assert_eq(String(by_id["eq_lair"].get("type_id", "")), "beast_den", "lair type_id")
assert_eq(String(by_id["eq_village"].get("type_id", "")), "village", "village type_id")
assert_eq(String(by_id["eq_ruin"].get("type_id", "")), "ruin", "ruin type_id")
# position is the [col, row] Array shape — the trap readers must adapt to
var lair_pos: Array = by_id["eq_lair"].get("position", []) as Array
assert_eq(lair_pos.size(), 2, "position is a 2-element Array, not a Vector2i")
assert_eq(int(lair_pos[0]), 3, "lair col")
assert_eq(int(lair_pos[1]), 4, "lair row")
# visited flag round-trips per-building
assert_false(bool(by_id["eq_lair"].get("visited", true)), "lair visited=false")
assert_true(bool(by_id["eq_ruin"].get("visited", false)), "ruin visited=true")
func test_per_tile_lookup_matches_seeded_positions() -> void:
var gd_state: RefCounted = _gd_state_or_skip()
if gd_state == null:
return
_seed_canonical_set()
# get_npc_buildings_at returns BuildingScript views at the exact tile.
var at_lair: Array = GameState.get_npc_buildings_at(Vector2i(3, 4))
assert_eq(at_lair.size(), 1, "exactly one building at the lair tile")
var lair_view: RefCounted = at_lair[0]
assert_true(lair_view is BuildingScript, "per-tile lookup hands back a BuildingScript view")
assert_eq(String(lair_view.get("type_id")), "beast_den", "view type_id at lair tile")
assert_eq(lair_view.get("position"), Vector2i(3, 4), "view position rehydrated to Vector2i")
# Empty tile yields no buildings.
assert_eq(
GameState.get_npc_buildings_at(Vector2i(0, 0)).size(), 0, "no building on an empty tile"
)
# Filtered single-building lookup.
var village_view: RefCounted = (
GameState.get_npc_building_at(Vector2i(7, 2), "village") as RefCounted
)
assert_not_null(village_view, "village found via filtered get_npc_building_at")
assert_eq(String(village_view.get("type_id")), "village", "filtered lookup returns the village")
# Type filter excludes a mismatched tile.
assert_null(
GameState.get_npc_building_at(Vector2i(3, 4), "village"),
"lair tile yields null when filtered for village"
)
func test_view_and_dict_agree_field_for_field() -> void:
## The view (index path) and the dict (npc_buildings_all path) must read
## identical fields for the same entity — this pins that no reader sees a
## divergent shape depending on which accessor it chose.
var gd_state: RefCounted = _gd_state_or_skip()
if gd_state == null:
return
_seed_canonical_set()
var views: Array = GameState.npc_buildings_all_views()
var dicts: Array[Dictionary] = gd_state.npc_buildings_all()
assert_eq(views.size(), dicts.size(), "view count equals dict count")
for i: int in views.size():
var view: RefCounted = views[i]
var d: Dictionary = dicts[i]
assert_eq(String(view.get("id")), String(d.get("id", "")), "id agrees at index %d" % i)
assert_eq(
String(view.get("type_id")),
String(d.get("type_id", "")),
"type_id agrees at index %d" % i
)
assert_eq(
bool(view.get("visited")),
bool(d.get("visited", false)),
"visited agrees at index %d" % i
)
var raw_pos: Array = d.get("position", []) as Array
var dict_pos: Vector2i = Vector2i(int(raw_pos[0]), int(raw_pos[1]))
assert_eq(view.get("position"), dict_pos, "position agrees at index %d" % i)
func test_type_filtered_collection_matches() -> void:
var gd_state: RefCounted = _gd_state_or_skip()
if gd_state == null:
return
_seed_canonical_set()
# A second lair so the type filter must return more than one.
GameState.spawn_npc_building(
"eq_lair2", "beast_den", "Beast Den", "map", -1, Vector2i(12, 1), false
)
var lairs: Array = GameState.get_all_npc_buildings_of_type("beast_den")
assert_eq(lairs.size(), 2, "two beast_den lairs returned by type filter")
var villages: Array = GameState.get_all_npc_buildings_of_type("village")
assert_eq(villages.size(), 1, "one village returned by type filter")
var ruins: Array = GameState.get_all_npc_buildings_of_type("ruin")
assert_eq(ruins.size(), 1, "one ruin returned by type filter")
func test_serialize_round_trip_is_byte_identical() -> void:
## Re-pointing _serialize_npc_buildings at the Rust mirror must NOT change
## the save bytes. Seed, serialize, deserialize, serialize again, and
## assert the two JSON encodings are identical.
var gd_state: RefCounted = _gd_state_or_skip()
if gd_state == null:
return
_seed_canonical_set()
var first: Array = GameState._serialize_npc_buildings()
var first_json: String = JSON.stringify(first)
GameState._deserialize_npc_buildings(first)
var second: Array = GameState._serialize_npc_buildings()
var second_json: String = JSON.stringify(second)
assert_eq(first_json, second_json, "serialize→deserialize→serialize is byte-identical")
assert_eq(first.size(), 3, "all three buildings survive the round-trip")

View file

@ -196,8 +196,6 @@ func _seed_game_state() -> void:
GameState.layers = []
GameState.transit_nodes = []
GameState.wonders_built = {}
GameState.npc_buildings = []
GameState._npc_buildings_by_tile = {}
if GameState._gd_state != null:
GameState._gd_state.clear_npc_buildings()
GameState.diplomacy = {}

View file

@ -459,8 +459,6 @@ func _seed_game_state() -> void:
GameState.layers = []
GameState.transit_nodes = []
GameState.wonders_built = {}
GameState.npc_buildings = []
GameState._npc_buildings_by_tile = {}
if GameState._gd_state != null:
GameState._gd_state.clear_npc_buildings()
GameState.game_settings = {"num_players": 2}

View file

@ -3,26 +3,22 @@ extends GutTest
## Covers: attack target detection, lair lookup, leash boundary check,
## and spawn validation on null tiles.
const WildCreatureAIScript: GDScript = preload(
"res://engine/src/modules/ai/wild_creature_ai.gd"
)
const WildCreatureAIScript: GDScript = preload("res://engine/src/modules/ai/wild_creature_ai.gd")
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
## Minimal UnitManager stub — only needs get_units_at() for WildCreatureAI
class StubUnitManager extends RefCounted:
class StubUnitManager:
extends RefCounted
func get_units_at(_pos: Vector2i) -> Array:
return []
## Minimal NPC building with required duck-type properties
class FakeBuilding extends RefCounted:
var type_id: String = ""
var position: Vector2i = Vector2i.ZERO
var _wild_ai: WildCreatureAIScript = null
var _stub_mgr: StubUnitManager = null
var _lair_seq: int = 0
func before_all() -> void:
@ -32,13 +28,29 @@ func before_all() -> void:
func before_each() -> void:
_stub_mgr = StubUnitManager.new()
_wild_ai = WildCreatureAIScript.new(_stub_mgr)
_lair_seq = 0
GameState.players = []
GameState.diplomacy = {}
GameState.npc_buildings = []
_clear_npc_buildings()
GameState.layers = [{"units": [], "map": null}]
func _clear_npc_buildings() -> void:
## Reset the Rust mirror — the single NPC-building store.
var gd_state: RefCounted = GameState.get_gd_state()
if gd_state != null:
gd_state.clear_npc_buildings()
func _seed_lair(lair_type_id: String, pos: Vector2i) -> void:
## Seed one NPC building into the Rust mirror via the canonical spawn
## path — the same path `_find_nearest_lair` now reads through.
_lair_seq += 1
var id: String = "test_npc_%d" % _lair_seq
GameState.spawn_npc_building(id, lair_type_id, lair_type_id, "map", -1, pos, false)
func _make_wild_unit(pos: Vector2i) -> UnitScript:
var unit: UnitScript = UnitScript.new()
unit.unit_id = "wolf"
@ -63,17 +75,11 @@ func _make_player_unit(owner_idx: int, pos: Vector2i) -> UnitScript:
return unit
func _make_lair(lair_type_id: String, pos: Vector2i) -> FakeBuilding:
var bld: FakeBuilding = FakeBuilding.new()
bld.type_id = lair_type_id
bld.position = pos
return bld
# ---------------------------------------------------------------------------
# _find_attack_target
# ---------------------------------------------------------------------------
func test_find_attack_target_returns_null_when_no_player_units() -> void:
var wild: UnitScript = _make_wild_unit(Vector2i(0, 0))
GameState.layers = [{"units": [wild], "map": null}]
@ -93,7 +99,9 @@ func test_find_attack_target_detects_player_unit_in_range() -> void:
"Must detect player unit within detection radius"
)
var target: RefCounted = _wild_ai._find_attack_target(wild, 4)
assert_eq(target.get("position"), player_unit.position, "Must return position of detected player unit")
assert_eq(
target.get("position"), player_unit.position, "Must return position of detected player unit"
)
func test_find_attack_target_ignores_units_outside_radius() -> void:
@ -124,7 +132,8 @@ func test_find_attack_target_returns_nearest_when_multiple() -> void:
var target: RefCounted = _wild_ai._find_attack_target(wild, 5)
assert_eq(
target.get("position"), close_unit.position,
target.get("position"),
close_unit.position,
"Must return position of nearest player unit when multiple exist"
)
@ -133,33 +142,33 @@ func test_find_attack_target_returns_nearest_when_multiple() -> void:
# _find_nearest_lair
# ---------------------------------------------------------------------------
func test_find_nearest_lair_returns_current_pos_when_no_buildings() -> void:
GameState.npc_buildings = []
_clear_npc_buildings()
var pos: Vector2i = Vector2i(5, 5)
var result: Vector2i = _wild_ai._find_nearest_lair(pos, 10)
assert_eq(result, pos, "Must return current position when no lairs exist")
func test_find_nearest_lair_finds_lair_in_range() -> void:
var bld: FakeBuilding = _make_lair("beast_den", Vector2i(3, 3))
GameState.npc_buildings = [bld]
var lair_pos: Vector2i = Vector2i(3, 3)
_seed_lair("beast_den", lair_pos)
var result: Vector2i = _wild_ai._find_nearest_lair(Vector2i(0, 0), 10)
assert_eq(result, bld.position, "Must return lair position when within search radius")
assert_eq(result, lair_pos, "Must return lair position when within search radius")
func test_find_nearest_lair_ignores_villages() -> void:
var village: FakeBuilding = _make_lair("village", Vector2i(2, 0))
var lair: FakeBuilding = _make_lair("beast_den", Vector2i(5, 0))
GameState.npc_buildings = [village, lair]
_seed_lair("village", Vector2i(2, 0))
var lair_pos: Vector2i = Vector2i(5, 0)
_seed_lair("beast_den", lair_pos)
var result: Vector2i = _wild_ai._find_nearest_lair(Vector2i(0, 0), 10)
assert_eq(result, lair.position, "Must skip villages and find the nearest lair")
assert_eq(result, lair_pos, "Must skip villages and find the nearest lair")
func test_find_nearest_lair_ignores_ruins() -> void:
var ruin: FakeBuilding = _make_lair("ruin", Vector2i(1, 0))
GameState.npc_buildings = [ruin]
_seed_lair("ruin", Vector2i(1, 0))
var pos: Vector2i = Vector2i(0, 0)
var result: Vector2i = _wild_ai._find_nearest_lair(pos, 10)
@ -167,18 +176,19 @@ func test_find_nearest_lair_ignores_ruins() -> void:
func test_find_nearest_lair_returns_closest_of_multiple() -> void:
var near_lair: FakeBuilding = _make_lair("spider_nest", Vector2i(3, 0))
var far_lair: FakeBuilding = _make_lair("dragon_lair", Vector2i(9, 0))
GameState.npc_buildings = [far_lair, near_lair]
var near_pos: Vector2i = Vector2i(3, 0)
_seed_lair("dragon_lair", Vector2i(9, 0))
_seed_lair("spider_nest", near_pos)
var result: Vector2i = _wild_ai._find_nearest_lair(Vector2i(0, 0), 10)
assert_eq(result, near_lair.position, "Must return the closest lair among multiple")
assert_eq(result, near_pos, "Must return the closest lair among multiple")
# ---------------------------------------------------------------------------
# _is_outside_leash
# ---------------------------------------------------------------------------
func test_outside_leash_true_when_beyond_radius() -> void:
assert_true(
_wild_ai._is_outside_leash(Vector2i(10, 0), Vector2i(0, 0), 5),