test(entities): prove the Unit hybrid proxy over presentation_units
GUT coverage for Rail-1 Phase-1 increment 1. Each test reads the authoritative Rust slot directly (unit_dict / unit_index_by_id / unit_locate_by_id) and asserts the proxy stays in lockstep: - spawn → slot reflects position + hp; proxy reads the same - move → slot position updates - take_damage → slot hp drops - fortify → slot is_fortified set - death → remove_unit deletes the entry and clears rust_id - index-shift safety → survivors stay addressable by stable id - wild creatures land in the wilds row, never colliding with player 0 - transfer_to_owner moves the slot entry between rows - save → load round-trips a unit through presentation_units Tests early-return with a pending() note when the GDExtension dylib is absent (headless CI that never built it) rather than asserting the local-mirror fallback, which the existing test_unit_actions.gd no-extension suite covers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b28e25f554
commit
b5833b8e0f
1 changed files with 213 additions and 0 deletions
213
src/game/engine/tests/unit/entities/test_unit_slot_proxy.gd
Normal file
213
src/game/engine/tests/unit/entities/test_unit_slot_proxy.gd
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
extends GutTest
|
||||
## Rail-1 Phase-1 increment 1 — Unit hybrid proxy over `presentation_units`.
|
||||
## Proves the proxy flips the unit's authoritative surface (position / hp /
|
||||
## movement / posture / xp) onto the Rust slot reached via
|
||||
## `GameState.get_gd_state().unit_*(pi, ui)`, and that spawn / move / damage /
|
||||
## death / save-load round-trip through that slot.
|
||||
##
|
||||
## The GDExtension dylib is REQUIRED for these assertions (the whole point is to
|
||||
## verify Rust-backing). When the dylib is absent (a headless CI host that never
|
||||
## built it), every test early-returns after a single pending() note rather than
|
||||
## asserting against the local-mirror fallback — that path is covered by the
|
||||
## existing `test_unit_actions.gd` no-extension suite.
|
||||
|
||||
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
|
||||
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||
|
||||
|
||||
func before_all() -> void:
|
||||
DataLoader.load_theme("age-of-dwarves")
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
# A two-player roster so `unit_slot_pi(0/1)` and the wilds row
|
||||
# (`wilds_pi() == 2`) are stable; drain the slot so indices start clean.
|
||||
GameState.players = [_make_player(0), _make_player(1)]
|
||||
GameState.layers = [{"units": []}]
|
||||
GameState._next_unit_id = 1
|
||||
var state: RefCounted = GameState.get_gd_state()
|
||||
if state != null:
|
||||
GameState._drain_unit_slot(GameState.players.size())
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
var state: RefCounted = GameState.get_gd_state()
|
||||
if state != null:
|
||||
GameState._drain_unit_slot(GameState.players.size())
|
||||
GameState.players = []
|
||||
GameState.layers = []
|
||||
|
||||
|
||||
func _make_player(idx: int) -> PlayerScript:
|
||||
var p: PlayerScript = PlayerScript.new(idx, "TestPlayer%d" % idx, "dwarf")
|
||||
p.units = []
|
||||
p.cities = []
|
||||
return p
|
||||
|
||||
|
||||
func _ext() -> RefCounted:
|
||||
return GameState.get_gd_state()
|
||||
|
||||
|
||||
func _skip_if_no_ext() -> bool:
|
||||
if _ext() == null:
|
||||
pending("GDExtension dylib absent — Rust-backed proxy assertions skipped")
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# ── Spawn: unit_dict reflects position / hp ──────────────────────────────────
|
||||
|
||||
func test_spawn_reflects_position_and_hp_in_slot() -> void:
|
||||
if _skip_if_no_ext():
|
||||
return
|
||||
var u: UnitScript = UnitScript.new("dwarf_warrior", 0, Vector2i(3, 4))
|
||||
var ui: int = u.spawn_into_slot()
|
||||
assert_true(ui >= 0, "spawn_into_slot must return a valid slot index")
|
||||
assert_ne(u.rust_id, 0, "spawn must claim a stable rust_id")
|
||||
# Read the authoritative slot directly, not the proxy.
|
||||
var d: Dictionary = _ext().unit_dict(0, ui)
|
||||
assert_false(d.is_empty(), "slot must hold the spawned unit")
|
||||
assert_eq(d.get("position"), Vector2i(3, 4), "slot position must match spawn pos")
|
||||
assert_eq(int(d.get("hp")), u.max_hp, "slot hp must equal spawned max_hp")
|
||||
# The proxy getters read the same slot values.
|
||||
assert_eq(u.position, Vector2i(3, 4), "proxy position reads the slot")
|
||||
assert_eq(u.hp, int(d.get("hp")), "proxy hp reads the slot")
|
||||
|
||||
|
||||
# ── Move: position updates in the slot ───────────────────────────────────────
|
||||
|
||||
func test_move_updates_slot_position() -> void:
|
||||
if _skip_if_no_ext():
|
||||
return
|
||||
var u: UnitScript = UnitScript.new("dwarf_warrior", 0, Vector2i(1, 1))
|
||||
u.spawn_into_slot()
|
||||
u.position = Vector2i(5, 7)
|
||||
var loc: Vector2i = _ext().unit_locate_by_id(u.rust_id)
|
||||
var d: Dictionary = _ext().unit_dict(loc.x, loc.y)
|
||||
assert_eq(d.get("position"), Vector2i(5, 7), "slot must reflect the moved position")
|
||||
assert_eq(u.position, Vector2i(5, 7), "proxy must read back the moved position")
|
||||
|
||||
|
||||
# ── Damage: hp drops in the slot ─────────────────────────────────────────────
|
||||
|
||||
func test_take_damage_drops_slot_hp() -> void:
|
||||
if _skip_if_no_ext():
|
||||
return
|
||||
var u: UnitScript = UnitScript.new("dwarf_warrior", 0, Vector2i(0, 0))
|
||||
u.spawn_into_slot()
|
||||
var full: int = u.max_hp
|
||||
var dead: bool = u.take_damage(3)
|
||||
assert_false(dead, "3 damage must not kill a full-hp warrior")
|
||||
var ui: int = _ext().unit_index_by_id(0, u.rust_id)
|
||||
var d: Dictionary = _ext().unit_dict(0, ui)
|
||||
assert_eq(int(d.get("hp")), full - 3, "slot hp must drop by the damage dealt")
|
||||
assert_eq(u.hp, full - 3, "proxy hp must read the reduced slot hp")
|
||||
|
||||
|
||||
# ── Posture: fortify writes the slot flag ────────────────────────────────────
|
||||
|
||||
func test_fortify_sets_slot_flag() -> void:
|
||||
if _skip_if_no_ext():
|
||||
return
|
||||
var u: UnitScript = UnitScript.new("dwarf_warrior", 0, Vector2i(0, 0))
|
||||
u.spawn_into_slot()
|
||||
u.is_fortified = true
|
||||
var ui: int = _ext().unit_index_by_id(0, u.rust_id)
|
||||
assert_true(bool(_ext().unit_dict(0, ui).get("is_fortified")),
|
||||
"slot is_fortified must be set through the proxy")
|
||||
|
||||
|
||||
# ── Death: remove_from_slot deletes the entry ────────────────────────────────
|
||||
|
||||
func test_remove_from_slot_deletes_unit() -> void:
|
||||
if _skip_if_no_ext():
|
||||
return
|
||||
var u: UnitScript = UnitScript.new("dwarf_warrior", 0, Vector2i(2, 2))
|
||||
u.spawn_into_slot()
|
||||
var rid: int = u.rust_id
|
||||
assert_eq(_ext().unit_index_by_id(0, rid), 0, "unit present before removal")
|
||||
u.remove_from_slot()
|
||||
assert_eq(_ext().unit_index_by_id(0, rid), -1, "unit gone from its row after removal")
|
||||
assert_eq(_ext().unit_locate_by_id(rid), Vector2i(-1, -1), "unit gone from every row")
|
||||
assert_eq(u.rust_id, 0, "rust_id cleared after removal")
|
||||
|
||||
|
||||
# ── Death index-shift safety: survivors stay addressable by id ───────────────
|
||||
|
||||
func test_remove_shifts_indices_but_survivor_resolves() -> void:
|
||||
if _skip_if_no_ext():
|
||||
return
|
||||
var a: UnitScript = UnitScript.new("dwarf_warrior", 0, Vector2i(0, 0))
|
||||
var b: UnitScript = UnitScript.new("dwarf_warrior", 0, Vector2i(1, 0))
|
||||
var c: UnitScript = UnitScript.new("dwarf_warrior", 0, Vector2i(2, 0))
|
||||
a.spawn_into_slot()
|
||||
b.spawn_into_slot()
|
||||
c.spawn_into_slot()
|
||||
# Remove the middle unit; c shifts from index 2 → 1 but its id still resolves
|
||||
# and its proxy still reads the correct position.
|
||||
b.remove_from_slot()
|
||||
assert_eq(_ext().unit_index_by_id(0, c.rust_id), 1, "survivor shifted to index 1")
|
||||
assert_eq(c.position, Vector2i(2, 0), "survivor proxy still reads its own position")
|
||||
assert_eq(a.position, Vector2i(0, 0), "earlier unit unaffected")
|
||||
|
||||
|
||||
# ── Wild creatures land in the dedicated wilds row ───────────────────────────
|
||||
|
||||
func test_wild_unit_uses_wilds_row() -> void:
|
||||
if _skip_if_no_ext():
|
||||
return
|
||||
var w: UnitScript = UnitScript.new("dwarf_warrior", -1, Vector2i(9, 9))
|
||||
w.spawn_into_slot()
|
||||
var wilds: int = GameState.wilds_pi()
|
||||
assert_eq(wilds, 2, "wilds row is one past the two players")
|
||||
assert_eq(_ext().unit_index_by_id(wilds, w.rust_id), 0,
|
||||
"wild unit must live in the wilds row, not player 0")
|
||||
assert_eq(_ext().unit_index_by_id(0, w.rust_id), -1,
|
||||
"wild unit must NOT collide with player 0's row")
|
||||
|
||||
|
||||
# ── Capture: transfer_to_owner moves the slot entry ──────────────────────────
|
||||
|
||||
func test_transfer_to_owner_moves_between_rows() -> void:
|
||||
if _skip_if_no_ext():
|
||||
return
|
||||
var u: UnitScript = UnitScript.new("dwarf_warrior", 0, Vector2i(4, 4))
|
||||
u.spawn_into_slot()
|
||||
var rid: int = u.rust_id
|
||||
u.transfer_to_owner(1)
|
||||
assert_eq(u.owner, 1, "owner re-stamped to the captor")
|
||||
assert_eq(_ext().unit_index_by_id(0, rid), -1, "gone from the old owner's row")
|
||||
assert_true(_ext().unit_index_by_id(1, rid) >= 0, "present in the captor's row")
|
||||
assert_eq(u.position, Vector2i(4, 4), "proxy still reads the unit's position post-capture")
|
||||
|
||||
|
||||
# ── Save → load round-trips a unit through presentation_units ─────────────────
|
||||
|
||||
func test_save_load_round_trips_through_slot() -> void:
|
||||
if _skip_if_no_ext():
|
||||
return
|
||||
var u: UnitScript = UnitScript.new("dwarf_warrior", 0, Vector2i(6, 8))
|
||||
u.spawn_into_slot()
|
||||
u.take_damage(2)
|
||||
u.is_fortified = true
|
||||
u.movement_remaining = 0
|
||||
var saved: Dictionary = u.to_save_dict()
|
||||
var saved_hp: int = int(saved.get("hp"))
|
||||
var saved_rid: int = int(saved.get("rust_id"))
|
||||
assert_true(saved_rid > 0, "save must persist the rust_id")
|
||||
|
||||
# Drain and rehydrate into a fresh proxy, exactly as the load path does.
|
||||
GameState._drain_unit_slot(GameState.players.size())
|
||||
var restored: UnitScript = UnitScript.new("", -1, Vector2i.ZERO, false)
|
||||
restored.from_save_dict(saved)
|
||||
assert_eq(restored.rust_id, saved_rid, "restored proxy keeps the saved rust_id")
|
||||
assert_eq(restored.position, Vector2i(6, 8), "restored position round-trips")
|
||||
assert_eq(restored.hp, saved_hp, "restored hp round-trips")
|
||||
assert_true(restored.is_fortified, "restored fortified flag round-trips")
|
||||
assert_eq(restored.movement_remaining, 0, "restored movement round-trips")
|
||||
# And it is backed by a real slot entry, not just local mirrors.
|
||||
var loc: Vector2i = _ext().unit_locate_by_id(saved_rid)
|
||||
assert_true(loc.x >= 0, "restored unit is present in presentation_units")
|
||||
assert_eq(int(_ext().unit_dict(loc.x, loc.y).get("hp")), saved_hp,
|
||||
"slot hp matches the restored proxy hp")
|
||||
Loading…
Add table
Reference in a new issue