diff --git a/src/game/engine/tests/unit/entities/test_unit_slot_proxy.gd b/src/game/engine/tests/unit/entities/test_unit_slot_proxy.gd new file mode 100644 index 00000000..fd789ea6 --- /dev/null +++ b/src/game/engine/tests/unit/entities/test_unit_slot_proxy.gd @@ -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")