feat(@projects/@magic-civilization): add new rewards to buildings

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-08 06:15:44 -07:00
parent fa84810da4
commit afce3602f7
29 changed files with 576 additions and 23 deletions

View file

@ -32,6 +32,7 @@
]
},
"produces": [
"dwarf_gyrocopter",
"dwarf_steam_bomber"
],
"stack_mode": "parallel"

View file

@ -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"
]
}
]

View file

@ -42,6 +42,12 @@
"dwarven"
]
},
"stack_mode": "parallel"
"stack_mode": "parallel",
"produces": [
"dwarf_adamantine_champion",
"iron_halberd",
"dwarf_ascendant_sapper",
"dwarf_ascendant_engineer"
]
}
]

View file

@ -40,5 +40,10 @@
"world"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"dwarf_river_galley",
"dwarf_war_galley",
"cartographer"
]
}

View file

@ -28,6 +28,9 @@
"infrastructure"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"field_medic"
]
}
]

View file

@ -40,5 +40,9 @@
"world"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"saga_singer",
"loremaster"
]
}

View file

@ -40,5 +40,9 @@
"world"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"merchant",
"caravan_master"
]
}

View file

@ -27,5 +27,8 @@
"infrastructure"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"hold_courier"
]
}

View file

@ -40,5 +40,9 @@
"world"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"dwarf_high_engineer",
"dwarf_grand_engineer"
]
}

View file

@ -40,5 +40,9 @@
"world"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"merchant",
"loremaster"
]
}

View file

@ -31,6 +31,10 @@
"modern"
]
},
"stack_mode": "parallel"
"stack_mode": "parallel",
"produces": [
"motorized_artillery",
"rocket_trooper"
]
}
]

View file

@ -40,5 +40,10 @@
"world"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"dwarf_ascendant_smith",
"dwarf_grand_smith",
"forge_titan"
]
}

View file

@ -36,5 +36,9 @@
"world"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"dwarf_grand_smith",
"dwarf_ascendant_smith"
]
}

View file

@ -35,5 +35,9 @@
"infrastructure"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"dwarf_deep_guard",
"combat_engineer"
]
}

View file

@ -41,5 +41,9 @@
"world"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"battle_priest",
"loremaster"
]
}

View file

@ -53,5 +53,9 @@
"ecology"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"beast_scout",
"boar_scout"
]
}

View file

@ -28,5 +28,8 @@
"infrastructure"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"field_medic"
]
}

View file

@ -34,6 +34,10 @@
]
},
"stack_mode": "amplify",
"produces": [
"founder",
"worker"
],
"palace_tier": "longhouse",
"evolves_to": "great_hall",
"unlocked_by": null,

View file

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

View file

@ -35,6 +35,11 @@
"future"
]
},
"stack_mode": "parallel"
"stack_mode": "parallel",
"produces": [
"emp_trooper",
"stormbolt_trooper",
"commando"
]
}
]

View file

@ -40,5 +40,9 @@
"world"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"saga_singer",
"bard"
]
}

View file

@ -27,5 +27,8 @@
"infrastructure"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"worker"
]
}

View file

@ -56,5 +56,9 @@
"tier10"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"dwarf_adamantine_champion",
"mountain_king"
]
}

View file

@ -50,5 +50,9 @@
"tier8"
]
},
"stack_mode": "amplify"
"stack_mode": "amplify",
"produces": [
"merchant",
"caravan_master"
]
}

View file

@ -38,5 +38,9 @@
},
"specialist_slots": [
"tradeswright"
],
"produces": [
"merchant",
"caravan_master"
]
}

View file

@ -34,6 +34,10 @@
"defense"
]
},
"stack_mode": "parallel"
"stack_mode": "parallel",
"produces": [
"defender",
"shield_bearer"
]
}
]

View file

@ -33,6 +33,8 @@
]
},
"produces": [
"dwarf_iron_hawk",
"dwarf_mithril_hawk",
"dwarf_war_zeppelin",
"dwarf_sky_fortress"
],

View file

@ -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<AwardDef>,
}

View file

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