From 1bca207c795bb902220d8905dd37dc55bf193061 Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 8 Jun 2026 07:37:54 -0700 Subject: [PATCH] =?UTF-8?q?fix(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=90=9B=20remove=20npc=5Fbuildings=20array=20and=20rebuild?= =?UTF-8?q?=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../objectives/p2-72a-building-entity-port.md | 117 +++++++++-- src/game/engine/src/autoloads/game_state.gd | 154 +++++++------- .../game_state_serialization_helpers.gd | 9 - src/game/engine/src/entities/building.gd | 23 ++- .../src/generation/village_lair_placer.gd | 41 +++- src/game/engine/src/map/entity_finder.gd | 17 +- .../engine/src/modules/ai/wild_creature_ai.gd | 38 +++- .../management/rust_fauna_integration.gd | 15 +- .../src/rendering/lair_overlay_renderer.gd | 23 ++- .../test_npc_building_mirror_equivalence.gd | 191 ++++++++++++++++++ .../integration/test_save_load_round_trip.gd | 2 - .../engine/tests/unit/test_save_manager.gd | 2 - .../tests/unit/test_wild_creature_ai.gd | 78 +++---- 13 files changed, 521 insertions(+), 189 deletions(-) create mode 100644 src/game/engine/tests/integration/test_npc_building_mirror_equivalence.gd diff --git a/.project/objectives/p2-72a-building-entity-port.md b/.project/objectives/p2-72a-building-entity-port.md index fea42ca8..4637fd27 100644 --- a/.project/objectives/p2-72a-building-entity-port.md +++ b/.project/objectives/p2-72a-building-entity-port.md @@ -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` 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` 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 diff --git a/src/game/engine/src/autoloads/game_state.gd b/src/game/engine/src/autoloads/game_state.gd index 102abc1d..acb7f82c 100644 --- a/src/game/engine/src/autoloads/game_state.gd +++ b/src/game/engine/src/autoloads/game_state.gd @@ -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: diff --git a/src/game/engine/src/autoloads/game_state_serialization_helpers.gd b/src/game/engine/src/autoloads/game_state_serialization_helpers.gd index 864ded05..2c1b82e2 100644 --- a/src/game/engine/src/autoloads/game_state_serialization_helpers.gd +++ b/src/game/engine/src/autoloads/game_state_serialization_helpers.gd @@ -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") diff --git a/src/game/engine/src/entities/building.gd b/src/game/engine/src/entities/building.gd index a43ec2f3..082245ee 100644 --- a/src/game/engine/src/entities/building.gd +++ b/src/game/engine/src/entities/building.gd @@ -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` diff --git a/src/game/engine/src/generation/village_lair_placer.gd b/src/game/engine/src/generation/village_lair_placer.gd index 59ccf98e..ff959430 100644 --- a/src/game/engine/src/generation/village_lair_placer.gd +++ b/src/game/engine/src/generation/village_lair_placer.gd @@ -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(): diff --git a/src/game/engine/src/map/entity_finder.gd b/src/game/engine/src/map/entity_finder.gd index 4c2f0afe..76f5bc51 100644 --- a/src/game/engine/src/map/entity_finder.gd +++ b/src/game/engine/src/map/entity_finder.gd @@ -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) diff --git a/src/game/engine/src/modules/ai/wild_creature_ai.gd b/src/game/engine/src/modules/ai/wild_creature_ai.gd index c0450e5b..79b5fcd9 100644 --- a/src/game/engine/src/modules/ai/wild_creature_ai.gd +++ b/src/game/engine/src/modules/ai/wild_creature_ai.gd @@ -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: diff --git a/src/game/engine/src/modules/management/rust_fauna_integration.gd b/src/game/engine/src/modules/management/rust_fauna_integration.gd index 36f1c8c0..c961ef45 100644 --- a/src/game/engine/src/modules/management/rust_fauna_integration.gd +++ b/src/game/engine/src/modules/management/rust_fauna_integration.gd @@ -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 diff --git a/src/game/engine/src/rendering/lair_overlay_renderer.gd b/src/game/engine/src/rendering/lair_overlay_renderer.gd index 9cd31932..9ac3fcb7 100644 --- a/src/game/engine/src/rendering/lair_overlay_renderer.gd +++ b/src/game/engine/src/rendering/lair_overlay_renderer.gd @@ -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, } diff --git a/src/game/engine/tests/integration/test_npc_building_mirror_equivalence.gd b/src/game/engine/tests/integration/test_npc_building_mirror_equivalence.gd new file mode 100644 index 00000000..ddbb477a --- /dev/null +++ b/src/game/engine/tests/integration/test_npc_building_mirror_equivalence.gd @@ -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") diff --git a/src/game/engine/tests/integration/test_save_load_round_trip.gd b/src/game/engine/tests/integration/test_save_load_round_trip.gd index 199982b8..14590d94 100644 --- a/src/game/engine/tests/integration/test_save_load_round_trip.gd +++ b/src/game/engine/tests/integration/test_save_load_round_trip.gd @@ -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 = {} diff --git a/src/game/engine/tests/unit/test_save_manager.gd b/src/game/engine/tests/unit/test_save_manager.gd index ded97c75..2e3ff63b 100644 --- a/src/game/engine/tests/unit/test_save_manager.gd +++ b/src/game/engine/tests/unit/test_save_manager.gd @@ -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} diff --git a/src/game/engine/tests/unit/test_wild_creature_ai.gd b/src/game/engine/tests/unit/test_wild_creature_ai.gd index acb27376..9bec687b 100644 --- a/src/game/engine/tests/unit/test_wild_creature_ai.gd +++ b/src/game/engine/tests/unit/test_wild_creature_ai.gd @@ -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),