diff --git a/public/games/age-of-dwarves/guide/src/simulation/golden.test.ts b/public/games/age-of-dwarves/guide/src/simulation/golden.test.ts index fde42605..bb3d76be 100644 --- a/public/games/age-of-dwarves/guide/src/simulation/golden.test.ts +++ b/public/games/age-of-dwarves/guide/src/simulation/golden.test.ts @@ -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( vector.expected_output.distances.map((e) => [e.name, e.distance]), ) diff --git a/src/game/engine/tests/ffi/golden_vector_loader.gd b/src/game/engine/tests/ffi/golden_vector_loader.gd index 096c0434..7a3878d4 100644 --- a/src/game/engine/tests/ffi/golden_vector_loader.gd +++ b/src/game/engine/tests/ffi/golden_vector_loader.gd @@ -25,14 +25,16 @@ static func vectors_dir() -> String: var res_abs: String = ProjectSettings.globalize_path("res://") # `res://` resolves to `/src/game/`. Walk up to repo and # back down into the simulator tests tree. - var 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 diff --git a/src/game/engine/tests/ffi/test_golden_combat.gd b/src/game/engine/tests/ffi/test_golden_combat.gd new file mode 100644 index 00000000..ed9e6660 --- /dev/null +++ b/src/game/engine/tests/ffi/test_golden_combat.gd @@ -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", "") + 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") diff --git a/src/game/engine/tests/ffi/test_golden_economy.gd b/src/game/engine/tests/ffi/test_golden_economy.gd new file mode 100644 index 00000000..df61cf37 --- /dev/null +++ b/src/game/engine/tests/ffi/test_golden_economy.gd @@ -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: +## expected_output.treasury.equipped_count: + +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", "") + 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 + ) diff --git a/src/game/engine/tests/ffi/test_golden_hex.gd b/src/game/engine/tests/ffi/test_golden_hex.gd new file mode 100644 index 00000000..15ec5a7b --- /dev/null +++ b/src/game/engine/tests/ffi/test_golden_hex.gd @@ -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", "") + 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", "") + 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) diff --git a/src/simulator/crates/mc-combat/tests/golden.rs b/src/simulator/crates/mc-combat/tests/golden.rs new file mode 100644 index 00000000..feeb6381 --- /dev/null +++ b/src/simulator/crates/mc-combat/tests/golden.rs @@ -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, + defender_keywords: Vec, + attacker_bonuses: CombatBonusesDto, + defender_bonuses: CombatBonusesDto, + city_hp: Option, + city_wall_tier: i32, + city_has_garrison: bool, + attacker_is_siege: bool, +} + +#[derive(Debug, Deserialize)] +struct CombatInputs { + cases: Vec, +} + +#[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, +} + +#[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::(&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 { + 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::>(), + expected_names.keys().collect::>(), + "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, + ); + } +} diff --git a/src/simulator/crates/mc-combat/tests/probe.rs b/src/simulator/crates/mc-combat/tests/probe.rs deleted file mode 100644 index 29ec2566..00000000 --- a/src/simulator/crates/mc-combat/tests/probe.rs +++ /dev/null @@ -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)); -} diff --git a/src/simulator/crates/mc-economy/tests/golden.rs b/src/simulator/crates/mc-economy/tests/golden.rs index 9ef607ff..145d798a 100644 --- a/src/simulator/crates/mc-economy/tests/golden.rs +++ b/src/simulator/crates/mc-economy/tests/golden.rs @@ -88,24 +88,12 @@ struct TurnVector { expected_output: TurnExpected, } -fn load_vector(name: &str) -> TurnVector { +fn load_vector(name: &str) -> Result { 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::(&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::(&body) + .map_err(|e| format!("parsing fixture {}: {}", path.display(), e)) } fn to_inputs(case: &TurnCase) -> (Vec, Vec) { @@ -130,17 +118,18 @@ fn to_inputs(case: &TurnCase) -> (Vec, Vec) } #[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(()) }