fix(@projects/@magic-civilization): 🐛 adjust golden test paths for repo layout
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
57786a1a0e
commit
fb93ed808d
3 changed files with 313 additions and 231 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue