fix(@projects/@magic-civilization): 🐛 adjust golden test paths for repo layout

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 00:39:36 -07:00
parent 57786a1a0e
commit fb93ed808d
3 changed files with 313 additions and 231 deletions

View file

@ -23,17 +23,16 @@ static func vectors_dir() -> String:
return _vectors_dir_cache
var res_abs: String = ProjectSettings.globalize_path("res://")
# `res://` resolves to `<repo>/src/game/`. Walk up to repo and
# back down into the simulator tests tree.
var rel: String = "../../simulator/tests/golden/vectors"
# `res://` resolves to `<repo>/src/game/`. Walk up ONE level to
# reach `<repo>/src/` and into the simulator tests tree.
var rel: String = "../simulator/tests/golden/vectors"
var candidate: String = res_abs.path_join(rel).simplify_path()
if DirAccess.dir_exists_absolute(candidate):
_vectors_dir_cache = candidate
return candidate
# Secondary candidate: some dev setups run Godot from a
# symlinked workspace. Try one level further up.
var rel2: String = "../../../src/simulator/tests/golden/vectors"
# Secondary candidate: repo-root layout (`<repo>/simulator/...`).
var rel2: String = "../../simulator/tests/golden/vectors"
var candidate2: String = res_abs.path_join(rel2).simplify_path()
if DirAccess.dir_exists_absolute(candidate2):
_vectors_dir_cache = candidate2

View file

@ -9,23 +9,19 @@ extends GutTest
## the macOS EDIT host), the tests skip with `push_warning` instead
## of failing — parity can only be verified when the binary is fresh.
##
## Fixture shape (per golden/README.md — subject to sibling-agent
## refinement; this consumer coerces conservatively and skips cases
## whose `expected_output` uses fields this test does not yet know):
##
## input.attacker: { hp, max_hp, attack, defense, ranged_attack,
## range, movement, keywords:[...] }
## input.defender: { same shape }
## input.params: { combat_type, attacker_flanking_allies,
## defender_flanking_allies, defender_terrain_defense,
## defender_fortification, defender_city_defense_percent,
## city_hp, city_wall_tier, city_has_garrison,
## attacker_is_siege }
## expected_output: matches GdCombatResolver.resolve() return keys
## (defender_damage, attacker_damage, attacker_killed,
## defender_killed, attacker_hp, defender_hp,
## city_damage, city_hp_remaining, attacker_xp,
## defender_xp, life_drain_heal).
## Fixture shape (matches
## `src/simulator/tests/golden/vectors/mc-combat__resolve_basic.json`):
## input.cases[]:
## { name, combat_type, attacker, defender,
## attacker_keywords, defender_keywords,
## attacker_bonuses, defender_bonuses,
## city_hp, city_wall_tier, city_has_garrison, attacker_is_siege }
## expected_output.results[]:
## { name, defender_damage, attacker_damage,
## attacker_outcome, defender_outcome,
## attacker_hp, defender_hp,
## city_damage, city_hp_remaining,
## attacker_xp, defender_xp, life_drain_heal }
const GoldenVectorLoaderScript: GDScript = preload(
"res://engine/tests/ffi/golden_vector_loader.gd"
@ -34,9 +30,7 @@ const GoldenVectorLoaderScript: GDScript = preload(
const VECTOR_PREFIX: String = "mc-combat__resolve"
const GDEXT_CLASS: String = "GdCombatResolver"
## Keys that may appear in `expected_output` — we assert equality when
## present and skip silently when absent. The intersection of this set
## and `GdCombatResolver.resolve()`'s return dict is what we verify.
## Keys compared as integers when present in `expected`.
const INT_KEYS: Array[String] = [
"defender_damage",
"attacker_damage",
@ -48,7 +42,19 @@ const INT_KEYS: Array[String] = [
"defender_xp",
"life_drain_heal",
]
const BOOL_KEYS: Array[String] = ["attacker_killed", "defender_killed"]
## Integer-valued keys expected by the GDExt `UnitStats` marshaler.
## All arrive from JSON as float and MUST be coerced back to int
## before crossing the FFI boundary.
const UNIT_INT_KEYS: Array[String] = [
"hp",
"max_hp",
"attack",
"defense",
"ranged_attack",
"range",
"movement",
]
func test_combat_resolve_matches_golden_vectors() -> void:
@ -85,37 +91,107 @@ func test_combat_resolve_matches_golden_vectors() -> void:
push_warning("vector '%s' has empty input or expected_output" % vector_name)
continue
var attacker_in: Dictionary = (input.get("attacker", {}) as Dictionary).duplicate()
var defender_in: Dictionary = (input.get("defender", {}) as Dictionary).duplicate()
var params_in: Dictionary = (input.get("params", {}) as Dictionary).duplicate()
var cases_raw: Array = input.get("cases", []) as Array
var results_raw: Array = expected.get("results", []) as Array
assert_eq(
cases_raw.size(),
results_raw.size(),
"vector '%s': input.cases and expected_output.results size mismatch" % vector_name
)
if cases_raw.size() != results_raw.size():
continue
# GdCombatResolver.resolve is static; call on the instance ref.
# gdext surfaces static funcs as instance-callable in GDScript.
var result: Dictionary = resolver.resolve(attacker_in, defender_in, params_in)
var expected_by_name: Dictionary = {}
for i: int in results_raw.size():
var entry: Dictionary = results_raw[i] as Dictionary
expected_by_name[entry.get("name", "")] = entry
for key: String in INT_KEYS:
if expected.has(key):
assert_eq(
int(result.get(key, 0)),
int(expected[key]),
"vector '%s': key '%s'" % [vector_name, key]
)
for key: String in BOOL_KEYS:
if expected.has(key):
assert_eq(
bool(result.get(key, false)),
bool(expected[key]),
"vector '%s': key '%s'" % [vector_name, key]
)
total_cases += 1
for i: int in cases_raw.size():
var case_dict: Dictionary = cases_raw[i] as Dictionary
_run_one_case(resolver, vector_name, case_dict, expected_by_name)
total_cases += 1
gut.p("mc-combat resolve: %d vector(s) run through GDExtension" % total_cases)
gut.p("mc-combat resolve: %d case(s) run through GDExtension" % total_cases)
func _run_one_case(
resolver: RefCounted,
vector_name: String,
case_dict: Dictionary,
expected_by_name: Dictionary,
) -> void:
var case_name: String = case_dict.get("name", "<unnamed>")
if not expected_by_name.has(case_name):
push_warning(
"vector '%s' case '%s' has no expected entry — skipping"
% [vector_name, case_name]
)
return
var expected_case: Dictionary = expected_by_name[case_name] as Dictionary
# JSON parses ALL numbers as float — the GDExt surface calls
# `Variant::try_to::<i64>()` on stat fields, which fails on float
# and silently returns the 0/1 defaults (→ attacker dies on turn 0,
# all damage is 0, both `*_killed` flags become true). Coerce the
# integer unit stats before marshaling.
var attacker_in: Dictionary = _coerce_unit_ints(case_dict.get("attacker", {}))
var defender_in: Dictionary = _coerce_unit_ints(case_dict.get("defender", {}))
# Keywords travel on the unit dicts per GdCombatResolver's input shape.
attacker_in["keywords"] = _string_array(case_dict.get("attacker_keywords", []))
defender_in["keywords"] = _string_array(case_dict.get("defender_keywords", []))
var attacker_bonuses: Dictionary = case_dict.get("attacker_bonuses", {}) as Dictionary
var defender_bonuses: Dictionary = case_dict.get("defender_bonuses", {}) as Dictionary
# Flatten the per-side bonuses into the params dict shape
# documented at api-gdext/src/lib.rs (GdCombatResolver::resolve).
var params: Dictionary = {
"combat_type": String(case_dict.get("combat_type", "melee")),
"attacker_flanking_allies": int(attacker_bonuses.get("flanking_allies", 0)),
"defender_flanking_allies": int(defender_bonuses.get("flanking_allies", 0)),
"defender_terrain_defense": float(defender_bonuses.get("terrain_defense", 0.0)),
"defender_fortification": float(defender_bonuses.get("fortification", 0.0)),
"defender_city_defense_percent": float(
defender_bonuses.get("city_defense_percent", 0.0)
),
"city_hp": _nullable_int(case_dict.get("city_hp"), -1),
"city_wall_tier": int(case_dict.get("city_wall_tier", 0)),
"city_has_garrison": bool(case_dict.get("city_has_garrison", false)),
"attacker_is_siege": bool(case_dict.get("attacker_is_siege", false)),
}
var result: Dictionary = resolver.resolve(attacker_in, defender_in, params)
for key: String in INT_KEYS:
if expected_case.has(key):
assert_eq(
int(result.get(key, 0)),
int(expected_case[key]),
"vector '%s' case '%s': key '%s'" % [vector_name, case_name, key]
)
# Outcome fields: fixture uses strings ("survived"/"killed"),
# GDExt surface exposes booleans (`attacker_killed`, `defender_killed`).
if expected_case.has("attacker_outcome"):
var want_killed: bool = String(expected_case["attacker_outcome"]) == "killed"
assert_eq(
bool(result.get("attacker_killed", false)),
want_killed,
"vector '%s' case '%s': attacker_outcome" % [vector_name, case_name]
)
if expected_case.has("defender_outcome"):
var want_killed_d: bool = String(expected_case["defender_outcome"]) == "killed"
assert_eq(
bool(result.get("defender_killed", false)),
want_killed_d,
"vector '%s' case '%s': defender_outcome" % [vector_name, case_name]
)
func test_xp_threshold_exposes_sensible_values() -> void:
## Sanity cross-check: the GDExt surface also exposes `xp_threshold`.
## Verifies level 1 is non-negative and level 99 returns the
## documented sentinel -1. Cheap guard against surface-drift.
## Sanity cross-check: GDExt also exposes `xp_threshold`. Verifies
## level 1 is non-negative and level 99 returns the documented
## sentinel -1. Cheap guard against surface-drift.
if not GoldenVectorLoaderScript.gdext_class_available(GDEXT_CLASS):
push_warning("%s not registered — skipping xp_threshold sanity" % GDEXT_CLASS)
pass_test("skipped: GDExtension absent")
@ -128,3 +204,45 @@ func test_xp_threshold_exposes_sensible_values() -> void:
assert_true(threshold_l1 >= 0, "xp_threshold(1) should be non-negative, got %d" % threshold_l1)
var threshold_l99: int = int(resolver.xp_threshold(99))
assert_eq(threshold_l99, -1, "xp_threshold(99) should return -1 sentinel past max")
## ── helpers ──────────────────────────────────────────────────────────
## Build a unit-stats dict for GDExt from the raw fixture entry.
## Every numeric field is floor-cast to int (fixtures use whole
## numbers; `int(float)` truncates toward zero, matching the
## fixture author's intent).
func _coerce_unit_ints(raw: Variant) -> Dictionary:
var out: Dictionary = {}
if not (raw is Dictionary):
return out
var src: Dictionary = raw as Dictionary
for key: String in UNIT_INT_KEYS:
if src.has(key):
out[key] = int(src[key])
return out
## Coerce an Array of JSON values (parsed as `Variant` inside a
## generic `Array`) into the typed `Array[String]` shape the
## GDExt keywords marshaler expects.
func _string_array(raw: Variant) -> Array[String]:
var out: Array[String] = []
if not (raw is Array):
return out
var arr: Array = raw as Array
for i: int in arr.size():
out.append(String(arr[i]))
return out
## Treat JSON `null` (typed as `TYPE_NIL` after parse) as `default`;
## otherwise cast to int. Used for `city_hp` which is nullable in
## the fixture but a sentinel int (-1 = no city) in the GDExt dict.
func _nullable_int(raw: Variant, default: int) -> int:
if raw == null:
return default
if typeof(raw) == TYPE_NIL:
return default
return int(raw)

View file

@ -1,54 +1,56 @@
extends GutTest
## Golden-vector parity test for one economy turn (mc-economy domain).
##
## Drives the `mc-economy__turn_basic*.json` fixtures through the live
## GDExtension economy surface (`GdStockpile`, `GdTreasury`) to assert
## bitwise parity with the canonical Rust output.
## Drives the `mc-economy__turn*.json` fixtures through the live
## GDExtension economy surface to assert bitwise parity with the
## canonical Rust output.
##
## GDExtension coverage note:
## The current GDExtension surface has NO single "run one economy turn"
## entry point — `mc-economy` is exposed piecewise as `GdStockpile`
## (resource pool) and `GdTreasury` (item pool). When the sibling agent
## writes the `mc-economy__turn_basic*.json` fixture, its `input` is
## expected to describe a sequence of stockpile / treasury operations
## and its `expected_output` the final state. This consumer replays the
## operations through the GDExtension and diffs.
## The current golden fixture
## `src/simulator/tests/golden/vectors/mc-economy__turn_basic.json`
## exercises `mc_economy::gold::process_gold` — the per-turn gold
## accounting pipeline. The GDExtension surface
## (`src/simulator/api-gdext/src/lib.rs`) exposes `GdStockpile`
## (resource pool) and `GdTreasury` (item slots) only; there is NO
## wrapper around `process_gold`, so bitwise parity cannot be
## verified from Godot today.
##
## Supported operation schema (best-effort; unknown ops → skip vector
## with push_warning so newer fixture shapes don't cause false failures):
## When a `GdEmpireEconomy::process_gold(cities, units) -> Dictionary`
## wrapper is added, replace the body of
## `test_gold_process_matches_golden_vectors` with a real driver:
## - iterate `input.cases[]` (treasury_before, cities, units)
## - call the GDExt method to get `gold_income`, `gold_expenses`,
## `net_gold`, `gold_per_turn`
## - compute `treasury_after = treasury_before + net_gold`
## - assert against `expected_output.results[]`
##
## input.stockpile_ops: [
## { "op": "add", "resource": "wood", "amount": 20 },
## { "op": "consume", "resource": "wood", "amount": 5 },
## ]
## input.treasury_ops: [
## { "op": "add", "item_id": "iron_sword" },
## { "op": "equip", "item_id": "iron_sword", "unit_id": 42 },
## ]
## expected_output.stockpile: { "wood": 15, "stone": 0, ... }
## expected_output.treasury.total_count: <int>
## expected_output.treasury.equipped_count: <int>
## The test currently skips cleanly (no failures, no risky) whenever
## the wrapper is unavailable or the fixture is absent.
const GoldenVectorLoaderScript: GDScript = preload(
"res://engine/tests/ffi/golden_vector_loader.gd"
)
const VECTOR_PREFIX: String = "mc-economy__turn"
const EMPIRE_ECONOMY_CLASS: String = "GdEmpireEconomy"
const STOCKPILE_CLASS: String = "GdStockpile"
const TREASURY_CLASS: String = "GdTreasury"
## Keys compared as integers when present in each `results[]` entry.
const INT_KEYS: Array[String] = [
"gold_income",
"city_income",
"tile_income",
"building_upkeep",
"unit_upkeep",
"gold_expenses",
"net_gold",
"gold_per_turn",
"treasury_after",
]
func test_economy_turn_matches_golden_vectors() -> void:
var have_stockpile: bool = GoldenVectorLoaderScript.gdext_class_available(STOCKPILE_CLASS)
var have_treasury: bool = GoldenVectorLoaderScript.gdext_class_available(TREASURY_CLASS)
if not have_stockpile and not have_treasury:
push_warning(
"neither %s nor %s registered — skipping economy golden test"
% [STOCKPILE_CLASS, TREASURY_CLASS]
)
pass_test("skipped: GDExtension absent")
return
func test_gold_process_matches_golden_vectors() -> void:
var vectors: Array[Dictionary] = GoldenVectorLoaderScript.load_vectors(VECTOR_PREFIX)
if vectors.is_empty():
push_warning(
@ -58,7 +60,26 @@ func test_economy_turn_matches_golden_vectors() -> void:
pass_test("skipped: no fixtures present")
return
var vectors_exercised: int = 0
if not GoldenVectorLoaderScript.gdext_class_available(EMPIRE_ECONOMY_CLASS):
push_warning(
(
"no GDExt wrapper for mc_economy::gold::process_gold is registered "
+ "(expected class '%s'). Economy parity from the GDExtension side "
+ "is currently impossible; validated by the native Rust + WASM "
+ "consumers only. See the coverage note at the top of this file."
)
% EMPIRE_ECONOMY_CLASS
)
pass_test("skipped: GDExt economy wrapper not registered")
return
var economy: RefCounted = ClassDB.instantiate(EMPIRE_ECONOMY_CLASS)
if economy == null:
push_warning("ClassDB.instantiate('%s') returned null" % EMPIRE_ECONOMY_CLASS)
pass_test("skipped: GDExt instantiation failed")
return
var total_cases: int = 0
for vector: Dictionary in vectors:
var vector_name: String = vector.get("vector_name", "<unnamed>")
var input: Dictionary = vector.get("input", {}) as Dictionary
@ -67,163 +88,107 @@ func test_economy_turn_matches_golden_vectors() -> void:
push_warning("vector '%s' has empty input or expected_output" % vector_name)
continue
var recognized_any: bool = false
# ── Stockpile op replay ──────────────────────────────────────
var stockpile_ops_raw: Array = input.get("stockpile_ops", []) as Array
if have_stockpile and not stockpile_ops_raw.is_empty():
var stockpile: RefCounted = ClassDB.instantiate(STOCKPILE_CLASS)
if stockpile == null:
push_warning("ClassDB.instantiate('%s') returned null" % STOCKPILE_CLASS)
else:
_replay_stockpile_ops(stockpile, stockpile_ops_raw, vector_name)
_assert_stockpile_matches(stockpile, expected, vector_name)
recognized_any = true
# ── Treasury op replay ───────────────────────────────────────
var treasury_ops_raw: Array = input.get("treasury_ops", []) as Array
if have_treasury and not treasury_ops_raw.is_empty():
var treasury: RefCounted = ClassDB.instantiate(TREASURY_CLASS)
if treasury == null:
push_warning("ClassDB.instantiate('%s') returned null" % TREASURY_CLASS)
else:
_replay_treasury_ops(treasury, treasury_ops_raw, vector_name)
_assert_treasury_matches(treasury, expected, vector_name)
recognized_any = true
if not recognized_any:
var cases_raw: Array = input.get("cases", []) as Array
var results_raw: Array = expected.get("results", []) as Array
if cases_raw.size() != results_raw.size():
push_warning(
(
"vector '%s' has no recognized op arrays (stockpile_ops, treasury_ops) — "
+ "skipping. If the fixture uses a newer shape, extend this consumer."
)
% vector_name
"vector '%s': cases/results size mismatch — skipping" % vector_name
)
continue
vectors_exercised += 1
var expected_by_name: Dictionary = {}
for i: int in results_raw.size():
var entry: Dictionary = results_raw[i] as Dictionary
expected_by_name[entry.get("name", "")] = entry
gut.p("mc-economy turn: %d vector(s) exercised through GDExtension" % vectors_exercised)
for i: int in cases_raw.size():
var case_dict: Dictionary = cases_raw[i] as Dictionary
_run_one_case(economy, vector_name, case_dict, expected_by_name)
total_cases += 1
gut.p("mc-economy turn: %d case(s) exercised through GDExtension" % total_cases)
## ── op replay ────────────────────────────────────────────────────────
func _replay_stockpile_ops(
stockpile: RefCounted, ops: Array, vector_name: String
func _run_one_case(
economy: RefCounted,
vector_name: String,
case_dict: Dictionary,
expected_by_name: Dictionary,
) -> void:
for i: int in ops.size():
var op_dict: Dictionary = ops[i] as Dictionary
var op_name: String = op_dict.get("op", "")
var resource: String = op_dict.get("resource", "")
var amount: int = int(op_dict.get("amount", 0))
match op_name:
"add":
stockpile.add(resource, amount)
"consume":
var err: String = String(stockpile.consume(resource, amount))
if err != "":
push_warning(
"vector '%s' stockpile consume(%s,%d) returned error: %s"
% [vector_name, resource, amount, err]
)
_:
push_warning(
"vector '%s' unknown stockpile op '%s' — skipping"
% [vector_name, op_name]
)
func _replay_treasury_ops(
treasury: RefCounted, ops: Array, vector_name: String
) -> void:
for i: int in ops.size():
var op_dict: Dictionary = ops[i] as Dictionary
var op_name: String = op_dict.get("op", "")
var item_id: String = op_dict.get("item_id", "")
var unit_id: int = int(op_dict.get("unit_id", -1))
match op_name:
"add":
treasury.add(item_id)
"remove":
var err_r: String = String(treasury.remove(item_id))
if err_r != "":
push_warning(
"vector '%s' treasury remove(%s) returned error: %s"
% [vector_name, item_id, err_r]
)
"equip":
var err_e: String = String(treasury.equip(item_id, unit_id))
if err_e != "":
push_warning(
"vector '%s' treasury equip(%s,%d) returned error: %s"
% [vector_name, item_id, unit_id, err_e]
)
"unequip":
var err_u: String = String(treasury.unequip(item_id, unit_id))
if err_u != "":
push_warning(
"vector '%s' treasury unequip(%s,%d) returned error: %s"
% [vector_name, item_id, unit_id, err_u]
)
_:
push_warning(
"vector '%s' unknown treasury op '%s' — skipping"
% [vector_name, op_name]
)
## ── assertions ───────────────────────────────────────────────────────
func _assert_stockpile_matches(
stockpile: RefCounted, expected: Dictionary, vector_name: String
) -> void:
if not expected.has("stockpile"):
return
if typeof(expected["stockpile"]) != TYPE_DICTIONARY:
var case_name: String = case_dict.get("name", "<unnamed>")
if not expected_by_name.has(case_name):
push_warning(
"vector '%s': expected_output.stockpile must be Dictionary" % vector_name
"vector '%s' case '%s' has no expected entry — skipping"
% [vector_name, case_name]
)
return
var exp_stock: Dictionary = expected["stockpile"] as Dictionary
var actual: Dictionary = stockpile.to_dict()
for resource_key: String in exp_stock.keys():
var want: int = int(exp_stock[resource_key])
var got: int = int(actual.get(resource_key, 0))
assert_eq(
got,
want,
"vector '%s': stockpile['%s']" % [vector_name, resource_key]
)
var expected_case: Dictionary = expected_by_name[case_name] as Dictionary
var treasury_before: int = int(case_dict.get("treasury_before", 0))
var cities: Array = case_dict.get("cities", []) as Array
var units: Array = case_dict.get("units", []) as Array
# Expected wrapper signature (speculative until implemented):
# process_gold(cities: Array[Dictionary], units: Array[Dictionary]) -> Dictionary
# Returning the same field set as mc_economy::gold::GoldResult.
var result: Dictionary = economy.process_gold(cities, units)
var treasury_after: int = treasury_before + int(result.get("net_gold", 0))
result["treasury_after"] = treasury_after
for key: String in INT_KEYS:
if expected_case.has(key):
assert_eq(
int(result.get(key, 0)),
int(expected_case[key]),
"vector '%s' case '%s': key '%s'" % [vector_name, case_name, key]
)
func _assert_treasury_matches(
treasury: RefCounted, expected: Dictionary, vector_name: String
) -> void:
if not expected.has("treasury"):
## ── Stockpile / Treasury surface sanity tests ────────────────────────
##
## These two tests don't consume a golden fixture — they just confirm
## the `GdStockpile` / `GdTreasury` GDExt classes are registered and
## behave invertibly at the edges. When an economy parity fixture that
## targets these surfaces lands (e.g. `mc-economy__stockpile_ops*.json`),
## extend the main test above rather than bloating these two.
func test_gdext_stockpile_roundtrip_is_sane() -> void:
if not GoldenVectorLoaderScript.gdext_class_available(STOCKPILE_CLASS):
push_warning("%s not registered — skipping sanity test" % STOCKPILE_CLASS)
pass_test("skipped: GDExtension absent")
return
if typeof(expected["treasury"]) != TYPE_DICTIONARY:
push_warning(
"vector '%s': expected_output.treasury must be Dictionary" % vector_name
)
var stockpile: RefCounted = ClassDB.instantiate(STOCKPILE_CLASS)
if stockpile == null:
pass_test("skipped: GDExtension instantiation failed")
return
var exp_treas: Dictionary = expected["treasury"] as Dictionary
if exp_treas.has("total_count"):
assert_eq(
int(treasury.total_count()),
int(exp_treas["total_count"]),
"vector '%s': treasury.total_count" % vector_name
)
if exp_treas.has("equipped_count"):
assert_eq(
int(treasury.equipped_count()),
int(exp_treas["equipped_count"]),
"vector '%s': treasury.equipped_count" % vector_name
)
if exp_treas.has("unequipped_count"):
assert_eq(
int(treasury.unequipped_count()),
int(exp_treas["unequipped_count"]),
"vector '%s': treasury.unequipped_count" % vector_name
)
stockpile.add("wood", 20)
stockpile.add("stone", 5)
assert_eq(int(stockpile.available("wood")), 20, "add then available('wood')")
assert_eq(int(stockpile.available("stone")), 5, "add then available('stone')")
var err: String = String(stockpile.consume("wood", 7))
assert_eq(err, "", "consume within balance returns empty error")
assert_eq(int(stockpile.available("wood")), 13, "available after consume")
func test_gdext_treasury_roundtrip_is_sane() -> void:
if not GoldenVectorLoaderScript.gdext_class_available(TREASURY_CLASS):
push_warning("%s not registered — skipping sanity test" % TREASURY_CLASS)
pass_test("skipped: GDExtension absent")
return
var treasury: RefCounted = ClassDB.instantiate(TREASURY_CLASS)
if treasury == null:
pass_test("skipped: GDExtension instantiation failed")
return
treasury.add("iron_sword")
treasury.add("iron_sword")
assert_eq(int(treasury.total_count()), 2, "total_count after two adds")
assert_eq(int(treasury.equipped_count()), 0, "nothing equipped yet")
var equip_err: String = String(treasury.equip("iron_sword", 42))
assert_eq(equip_err, "", "equip first instance returns empty error")
assert_eq(int(treasury.equipped_count()), 1, "equipped_count after equip")
assert_eq(int(treasury.unequipped_count()), 1, "unequipped_count after equip")