feat(@projects/@magic-civilization): ✨ add golden test suite for engine and economy modules
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
36db3a2d5e
commit
57786a1a0e
8 changed files with 808 additions and 152 deletions
|
|
@ -111,8 +111,8 @@ interface SkipResult {
|
|||
|
||||
type ConsumerResult = void | SkipResult
|
||||
|
||||
function isSkip(result: ConsumerResult): result is SkipResult {
|
||||
return result !== undefined && (result as SkipResult).skipped === true
|
||||
function isSkip(result: ConsumerResult | Consumer): result is SkipResult {
|
||||
return typeof result === 'object' && result !== null && (result as SkipResult).skipped === true
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -138,6 +138,18 @@ function consumeHexDistance(
|
|||
}
|
||||
|
||||
const fn = wasmModule[found] as (q1: number, r1: number, q2: number, r2: number) => number
|
||||
|
||||
// Case-name coverage guard — mirrors the Rust native consumer's
|
||||
// golden_hex_distance_case_coverage test. Sorted arrays stand in for a
|
||||
// BTreeMap: any drift between input.cases and expected_output.distances
|
||||
// (missing entries either direction) fails loudly before assertions run.
|
||||
const inputNames = vector.input.cases.map((c) => c.name).sort()
|
||||
const expectedNames = vector.expected_output.distances.map((e) => e.name).sort()
|
||||
expect(inputNames, 'input.cases and expected_output.distances name sets must match').toEqual(
|
||||
expectedNames,
|
||||
)
|
||||
expect(inputNames.length, 'golden fixture must contain at least one case').toBeGreaterThan(0)
|
||||
|
||||
const expectedByName = new Map<string, number>(
|
||||
vector.expected_output.distances.map((e) => [e.name, e.distance]),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,14 +25,16 @@ static func vectors_dir() -> String:
|
|||
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 candidate: String = res_abs.path_join("../../simulator/tests/golden/vectors").simplify_path()
|
||||
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 candidate2: String = res_abs.path_join("../../../src/simulator/tests/golden/vectors").simplify_path()
|
||||
var rel2: String = "../../../src/simulator/tests/golden/vectors"
|
||||
var candidate2: String = res_abs.path_join(rel2).simplify_path()
|
||||
if DirAccess.dir_exists_absolute(candidate2):
|
||||
_vectors_dir_cache = candidate2
|
||||
return candidate2
|
||||
|
|
|
|||
130
src/game/engine/tests/ffi/test_golden_combat.gd
Normal file
130
src/game/engine/tests/ffi/test_golden_combat.gd
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
extends GutTest
|
||||
## Golden-vector parity test for combat resolve (mc-combat domain).
|
||||
##
|
||||
## Drives the `mc-combat__resolve*.json` fixtures through the real
|
||||
## `GdCombatResolver` GDExtension class and asserts the post-combat
|
||||
## state matches the canonical Rust output bitwise.
|
||||
##
|
||||
## If the GDExtension `.so` / `.dylib` is stale or absent (common on
|
||||
## 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).
|
||||
|
||||
const GoldenVectorLoaderScript: GDScript = preload(
|
||||
"res://engine/tests/ffi/golden_vector_loader.gd"
|
||||
)
|
||||
|
||||
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.
|
||||
const INT_KEYS: Array[String] = [
|
||||
"defender_damage",
|
||||
"attacker_damage",
|
||||
"attacker_hp",
|
||||
"defender_hp",
|
||||
"city_damage",
|
||||
"city_hp_remaining",
|
||||
"attacker_xp",
|
||||
"defender_xp",
|
||||
"life_drain_heal",
|
||||
]
|
||||
const BOOL_KEYS: Array[String] = ["attacker_killed", "defender_killed"]
|
||||
|
||||
|
||||
func test_combat_resolve_matches_golden_vectors() -> void:
|
||||
if not GoldenVectorLoaderScript.gdext_class_available(GDEXT_CLASS):
|
||||
push_warning(
|
||||
"%s GDExtension not registered — skipping combat golden test. "
|
||||
% GDEXT_CLASS
|
||||
+ "Run `bash src/simulator/build-gdext.sh` on the RUN host."
|
||||
)
|
||||
pass_test("skipped: GDExtension absent")
|
||||
return
|
||||
|
||||
var vectors: Array[Dictionary] = GoldenVectorLoaderScript.load_vectors(VECTOR_PREFIX)
|
||||
if vectors.is_empty():
|
||||
push_warning(
|
||||
"no '%s*.json' vectors found — skipping (sibling fixture agent may not have run yet)"
|
||||
% VECTOR_PREFIX
|
||||
)
|
||||
pass_test("skipped: no fixtures present")
|
||||
return
|
||||
|
||||
var resolver: RefCounted = ClassDB.instantiate(GDEXT_CLASS)
|
||||
if resolver == null:
|
||||
push_warning("ClassDB.instantiate('%s') returned null" % GDEXT_CLASS)
|
||||
pass_test("skipped: GDExtension 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
|
||||
var expected: Dictionary = vector.get("expected_output", {}) as Dictionary
|
||||
if input.is_empty() or expected.is_empty():
|
||||
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()
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
|
||||
gut.p("mc-combat resolve: %d vector(s) run through GDExtension" % total_cases)
|
||||
|
||||
|
||||
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.
|
||||
if not GoldenVectorLoaderScript.gdext_class_available(GDEXT_CLASS):
|
||||
push_warning("%s not registered — skipping xp_threshold sanity" % GDEXT_CLASS)
|
||||
pass_test("skipped: GDExtension absent")
|
||||
return
|
||||
var resolver: RefCounted = ClassDB.instantiate(GDEXT_CLASS)
|
||||
if resolver == null:
|
||||
pass_test("skipped: GDExtension instantiation failed")
|
||||
return
|
||||
var threshold_l1: int = int(resolver.xp_threshold(1))
|
||||
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")
|
||||
229
src/game/engine/tests/ffi/test_golden_economy.gd
Normal file
229
src/game/engine/tests/ffi/test_golden_economy.gd
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
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.
|
||||
##
|
||||
## 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.
|
||||
##
|
||||
## Supported operation schema (best-effort; unknown ops → skip vector
|
||||
## with push_warning so newer fixture shapes don't cause false failures):
|
||||
##
|
||||
## 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>
|
||||
|
||||
const GoldenVectorLoaderScript: GDScript = preload(
|
||||
"res://engine/tests/ffi/golden_vector_loader.gd"
|
||||
)
|
||||
|
||||
const VECTOR_PREFIX: String = "mc-economy__turn"
|
||||
const STOCKPILE_CLASS: String = "GdStockpile"
|
||||
const TREASURY_CLASS: String = "GdTreasury"
|
||||
|
||||
|
||||
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
|
||||
|
||||
var vectors: Array[Dictionary] = GoldenVectorLoaderScript.load_vectors(VECTOR_PREFIX)
|
||||
if vectors.is_empty():
|
||||
push_warning(
|
||||
"no '%s*.json' vectors found — skipping (sibling fixture agent may not have run yet)"
|
||||
% VECTOR_PREFIX
|
||||
)
|
||||
pass_test("skipped: no fixtures present")
|
||||
return
|
||||
|
||||
var vectors_exercised: int = 0
|
||||
for vector: Dictionary in vectors:
|
||||
var vector_name: String = vector.get("vector_name", "<unnamed>")
|
||||
var input: Dictionary = vector.get("input", {}) as Dictionary
|
||||
var expected: Dictionary = vector.get("expected_output", {}) as Dictionary
|
||||
if input.is_empty() or expected.is_empty():
|
||||
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:
|
||||
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
|
||||
)
|
||||
continue
|
||||
|
||||
vectors_exercised += 1
|
||||
|
||||
gut.p("mc-economy turn: %d vector(s) exercised through GDExtension" % vectors_exercised)
|
||||
|
||||
|
||||
## ── op replay ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
func _replay_stockpile_ops(
|
||||
stockpile: 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 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:
|
||||
push_warning(
|
||||
"vector '%s': expected_output.stockpile must be Dictionary" % vector_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]
|
||||
)
|
||||
|
||||
|
||||
func _assert_treasury_matches(
|
||||
treasury: RefCounted, expected: Dictionary, vector_name: String
|
||||
) -> void:
|
||||
if not expected.has("treasury"):
|
||||
return
|
||||
if typeof(expected["treasury"]) != TYPE_DICTIONARY:
|
||||
push_warning(
|
||||
"vector '%s': expected_output.treasury must be Dictionary" % vector_name
|
||||
)
|
||||
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
|
||||
)
|
||||
102
src/game/engine/tests/ffi/test_golden_hex.gd
Normal file
102
src/game/engine/tests/ffi/test_golden_hex.gd
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
extends GutTest
|
||||
## Golden-vector parity test for hex distance (mc-core domain).
|
||||
##
|
||||
## Drives the `mc-core__hex_distance*.json` fixtures through Godot's
|
||||
## hex API and asserts the distances match the canonical Rust output.
|
||||
##
|
||||
## GDExtension coverage note:
|
||||
## The current GDExtension surface (`src/simulator/api-gdext/src/lib.rs`)
|
||||
## exposes NO `GdHexUtils` class — hex math is only in the pure-Rust
|
||||
## `mc-core` crate plus the GDScript port at `res://engine/src/map/hex_utils.gd`.
|
||||
## Until a `GdHexUtils` wrapper is added, this consumer validates the
|
||||
## GDScript implementation against the Rust-generated fixtures. This still
|
||||
## catches logic drift between the two implementations; it just does not
|
||||
## cross the FFI boundary. When a `GdHexUtils` class is registered, swap
|
||||
## the `HexUtilsScript.hex_distance` call for the GDExt method and remove
|
||||
## this note.
|
||||
|
||||
const GoldenVectorLoaderScript: GDScript = preload(
|
||||
"res://engine/tests/ffi/golden_vector_loader.gd"
|
||||
)
|
||||
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
|
||||
|
||||
const VECTOR_PREFIX: String = "mc-core__hex_distance"
|
||||
|
||||
|
||||
func test_hex_distance_matches_golden_vectors() -> void:
|
||||
var vectors: Array[Dictionary] = GoldenVectorLoaderScript.load_vectors(VECTOR_PREFIX)
|
||||
if vectors.is_empty():
|
||||
push_warning(
|
||||
"no '%s*.json' vectors found — skipping (sibling fixture agent may not have run yet)"
|
||||
% VECTOR_PREFIX
|
||||
)
|
||||
pass_test("skipped: no fixtures present")
|
||||
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
|
||||
var expected: Dictionary = vector.get("expected_output", {}) as Dictionary
|
||||
|
||||
var cases_raw: Array = input.get("cases", []) as Array
|
||||
var expected_distances_raw: Array = expected.get("distances", []) as Array
|
||||
assert_eq(
|
||||
cases_raw.size(),
|
||||
expected_distances_raw.size(),
|
||||
"vector '%s': input.cases count must match expected_output.distances count" % vector_name
|
||||
)
|
||||
if cases_raw.size() != expected_distances_raw.size():
|
||||
continue
|
||||
|
||||
# Build expected lookup by case name — defends against reordering.
|
||||
var expected_by_name: Dictionary = {}
|
||||
for i: int in expected_distances_raw.size():
|
||||
var entry: Dictionary = expected_distances_raw[i] as Dictionary
|
||||
expected_by_name[entry.get("name", "")] = int(entry.get("distance", -1))
|
||||
|
||||
for i: int in cases_raw.size():
|
||||
var case_dict: Dictionary = cases_raw[i] as Dictionary
|
||||
var case_name: String = case_dict.get("name", "<unnamed>")
|
||||
var from_dict: Dictionary = case_dict.get("from", {}) as Dictionary
|
||||
var to_dict: Dictionary = case_dict.get("to", {}) as Dictionary
|
||||
|
||||
var from_vec: Vector2i = Vector2i(
|
||||
int(from_dict.get("q", 0)), int(from_dict.get("r", 0))
|
||||
)
|
||||
var to_vec: Vector2i = Vector2i(
|
||||
int(to_dict.get("q", 0)), int(to_dict.get("r", 0))
|
||||
)
|
||||
|
||||
var actual: int = HexUtilsScript.hex_distance(from_vec, to_vec)
|
||||
var expected_dist: int = int(expected_by_name.get(case_name, -999))
|
||||
assert_eq(
|
||||
actual,
|
||||
expected_dist,
|
||||
"vector '%s' case '%s': distance (%d,%d)→(%d,%d)"
|
||||
% [
|
||||
vector_name,
|
||||
case_name,
|
||||
from_vec.x,
|
||||
from_vec.y,
|
||||
to_vec.x,
|
||||
to_vec.y,
|
||||
]
|
||||
)
|
||||
total_cases += 1
|
||||
|
||||
gut.p("mc-core hex_distance: %d cases across %d vector(s)" % [total_cases, vectors.size()])
|
||||
|
||||
|
||||
func test_vectors_dir_resolves() -> void:
|
||||
## Meta-test: verifies the path resolution pattern works. If this
|
||||
## fails, every other test in this suite will silently skip.
|
||||
var dir: String = GoldenVectorLoaderScript.vectors_dir()
|
||||
if dir == "":
|
||||
push_warning(
|
||||
"golden vectors dir did not resolve. res:// globalized to '%s'"
|
||||
% ProjectSettings.globalize_path("res://")
|
||||
)
|
||||
pass_test("skipped: vectors dir absent (fixture agents may not have run)")
|
||||
return
|
||||
assert_true(DirAccess.dir_exists_absolute(dir), "resolved vectors dir must exist: %s" % dir)
|
||||
304
src/simulator/crates/mc-combat/tests/golden.rs
Normal file
304
src/simulator/crates/mc-combat/tests/golden.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
//! Golden vector harness — native Rust consumer.
|
||||
//!
|
||||
//! Reads canonical fixtures from `src/simulator/tests/golden/vectors/mc-combat__*.json`
|
||||
//! and asserts the Rust source-of-truth implementation produces the exact
|
||||
//! expected output. This is one of three consumers; the WASM and GDExtension
|
||||
//! consumers must match bitwise. See `src/simulator/tests/golden/README.md`.
|
||||
//!
|
||||
//! Invocation:
|
||||
//! cargo test -p mc-combat --test golden
|
||||
|
||||
#![allow(clippy::missing_docs_in_private_items)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use mc_combat::{
|
||||
CombatBonuses, CombatOutcome, CombatParams, CombatResolver, CombatResult, CombatType,
|
||||
Keyword, UnitStats,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
const FIXTURE_RESOLVE_BASIC: &str = "mc-combat__resolve_basic.json";
|
||||
|
||||
/// Epsilon for float comparison. All currently-asserted output fields are
|
||||
/// integer-typed (damage, HP, XP are i32), so this is unused today — reserved
|
||||
/// for forward-compat when the resolver exposes f32 metadata (e.g. strength
|
||||
/// ratios, hit probabilities) to downstream consumers. Kept here so the
|
||||
/// WASM and GDExt consumers reuse the same tolerance value.
|
||||
#[allow(dead_code)]
|
||||
const FLOAT_EPSILON: f64 = 1e-9;
|
||||
|
||||
/// Locate `src/simulator/tests/golden/vectors/` relative to the mc-combat crate.
|
||||
///
|
||||
/// `CARGO_MANIFEST_DIR` points at `src/simulator/crates/mc-combat/`, so we walk
|
||||
/// up two levels to reach `src/simulator/` and then descend into the fixtures
|
||||
/// directory.
|
||||
fn fixtures_dir() -> PathBuf {
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
manifest
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.map(|p| p.join("tests").join("golden").join("vectors"))
|
||||
.unwrap_or_else(|| PathBuf::from("src/simulator/tests/golden/vectors"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct UnitStatsDto {
|
||||
hp: i32,
|
||||
max_hp: i32,
|
||||
attack: i32,
|
||||
defense: i32,
|
||||
ranged_attack: i32,
|
||||
range: i32,
|
||||
movement: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct CombatBonusesDto {
|
||||
flanking_allies: i32,
|
||||
support_units: i32,
|
||||
terrain_defense: f32,
|
||||
fortification: f32,
|
||||
city_wall_bonus: f32,
|
||||
city_defense_percent: f32,
|
||||
river_crossing: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CombatCase {
|
||||
name: String,
|
||||
combat_type: String,
|
||||
attacker: UnitStatsDto,
|
||||
defender: UnitStatsDto,
|
||||
attacker_keywords: Vec<String>,
|
||||
defender_keywords: Vec<String>,
|
||||
attacker_bonuses: CombatBonusesDto,
|
||||
defender_bonuses: CombatBonusesDto,
|
||||
city_hp: Option<i32>,
|
||||
city_wall_tier: i32,
|
||||
city_has_garrison: bool,
|
||||
attacker_is_siege: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CombatInputs {
|
||||
cases: Vec<CombatCase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct ExpectedResult {
|
||||
name: String,
|
||||
defender_damage: i32,
|
||||
attacker_damage: i32,
|
||||
attacker_outcome: String,
|
||||
defender_outcome: String,
|
||||
attacker_hp: i32,
|
||||
defender_hp: i32,
|
||||
city_damage: i32,
|
||||
city_hp_remaining: i32,
|
||||
attacker_xp: i32,
|
||||
defender_xp: i32,
|
||||
life_drain_heal: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CombatExpected {
|
||||
results: Vec<ExpectedResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CombatVector {
|
||||
schema_version: u32,
|
||||
vector_name: String,
|
||||
input: CombatInputs,
|
||||
expected_output: CombatExpected,
|
||||
}
|
||||
|
||||
fn load_vector(name: &str) -> CombatVector {
|
||||
let path = fixtures_dir().join(name);
|
||||
let body = std::fs::read_to_string(&path).expect("reading golden fixture from disk");
|
||||
serde_json::from_str::<CombatVector>(&body).expect("parsing golden fixture JSON")
|
||||
}
|
||||
|
||||
fn stats_from(dto: &UnitStatsDto) -> UnitStats {
|
||||
UnitStats {
|
||||
hp: dto.hp,
|
||||
max_hp: dto.max_hp,
|
||||
attack: dto.attack,
|
||||
defense: dto.defense,
|
||||
ranged_attack: dto.ranged_attack,
|
||||
range: dto.range,
|
||||
movement: dto.movement,
|
||||
}
|
||||
}
|
||||
|
||||
fn bonuses_from(dto: &CombatBonusesDto) -> CombatBonuses {
|
||||
CombatBonuses {
|
||||
flanking_allies: dto.flanking_allies,
|
||||
support_units: dto.support_units,
|
||||
terrain_defense: dto.terrain_defense,
|
||||
fortification: dto.fortification,
|
||||
city_wall_bonus: dto.city_wall_bonus,
|
||||
city_defense_percent: dto.city_defense_percent,
|
||||
river_crossing: dto.river_crossing,
|
||||
}
|
||||
}
|
||||
|
||||
fn combat_type_from(s: &str) -> CombatType {
|
||||
match s {
|
||||
"melee" => CombatType::Melee,
|
||||
"ranged" => CombatType::Ranged,
|
||||
"siege" => CombatType::Siege,
|
||||
other => {
|
||||
// Guarded by `schema_version` test + JSON schema; reaching this
|
||||
// means the fixture is malformed and needs investigation.
|
||||
assert!(false, "unknown combat_type '{}'", other);
|
||||
CombatType::Melee
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn keywords_from(items: &[String]) -> Vec<Keyword> {
|
||||
items
|
||||
.iter()
|
||||
.map(|s| Keyword::from_str(s).expect("golden fixture references unknown keyword"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn outcome_str(o: CombatOutcome) -> &'static str {
|
||||
match o {
|
||||
CombatOutcome::Survived => "survived",
|
||||
CombatOutcome::Killed => "killed",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_params(case: &CombatCase) -> CombatParams {
|
||||
CombatParams {
|
||||
attacker: stats_from(&case.attacker),
|
||||
defender: stats_from(&case.defender),
|
||||
combat_type: combat_type_from(&case.combat_type),
|
||||
attacker_keywords: keywords_from(&case.attacker_keywords),
|
||||
defender_keywords: keywords_from(&case.defender_keywords),
|
||||
attacker_bonuses: bonuses_from(&case.attacker_bonuses),
|
||||
defender_bonuses: bonuses_from(&case.defender_bonuses),
|
||||
city_hp: case.city_hp,
|
||||
city_wall_tier: case.city_wall_tier,
|
||||
city_has_garrison: case.city_has_garrison,
|
||||
attacker_is_siege: case.attacker_is_siege,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_combat_resolve_schema_version() {
|
||||
let vector = load_vector(FIXTURE_RESOLVE_BASIC);
|
||||
assert_eq!(vector.schema_version, 1, "unsupported schema_version");
|
||||
assert_eq!(vector.vector_name, "resolve_basic_suite");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_combat_resolve_case_coverage() {
|
||||
// Guards against fixture/consumer drift — every input case needs an
|
||||
// expected entry and vice versa, with identical names.
|
||||
let vector = load_vector(FIXTURE_RESOLVE_BASIC);
|
||||
|
||||
let input_names: BTreeMap<&str, ()> = vector
|
||||
.input
|
||||
.cases
|
||||
.iter()
|
||||
.map(|c| (c.name.as_str(), ()))
|
||||
.collect();
|
||||
let expected_names: BTreeMap<&str, ()> = vector
|
||||
.expected_output
|
||||
.results
|
||||
.iter()
|
||||
.map(|e| (e.name.as_str(), ()))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
input_names.keys().collect::<Vec<_>>(),
|
||||
expected_names.keys().collect::<Vec<_>>(),
|
||||
"input and expected_output case names diverge",
|
||||
);
|
||||
assert!(
|
||||
!input_names.is_empty(),
|
||||
"golden fixture must contain at least one case",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_combat_resolve_matches_expected() {
|
||||
let vector = load_vector(FIXTURE_RESOLVE_BASIC);
|
||||
|
||||
let expected: BTreeMap<&str, ExpectedResult> = vector
|
||||
.expected_output
|
||||
.results
|
||||
.iter()
|
||||
.map(|e| (e.name.as_str(), e.clone()))
|
||||
.collect();
|
||||
|
||||
for case in &vector.input.cases {
|
||||
let want = expected
|
||||
.get(case.name.as_str())
|
||||
.expect("every input case must have a matching expected entry");
|
||||
let got: CombatResult = CombatResolver::resolve(&build_params(case));
|
||||
|
||||
assert_eq!(
|
||||
got.defender_damage, want.defender_damage,
|
||||
"case '{}' defender_damage mismatch",
|
||||
case.name,
|
||||
);
|
||||
assert_eq!(
|
||||
got.attacker_damage, want.attacker_damage,
|
||||
"case '{}' attacker_damage mismatch",
|
||||
case.name,
|
||||
);
|
||||
assert_eq!(
|
||||
outcome_str(got.attacker_outcome),
|
||||
want.attacker_outcome,
|
||||
"case '{}' attacker_outcome mismatch",
|
||||
case.name,
|
||||
);
|
||||
assert_eq!(
|
||||
outcome_str(got.defender_outcome),
|
||||
want.defender_outcome,
|
||||
"case '{}' defender_outcome mismatch",
|
||||
case.name,
|
||||
);
|
||||
assert_eq!(
|
||||
got.attacker_hp, want.attacker_hp,
|
||||
"case '{}' attacker_hp mismatch",
|
||||
case.name,
|
||||
);
|
||||
assert_eq!(
|
||||
got.defender_hp, want.defender_hp,
|
||||
"case '{}' defender_hp mismatch",
|
||||
case.name,
|
||||
);
|
||||
assert_eq!(
|
||||
got.city_damage, want.city_damage,
|
||||
"case '{}' city_damage mismatch",
|
||||
case.name,
|
||||
);
|
||||
assert_eq!(
|
||||
got.city_hp_remaining, want.city_hp_remaining,
|
||||
"case '{}' city_hp_remaining mismatch",
|
||||
case.name,
|
||||
);
|
||||
assert_eq!(
|
||||
got.attacker_xp, want.attacker_xp,
|
||||
"case '{}' attacker_xp mismatch",
|
||||
case.name,
|
||||
);
|
||||
assert_eq!(
|
||||
got.defender_xp, want.defender_xp,
|
||||
"case '{}' defender_xp mismatch",
|
||||
case.name,
|
||||
);
|
||||
assert_eq!(
|
||||
got.life_drain_heal, want.life_drain_heal,
|
||||
"case '{}' life_drain_heal mismatch",
|
||||
case.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
//! Temporary probe — prints real resolver outputs so the golden vector JSON
|
||||
//! can be populated with bitwise-accurate expected values. Removed once the
|
||||
//! golden.rs consumer test is green.
|
||||
|
||||
use mc_combat::{
|
||||
CombatBonuses, CombatParams, CombatResolver, CombatType, Keyword, UnitStats,
|
||||
};
|
||||
|
||||
fn archer_t3() -> UnitStats {
|
||||
UnitStats {
|
||||
hp: 40,
|
||||
max_hp: 40,
|
||||
attack: 5,
|
||||
defense: 1,
|
||||
ranged_attack: 18,
|
||||
range: 2,
|
||||
movement: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn warrior_t2() -> UnitStats {
|
||||
UnitStats {
|
||||
hp: 60,
|
||||
max_hp: 60,
|
||||
attack: 12,
|
||||
defense: 1,
|
||||
ranged_attack: 0,
|
||||
range: 0,
|
||||
movement: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn strong_attacker() -> UnitStats {
|
||||
UnitStats {
|
||||
hp: 100,
|
||||
max_hp: 100,
|
||||
attack: 40,
|
||||
defense: 5,
|
||||
ranged_attack: 0,
|
||||
range: 0,
|
||||
movement: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn weak_defender() -> UnitStats {
|
||||
UnitStats {
|
||||
hp: 20,
|
||||
max_hp: 20,
|
||||
attack: 10,
|
||||
defense: 0,
|
||||
ranged_attack: 0,
|
||||
range: 0,
|
||||
movement: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn dump(label: &str, r: &mc_combat::CombatResult) {
|
||||
println!("==== {} ====", label);
|
||||
println!("defender_damage: {}", r.defender_damage);
|
||||
println!("attacker_damage: {}", r.attacker_damage);
|
||||
println!("attacker_outcome: {:?}", r.attacker_outcome);
|
||||
println!("defender_outcome: {:?}", r.defender_outcome);
|
||||
println!("attacker_hp: {}", r.attacker_hp);
|
||||
println!("defender_hp: {}", r.defender_hp);
|
||||
println!("city_damage: {}", r.city_damage);
|
||||
println!("city_hp_remaining: {}", r.city_hp_remaining);
|
||||
println!("attacker_xp: {}", r.attacker_xp);
|
||||
println!("defender_xp: {}", r.defender_xp);
|
||||
println!("life_drain_heal: {}", r.life_drain_heal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_emit_expected_values() {
|
||||
// HAPPY: archer T3 (ranged) vs warrior T2 on grassland, no modifiers.
|
||||
let happy = CombatParams {
|
||||
attacker: archer_t3(),
|
||||
defender: warrior_t2(),
|
||||
combat_type: CombatType::Ranged,
|
||||
..Default::default()
|
||||
};
|
||||
dump("happy_archer_vs_warrior_ranged_grassland", &CombatResolver::resolve(&happy));
|
||||
|
||||
// EDGE: fortified warrior defender in hills (terrain 0.50, fort 0.50), melee.
|
||||
let edge = CombatParams {
|
||||
attacker: warrior_t2(),
|
||||
defender: warrior_t2(),
|
||||
combat_type: CombatType::Melee,
|
||||
defender_bonuses: CombatBonuses {
|
||||
terrain_defense: 0.50,
|
||||
fortification: 0.50,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
dump("edge_fortified_defender_in_hills", &CombatResolver::resolve(&edge));
|
||||
|
||||
// STRESS: first_strike one-round kill (strong atk, weak def, melee).
|
||||
let stress = CombatParams {
|
||||
attacker: strong_attacker(),
|
||||
defender: weak_defender(),
|
||||
combat_type: CombatType::Melee,
|
||||
attacker_keywords: vec![Keyword::FirstStrike],
|
||||
..Default::default()
|
||||
};
|
||||
dump("stress_first_strike_one_round_kill", &CombatResolver::resolve(&stress));
|
||||
}
|
||||
|
|
@ -88,24 +88,12 @@ struct TurnVector {
|
|||
expected_output: TurnExpected,
|
||||
}
|
||||
|
||||
fn load_vector(name: &str) -> TurnVector {
|
||||
fn load_vector(name: &str) -> Result<TurnVector, String> {
|
||||
let path = fixtures_dir().join(name);
|
||||
let body = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// Surface the error via a failing assertion instead of panic!.
|
||||
// The message includes the absolute path so CI logs are actionable.
|
||||
assert!(false, "reading fixture {}: {}", path.display(), e);
|
||||
unreachable!()
|
||||
}
|
||||
};
|
||||
match serde_json::from_str::<TurnVector>(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
assert!(false, "parsing fixture {}: {}", path.display(), e);
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
let body = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("reading fixture {}: {}", path.display(), e))?;
|
||||
serde_json::from_str::<TurnVector>(&body)
|
||||
.map_err(|e| format!("parsing fixture {}: {}", path.display(), e))
|
||||
}
|
||||
|
||||
fn to_inputs(case: &TurnCase) -> (Vec<CityGoldInput>, Vec<UnitMaintenanceInput>) {
|
||||
|
|
@ -130,17 +118,18 @@ fn to_inputs(case: &TurnCase) -> (Vec<CityGoldInput>, Vec<UnitMaintenanceInput>)
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn golden_economy_schema_version() {
|
||||
let vector = load_vector(FIXTURE);
|
||||
fn golden_economy_schema_version() -> Result<(), String> {
|
||||
let vector = load_vector(FIXTURE)?;
|
||||
assert_eq!(vector.schema_version, 1, "unsupported schema_version");
|
||||
assert_eq!(vector.vector_name, "turn_basic_suite");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_economy_case_coverage() {
|
||||
fn golden_economy_case_coverage() -> Result<(), String> {
|
||||
// Guard against fixture/consumer drift — every input case needs an
|
||||
// expected entry and vice versa, with identical names.
|
||||
let vector = load_vector(FIXTURE);
|
||||
let vector = load_vector(FIXTURE)?;
|
||||
|
||||
let input_names: BTreeMap<&str, ()> = vector
|
||||
.input
|
||||
|
|
@ -165,11 +154,12 @@ fn golden_economy_case_coverage() {
|
|||
3,
|
||||
"fixture must cover happy + edge + stress cases",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_economy_matches_expected() {
|
||||
let vector = load_vector(FIXTURE);
|
||||
fn golden_economy_matches_expected() -> Result<(), String> {
|
||||
let vector = load_vector(FIXTURE)?;
|
||||
|
||||
// BTreeMap keeps diagnostics deterministic if a field ever regresses.
|
||||
let expected: BTreeMap<&str, ExpectedResult> = vector
|
||||
|
|
@ -180,13 +170,9 @@ fn golden_economy_matches_expected() {
|
|||
.collect();
|
||||
|
||||
for case in &vector.input.cases {
|
||||
let want = match expected.get(case.name.as_str()) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
assert!(false, "case '{}' missing from expected_output", case.name);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let want = expected
|
||||
.get(case.name.as_str())
|
||||
.ok_or_else(|| format!("case '{}' missing from expected_output", case.name))?;
|
||||
|
||||
let (cities, units) = to_inputs(case);
|
||||
let result = process_gold(&cities, &units);
|
||||
|
|
@ -230,7 +216,7 @@ fn golden_economy_matches_expected() {
|
|||
);
|
||||
|
||||
// Treasury is not owned by process_gold — it's threaded by the caller.
|
||||
// The fixture asserts the contract treasury_after = treasury_before + net_gold.
|
||||
// Contract: treasury_after = treasury_before + net_gold.
|
||||
let treasury_after = case.treasury_before + result.net_gold;
|
||||
assert_eq!(
|
||||
treasury_after, want.treasury_after,
|
||||
|
|
@ -238,26 +224,22 @@ fn golden_economy_matches_expected() {
|
|||
case.name, case.treasury_before, result.net_gold,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_economy_edge_case_zeroes() {
|
||||
fn golden_economy_edge_case_zeroes() -> Result<(), String> {
|
||||
// Sanity pin: the edge-case entry must be all-zero and must not divide
|
||||
// by city count. This guards against regressions where an average-based
|
||||
// by city count. Guards against regressions where an average-based
|
||||
// stat sneaks into process_gold.
|
||||
let vector = load_vector(FIXTURE);
|
||||
let edge = match vector
|
||||
let vector = load_vector(FIXTURE)?;
|
||||
let edge = vector
|
||||
.input
|
||||
.cases
|
||||
.iter()
|
||||
.find(|c| c.name == "edge_empty_empire")
|
||||
{
|
||||
Some(c) => c,
|
||||
None => {
|
||||
assert!(false, "edge_empty_empire case missing from fixture");
|
||||
return;
|
||||
}
|
||||
};
|
||||
.ok_or_else(|| "edge_empty_empire case missing from fixture".to_string())?;
|
||||
|
||||
assert!(edge.cities.is_empty(), "edge case must have zero cities");
|
||||
assert!(edge.units.is_empty(), "edge case must have zero units");
|
||||
|
||||
|
|
@ -270,4 +252,5 @@ fn golden_economy_edge_case_zeroes() {
|
|||
assert_eq!(result.unit_upkeep, 0);
|
||||
assert_eq!(result.city_income, 0);
|
||||
assert_eq!(result.tile_income, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue