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:
Natalie 2026-06-28 01:55:09 -04:00
parent b28e25f554
commit b5833b8e0f

View 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")