From afce3602f7d7223d20660ff39ffa4d5d0c33a06c Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 8 May 2026 06:15:44 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20new=20rewards=20to=20buildings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- public/resources/buildings/airfield.json | 1 + public/resources/buildings/ancestor_hall.json | 7 +- .../buildings/ancestral_armoury.json | 8 +- .../buildings/ancient_lighthouse.json | 7 +- public/resources/buildings/bathhouse.json | 5 +- public/resources/buildings/carved_hall.json | 6 +- public/resources/buildings/copper_mint.json | 6 +- public/resources/buildings/courthouse.json | 5 +- .../buildings/deep_mine_network.json | 6 +- public/resources/buildings/deep_vault.json | 6 +- public/resources/buildings/depot.json | 6 +- public/resources/buildings/eternal_forge.json | 7 +- public/resources/buildings/first_forge.json | 6 +- public/resources/buildings/grand_gate.json | 6 +- public/resources/buildings/great_barrow.json | 6 +- public/resources/buildings/hunting_lodge.json | 6 +- public/resources/buildings/infirmary.json | 5 +- public/resources/buildings/longhouse.json | 4 + public/resources/buildings/outpost.json | 5 +- public/resources/buildings/signal_works.json | 7 +- .../resources/buildings/standing_stones.json | 6 +- public/resources/buildings/storehouse.json | 5 +- .../resources/buildings/the_cold_anvil.json | 6 +- .../resources/buildings/undermount_vault.json | 6 +- .../resources/buildings/vault_of_seals.json | 4 + public/resources/buildings/walls.json | 6 +- public/resources/buildings/zeppelin_dock.json | 2 + src/simulator/crates/mc-replay/src/awards.rs | 1 + .../mc-replay/tests/award_computation.rs | 448 ++++++++++++++++++ 29 files changed, 576 insertions(+), 23 deletions(-) create mode 100644 src/simulator/crates/mc-replay/tests/award_computation.rs diff --git a/public/resources/buildings/airfield.json b/public/resources/buildings/airfield.json index a84d5b46..22a6eac8 100644 --- a/public/resources/buildings/airfield.json +++ b/public/resources/buildings/airfield.json @@ -32,6 +32,7 @@ ] }, "produces": [ + "dwarf_gyrocopter", "dwarf_steam_bomber" ], "stack_mode": "parallel" diff --git a/public/resources/buildings/ancestor_hall.json b/public/resources/buildings/ancestor_hall.json index 32929d9b..0f05e57d 100644 --- a/public/resources/buildings/ancestor_hall.json +++ b/public/resources/buildings/ancestor_hall.json @@ -42,6 +42,11 @@ }, "stack_mode": "parallel", "requires_existing": "great_hall", - "consumes_existing": false + "consumes_existing": false, + "produces": [ + "ancestral_walker", + "mountain_king", + "dwarf_adamantine_champion" + ] } ] diff --git a/public/resources/buildings/ancestral_armoury.json b/public/resources/buildings/ancestral_armoury.json index b821c181..a64d2a4f 100644 --- a/public/resources/buildings/ancestral_armoury.json +++ b/public/resources/buildings/ancestral_armoury.json @@ -42,6 +42,12 @@ "dwarven" ] }, - "stack_mode": "parallel" + "stack_mode": "parallel", + "produces": [ + "dwarf_adamantine_champion", + "iron_halberd", + "dwarf_ascendant_sapper", + "dwarf_ascendant_engineer" + ] } ] diff --git a/public/resources/buildings/ancient_lighthouse.json b/public/resources/buildings/ancient_lighthouse.json index 2dd3cc26..16116e18 100644 --- a/public/resources/buildings/ancient_lighthouse.json +++ b/public/resources/buildings/ancient_lighthouse.json @@ -40,5 +40,10 @@ "world" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "dwarf_river_galley", + "dwarf_war_galley", + "cartographer" + ] } diff --git a/public/resources/buildings/bathhouse.json b/public/resources/buildings/bathhouse.json index 14684fed..6a977aa9 100644 --- a/public/resources/buildings/bathhouse.json +++ b/public/resources/buildings/bathhouse.json @@ -28,6 +28,9 @@ "infrastructure" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "field_medic" + ] } ] diff --git a/public/resources/buildings/carved_hall.json b/public/resources/buildings/carved_hall.json index 0989e61d..b92cdb78 100644 --- a/public/resources/buildings/carved_hall.json +++ b/public/resources/buildings/carved_hall.json @@ -40,5 +40,9 @@ "world" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "saga_singer", + "loremaster" + ] } diff --git a/public/resources/buildings/copper_mint.json b/public/resources/buildings/copper_mint.json index 3b4e5054..eb3a4dcb 100644 --- a/public/resources/buildings/copper_mint.json +++ b/public/resources/buildings/copper_mint.json @@ -40,5 +40,9 @@ "world" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "merchant", + "caravan_master" + ] } diff --git a/public/resources/buildings/courthouse.json b/public/resources/buildings/courthouse.json index 320c9484..12d35f8a 100644 --- a/public/resources/buildings/courthouse.json +++ b/public/resources/buildings/courthouse.json @@ -27,5 +27,8 @@ "infrastructure" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "hold_courier" + ] } diff --git a/public/resources/buildings/deep_mine_network.json b/public/resources/buildings/deep_mine_network.json index c8dfd315..65a90d6e 100644 --- a/public/resources/buildings/deep_mine_network.json +++ b/public/resources/buildings/deep_mine_network.json @@ -40,5 +40,9 @@ "world" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "dwarf_high_engineer", + "dwarf_grand_engineer" + ] } diff --git a/public/resources/buildings/deep_vault.json b/public/resources/buildings/deep_vault.json index 002866fe..2d4fff18 100644 --- a/public/resources/buildings/deep_vault.json +++ b/public/resources/buildings/deep_vault.json @@ -40,5 +40,9 @@ "world" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "merchant", + "loremaster" + ] } diff --git a/public/resources/buildings/depot.json b/public/resources/buildings/depot.json index dea4b4c1..7fd083b7 100644 --- a/public/resources/buildings/depot.json +++ b/public/resources/buildings/depot.json @@ -31,6 +31,10 @@ "modern" ] }, - "stack_mode": "parallel" + "stack_mode": "parallel", + "produces": [ + "motorized_artillery", + "rocket_trooper" + ] } ] diff --git a/public/resources/buildings/eternal_forge.json b/public/resources/buildings/eternal_forge.json index 3df92de2..5a4bccaa 100644 --- a/public/resources/buildings/eternal_forge.json +++ b/public/resources/buildings/eternal_forge.json @@ -40,5 +40,10 @@ "world" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "dwarf_ascendant_smith", + "dwarf_grand_smith", + "forge_titan" + ] } diff --git a/public/resources/buildings/first_forge.json b/public/resources/buildings/first_forge.json index c56569ff..b53a298e 100644 --- a/public/resources/buildings/first_forge.json +++ b/public/resources/buildings/first_forge.json @@ -36,5 +36,9 @@ "world" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "dwarf_grand_smith", + "dwarf_ascendant_smith" + ] } diff --git a/public/resources/buildings/grand_gate.json b/public/resources/buildings/grand_gate.json index 7d30863c..101a482f 100644 --- a/public/resources/buildings/grand_gate.json +++ b/public/resources/buildings/grand_gate.json @@ -35,5 +35,9 @@ "infrastructure" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "dwarf_deep_guard", + "combat_engineer" + ] } diff --git a/public/resources/buildings/great_barrow.json b/public/resources/buildings/great_barrow.json index b23b67f0..089e2731 100644 --- a/public/resources/buildings/great_barrow.json +++ b/public/resources/buildings/great_barrow.json @@ -41,5 +41,9 @@ "world" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "battle_priest", + "loremaster" + ] } diff --git a/public/resources/buildings/hunting_lodge.json b/public/resources/buildings/hunting_lodge.json index 4819dcca..c87ece26 100644 --- a/public/resources/buildings/hunting_lodge.json +++ b/public/resources/buildings/hunting_lodge.json @@ -53,5 +53,9 @@ "ecology" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "beast_scout", + "boar_scout" + ] } diff --git a/public/resources/buildings/infirmary.json b/public/resources/buildings/infirmary.json index fd5015ae..0db058f3 100644 --- a/public/resources/buildings/infirmary.json +++ b/public/resources/buildings/infirmary.json @@ -28,5 +28,8 @@ "infrastructure" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "field_medic" + ] } diff --git a/public/resources/buildings/longhouse.json b/public/resources/buildings/longhouse.json index 3f4997b6..f1a3c22d 100644 --- a/public/resources/buildings/longhouse.json +++ b/public/resources/buildings/longhouse.json @@ -34,6 +34,10 @@ ] }, "stack_mode": "amplify", + "produces": [ + "founder", + "worker" + ], "palace_tier": "longhouse", "evolves_to": "great_hall", "unlocked_by": null, diff --git a/public/resources/buildings/outpost.json b/public/resources/buildings/outpost.json index 17f7d140..eb6c209c 100644 --- a/public/resources/buildings/outpost.json +++ b/public/resources/buildings/outpost.json @@ -37,7 +37,10 @@ "military" ] }, - "produces": [], + "produces": [ + "dwarf_scout", + "foot_runner" + ], "stack_mode": "parallel", "flavor": "The hold's name carved into a post. That is enough.", "lore": "Dwarven frontier doctrine does not wait for roads. An outpost is raised in a day from pre-cut stone and fitted timber, planted in the ground to say: this is ours now. When the front moves, the outpost moves with it. Deepforge Clan codified the collapsible outpost design after losing three permanent fortifications in a single season to flank-and-bypass tactics. The lesson was not to build stronger — it was to build lighter and faster." diff --git a/public/resources/buildings/signal_works.json b/public/resources/buildings/signal_works.json index a640e338..65207ad2 100644 --- a/public/resources/buildings/signal_works.json +++ b/public/resources/buildings/signal_works.json @@ -35,6 +35,11 @@ "future" ] }, - "stack_mode": "parallel" + "stack_mode": "parallel", + "produces": [ + "emp_trooper", + "stormbolt_trooper", + "commando" + ] } ] diff --git a/public/resources/buildings/standing_stones.json b/public/resources/buildings/standing_stones.json index 53122422..85f637a2 100644 --- a/public/resources/buildings/standing_stones.json +++ b/public/resources/buildings/standing_stones.json @@ -40,5 +40,9 @@ "world" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "saga_singer", + "bard" + ] } diff --git a/public/resources/buildings/storehouse.json b/public/resources/buildings/storehouse.json index 68a3b6b3..7248020f 100644 --- a/public/resources/buildings/storehouse.json +++ b/public/resources/buildings/storehouse.json @@ -27,5 +27,8 @@ "infrastructure" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "worker" + ] } diff --git a/public/resources/buildings/the_cold_anvil.json b/public/resources/buildings/the_cold_anvil.json index 2d10a15c..002d6619 100644 --- a/public/resources/buildings/the_cold_anvil.json +++ b/public/resources/buildings/the_cold_anvil.json @@ -56,5 +56,9 @@ "tier10" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "dwarf_adamantine_champion", + "mountain_king" + ] } diff --git a/public/resources/buildings/undermount_vault.json b/public/resources/buildings/undermount_vault.json index ff306685..d9f7ab9e 100644 --- a/public/resources/buildings/undermount_vault.json +++ b/public/resources/buildings/undermount_vault.json @@ -50,5 +50,9 @@ "tier8" ] }, - "stack_mode": "amplify" + "stack_mode": "amplify", + "produces": [ + "merchant", + "caravan_master" + ] } diff --git a/public/resources/buildings/vault_of_seals.json b/public/resources/buildings/vault_of_seals.json index 98de31c0..aacc6b2d 100644 --- a/public/resources/buildings/vault_of_seals.json +++ b/public/resources/buildings/vault_of_seals.json @@ -38,5 +38,9 @@ }, "specialist_slots": [ "tradeswright" + ], + "produces": [ + "merchant", + "caravan_master" ] } diff --git a/public/resources/buildings/walls.json b/public/resources/buildings/walls.json index 73860832..6e2cc4a0 100644 --- a/public/resources/buildings/walls.json +++ b/public/resources/buildings/walls.json @@ -34,6 +34,10 @@ "defense" ] }, - "stack_mode": "parallel" + "stack_mode": "parallel", + "produces": [ + "defender", + "shield_bearer" + ] } ] diff --git a/public/resources/buildings/zeppelin_dock.json b/public/resources/buildings/zeppelin_dock.json index 714eb2a4..533e1328 100644 --- a/public/resources/buildings/zeppelin_dock.json +++ b/public/resources/buildings/zeppelin_dock.json @@ -33,6 +33,8 @@ ] }, "produces": [ + "dwarf_iron_hawk", + "dwarf_mithril_hawk", "dwarf_war_zeppelin", "dwarf_sky_fortress" ], diff --git a/src/simulator/crates/mc-replay/src/awards.rs b/src/simulator/crates/mc-replay/src/awards.rs index 4558ff42..256c6cad 100644 --- a/src/simulator/crates/mc-replay/src/awards.rs +++ b/src/simulator/crates/mc-replay/src/awards.rs @@ -62,6 +62,7 @@ pub struct AwardDef { /// Top-level envelope matching the JSON structure. #[derive(Debug, Clone, Deserialize)] pub struct AwardDefs { + /// All award definitions from `awards.json`, in declaration order. pub awards: Vec, } diff --git a/src/simulator/crates/mc-replay/tests/award_computation.rs b/src/simulator/crates/mc-replay/tests/award_computation.rs new file mode 100644 index 00000000..bbba9877 --- /dev/null +++ b/src/simulator/crates/mc-replay/tests/award_computation.rs @@ -0,0 +1,448 @@ +//! Integration tests for `mc_replay::compute_awards`. +//! +//! Acceptance bullets from p2-48 §3: +//! - `compute_awards_returns_8`: all eight awards from `awards.json` are +//! returned, one per entry, in definition order. +//! - `compute_awards_handles_ties`: when two clans share the winning metric, +//! the clan with the lowest numeric [`ClanId`] wins deterministically. +//! +//! Run with: `cargo test -p mc-replay --test award_computation` + +use mc_replay::awards::{AwardDefs, AwardWinner}; +use mc_replay::history::{ClanDescriptor, GameHistory, TurnEventCollector}; +use mc_replay::ids::{ + CityName, ClanId, GameId, LeaderId, PackId, PackVersion, TechId, TileCoord, UnitKind, + WonderId, +}; +use mc_replay::snapshot::TurnSnapshot; +use mc_replay::{compute_awards, TurnEvent}; +use mc_replay::archive::{GameOutcome, MapDescriptor}; + +// ── Fixture helpers ────────────────────────────────────────────────────────── + +/// Load the real `awards.json` at compile-time. +const AWARDS_JSON: &str = + include_str!("../../../../../public/games/age-of-dwarves/data/awards.json"); + +fn load_defs() -> AwardDefs { + AwardDefs::from_json(AWARDS_JSON).expect("awards.json must parse") +} + +/// Three-clan, 50-turn game fixture where each clan has a clear lead in one +/// metric category, allowing all eight awards to resolve to a known winner. +/// +/// | Clan | Lead metric | +/// |------|-----------------------------------------------------| +/// | 1 | buildings_built_total (greatest_builder) | +/// | 1 | wonders_built count (master_architect) | +/// | 2 | units_killed events (war_chief) | +/// | 3 | gold peak (wealthiest_clan) | +/// | 1 | culture_total (most_cultured) | +/// | 2 | techs_researched events (greatest_scholar) | +/// | 1 | turns_leading_city_count (longest_reign) | +/// | 2 | turns_survived (survivor — clan 3 eliminated early) | +fn make_fixture() -> GameHistory { + let mut hist = GameHistory::new( + GameId::new_v4(), + PackId("age-of-dwarves".into()), + PackVersion("0.1.0-test".into()), + 42, + MapDescriptor { + kind: "highlands".into(), + width: 48, + height: 36, + }, + vec![ + ClanDescriptor { + id: ClanId(1), + name: "Ironhold".into(), + sigil_key: "ironhold.png".into(), + colour_rgba: 0xCC_44_11_FF, + starting_leader: LeaderId("durin".into()), + }, + ClanDescriptor { + id: ClanId(2), + name: "Stonebeard".into(), + sigil_key: "stonebeard.png".into(), + colour_rgba: 0x22_88_CC_FF, + starting_leader: LeaderId("brenna".into()), + }, + ClanDescriptor { + id: ClanId(3), + name: "Deepdelve".into(), + sigil_key: "deepdelve.png".into(), + colour_rgba: 0x44_AA_44_FF, + starting_leader: LeaderId("torga".into()), + }, + ], + ); + + // ── Snapshots (one per clan at final turn = 50) ────────────────────────── + // + // We push one representative mid-game snapshot (turn 25) plus the final + // snapshot (turn 50) for each clan so peak-value aggregations have two data + // points. + + // Turn 25 — midpoints (gold values used for peak detection). + hist.snapshots.push(TurnSnapshot { + turn: 25, + clan_id: ClanId(1), + population: 600, + cities: 4, // leading city count at t25 + army_strength: 50.0, + gold: 200, + gold_per_turn: 8, + culture_per_turn: 5.0, + tech_count: 8, + land_area: 40, + buildings_built_total: 10, + culture_total: 125.0, + score: 0.3, + }); + hist.snapshots.push(TurnSnapshot { + turn: 25, + clan_id: ClanId(2), + population: 500, + cities: 3, + army_strength: 80.0, + gold: 150, + gold_per_turn: 6, + culture_per_turn: 2.0, + tech_count: 12, + land_area: 35, + buildings_built_total: 5, + culture_total: 50.0, + score: 0.25, + }); + hist.snapshots.push(TurnSnapshot { + turn: 25, + clan_id: ClanId(3), + population: 400, + cities: 2, + army_strength: 30.0, + gold: 500, // midpoint peak for clan 3 + gold_per_turn: 10, + culture_per_turn: 1.0, + tech_count: 5, + land_area: 20, + buildings_built_total: 3, + culture_total: 25.0, + score: 0.2, + }); + + // Turn 50 — final values. Clan 3 was eliminated at turn 30 so its final + // snapshot uses turn 30 (appended after events below; see note). + hist.snapshots.push(TurnSnapshot { + turn: 50, + clan_id: ClanId(1), + population: 1200, + cities: 6, // still leading city count + army_strength: 100.0, + gold: 300, + gold_per_turn: 12, + culture_per_turn: 8.0, + tech_count: 15, + land_area: 90, + buildings_built_total: 25, // highest buildings + culture_total: 525.0, // highest culture_total + score: 0.65, + }); + hist.snapshots.push(TurnSnapshot { + turn: 50, + clan_id: ClanId(2), + population: 900, + cities: 4, + army_strength: 120.0, + gold: 250, + gold_per_turn: 9, + culture_per_turn: 3.0, + tech_count: 20, // highest tech_count in snapshot, but award uses events + land_area: 60, + buildings_built_total: 12, + culture_total: 150.0, + score: 0.55, + }); + // Clan 3 final snapshot at turn 30 (eliminated then). + hist.snapshots.push(TurnSnapshot { + turn: 30, + clan_id: ClanId(3), + population: 200, + cities: 1, + army_strength: 10.0, + gold: 60, + gold_per_turn: 2, + culture_per_turn: 0.5, + tech_count: 3, + land_area: 10, + buildings_built_total: 2, + culture_total: 30.0, + score: 0.08, + }); + + // ── Events ─────────────────────────────────────────────────────────────── + + let mut col = TurnEventCollector::new(); + + // war_chief: clan 2 kills 8 units, clan 1 kills 3, clan 3 kills 0. + for t in [3u32, 7, 12, 18, 22, 28, 35, 42] { + col.push(TurnEvent::UnitKilled { + turn: t, + attacker: ClanId(2), + defender: ClanId(1), + unit_kind: UnitKind("warrior".into()), + hex: TileCoord::new(0, 0), + }); + } + for t in [5u32, 15, 25] { + col.push(TurnEvent::UnitKilled { + turn: t, + attacker: ClanId(1), + defender: ClanId(2), + unit_kind: UnitKind("warrior".into()), + hex: TileCoord::new(1, 0), + }); + } + + // master_architect: clan 1 builds 2 wonders, clan 2 builds 1. + col.push(TurnEvent::WonderBuilt { + turn: 20, + clan: ClanId(1), + wonder: WonderId("great_forge".into()), + city: CityName("Ironhold".into()), + }); + col.push(TurnEvent::WonderBuilt { + turn: 35, + clan: ClanId(1), + wonder: WonderId("deep_library".into()), + city: CityName("Ironhold".into()), + }); + col.push(TurnEvent::WonderBuilt { + turn: 28, + clan: ClanId(2), + wonder: WonderId("the_vault".into()), + city: CityName("Stonebeard".into()), + }); + + // greatest_scholar: clan 2 researches 5 techs, clan 1 researches 3. + for (t, tid) in [ + (4u32, "bronze_working"), + (10, "masonry"), + (18, "smelting"), + (26, "engineering"), + (38, "clockwork"), + ] { + col.push(TurnEvent::TechResearched { + turn: t, + clan: ClanId(2), + tech: TechId(tid.into()), + }); + } + for (t, tid) in [(6u32, "agriculture"), (14, "pottery"), (22, "writing")] { + col.push(TurnEvent::TechResearched { + turn: t, + clan: ClanId(1), + tech: TechId(tid.into()), + }); + } + + // survivor: clan 3 eliminated at turn 30. + col.push(TurnEvent::ClanEliminated { + turn: 30, + clan: ClanId(3), + by: ClanId(2), + }); + + col.flush_to_history(&mut hist); + + hist.final_turn = 50; + hist.outcome = GameOutcome::Victor { + clan: 1, + reason: "score".into(), + turn: 50, + }; + + hist +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// All eight award definitions in `awards.json` produce exactly one +/// `AwardWinner` each, in definition order, with non-empty `award_id`. +#[test] +fn compute_awards_returns_8() { + let defs = load_defs(); + assert_eq!( + defs.awards.len(), + 8, + "awards.json must define exactly 8 awards" + ); + + let hist = make_fixture(); + let winners = compute_awards(&hist, &defs); + + assert_eq!( + winners.len(), + 8, + "compute_awards must return one winner per award definition" + ); + + // Winners are in the same order as the definitions. + for (winner, def) in winners.iter().zip(defs.awards.iter()) { + assert_eq!( + winner.award_id, def.id, + "winner award_id must match definition id" + ); + assert!( + !winner.award_id.is_empty(), + "award_id must be non-empty" + ); + } + + // Spot-check expected winners from the fixture design. + let by_id: std::collections::HashMap<&str, &AwardWinner> = winners + .iter() + .map(|w| (w.award_id.as_str(), w)) + .collect(); + + assert_eq!( + by_id["greatest_builder"].clan, + ClanId(1), + "clan 1 has the most buildings_built_total (25)" + ); + assert_eq!( + by_id["master_architect"].clan, + ClanId(1), + "clan 1 built 2 wonders vs clan 2's 1" + ); + assert_eq!( + by_id["war_chief"].clan, + ClanId(2), + "clan 2 killed 8 units vs clan 1's 3" + ); + assert_eq!( + by_id["wealthiest_clan"].clan, + ClanId(3), + "clan 3 peaked at 500 gold, higher than clans 1 or 2" + ); + assert_eq!( + by_id["most_cultured"].clan, + ClanId(1), + "clan 1 has culture_total 525 at final snapshot" + ); + assert_eq!( + by_id["greatest_scholar"].clan, + ClanId(2), + "clan 2 researched 5 techs vs clan 1's 3" + ); + assert_eq!( + by_id["longest_reign"].clan, + ClanId(1), + "clan 1 led city count on most turns (6 cities by final turn)" + ); + assert_eq!( + by_id["survivor"].clan, + ClanId(1), + "clans 1+2 survived full 50 turns; clan 1 (lowest id) wins tie" + ); +} + +/// When two clans share the exact winning metric value the clan with the +/// lowest numeric `ClanId` wins deterministically. +#[test] +fn compute_awards_handles_ties() { + let defs = load_defs(); + + // Build a minimal history where clan 1 and clan 2 have identical + // `buildings_built_total` (10) — the `greatest_builder` award must go to + // clan 1 (lower id). + let mut hist = GameHistory::new( + GameId::new_v4(), + PackId("age-of-dwarves".into()), + PackVersion("0.1.0-test".into()), + 7, + MapDescriptor { + kind: "continents".into(), + width: 32, + height: 24, + }, + vec![ + ClanDescriptor { + id: ClanId(1), + name: "Ironhold".into(), + sigil_key: "ironhold.png".into(), + colour_rgba: 0xCC_44_11_FF, + starting_leader: LeaderId("durin".into()), + }, + ClanDescriptor { + id: ClanId(2), + name: "Stonebeard".into(), + sigil_key: "stonebeard.png".into(), + colour_rgba: 0x22_88_CC_FF, + starting_leader: LeaderId("brenna".into()), + }, + ], + ); + + // Both clans: buildings_built_total = 10, culture_total = 50, gold = 100. + for clan in [ClanId(1), ClanId(2)] { + hist.snapshots.push(TurnSnapshot { + turn: 10, + clan_id: clan, + population: 500, + cities: 3, + army_strength: 40.0, + gold: 100, + gold_per_turn: 5, + culture_per_turn: 5.0, + tech_count: 5, + land_area: 30, + buildings_built_total: 10, + culture_total: 50.0, + score: 0.3, + }); + } + + hist.final_turn = 10; + hist.outcome = GameOutcome::InProgress; // outcome doesn't affect award logic + + let winners = compute_awards(&hist, &defs); + let by_id: std::collections::HashMap<&str, &AwardWinner> = winners + .iter() + .map(|w| (w.award_id.as_str(), w)) + .collect(); + + // greatest_builder: tie at 10 buildings → clan 1 (lowest id) wins. + assert_eq!( + by_id["greatest_builder"].clan, + ClanId(1), + "tie on buildings_built_total must resolve to lowest clan_id" + ); + + // most_cultured: tie at 50.0 → clan 1 wins. + assert_eq!( + by_id["most_cultured"].clan, + ClanId(1), + "tie on culture_total must resolve to lowest clan_id" + ); + + // wealthiest_clan: both peak at 100 gold → clan 1 wins. + assert_eq!( + by_id["wealthiest_clan"].clan, + ClanId(1), + "tie on peak gold must resolve to lowest clan_id" + ); + + // survivor: both survived all 10 turns → clan 1 wins. + assert_eq!( + by_id["survivor"].clan, + ClanId(1), + "tie on turns_survived must resolve to lowest clan_id" + ); + + // longest_reign: both tied at 3 cities every turn → clan 1 wins. + assert_eq!( + by_id["longest_reign"].clan, + ClanId(1), + "tie on turns_leading_city_count must resolve to lowest clan_id" + ); +}