diff --git a/src/game/engine/tests/ffi/golden_vector_loader.gd b/src/game/engine/tests/ffi/golden_vector_loader.gd index 7a3878d4..dcfe357f 100644 --- a/src/game/engine/tests/ffi/golden_vector_loader.gd +++ b/src/game/engine/tests/ffi/golden_vector_loader.gd @@ -23,17 +23,16 @@ static func vectors_dir() -> String: return _vectors_dir_cache var res_abs: String = ProjectSettings.globalize_path("res://") - # `res://` resolves to `/src/game/`. Walk up to repo and - # back down into the simulator tests tree. - var rel: String = "../../simulator/tests/golden/vectors" + # `res://` resolves to `/src/game/`. Walk up ONE level to + # reach `/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 (`/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 diff --git a/src/game/engine/tests/ffi/test_golden_combat.gd b/src/game/engine/tests/ffi/test_golden_combat.gd index ed9e6660..6748bf25 100644 --- a/src/game/engine/tests/ffi/test_golden_combat.gd +++ b/src/game/engine/tests/ffi/test_golden_combat.gd @@ -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", "") + 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::()` 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) diff --git a/src/game/engine/tests/ffi/test_golden_economy.gd b/src/game/engine/tests/ffi/test_golden_economy.gd index df61cf37..737d9772 100644 --- a/src/game/engine/tests/ffi/test_golden_economy.gd +++ b/src/game/engine/tests/ffi/test_golden_economy.gd @@ -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: -## expected_output.treasury.equipped_count: +## 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", "") 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", "") + 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")