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:
Natalie 2026-04-17 00:34:32 -07:00
parent 36db3a2d5e
commit 57786a1a0e
8 changed files with 808 additions and 152 deletions

View file

@ -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]),
)

View file

@ -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

View 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")

View 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
)

View 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)

View 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,
);
}
}

View file

@ -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));
}

View file

@ -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(())
}