feat(mc-ai): Add parity tests for research picking and enhance evaluator logic in Monte Carlo AI simulations

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-03 05:02:38 -07:00
parent de7b8df166
commit fbed62f60f
3 changed files with 531 additions and 100 deletions

View file

@ -283,8 +283,24 @@ impl ScoringEvaluator {
candidates
}
/// Pick the best tech from available candidates.
/// Returns None if no candidates or not researching.
/// Pick the best research tech from the **full** tech list.
///
/// This is the Rail-1 port of `auto_play.gd::_pick_research` — the single
/// source of truth for AI research selection. It reproduces the three
/// GDScript passes:
///
/// 1. **Raw score** every tech via [`score_tech`] (cost-dominant base ×
/// per-pillar personality multiplier × tier-4-unit-unlock boost ×
/// tier-3 mercantile penalty), independent of availability.
/// 2. **Prereq boost**: prerequisites of any tech scoring ≥ `PREREQ_BOOST_THRESHOLD`
/// get a ×`PREREQ_BOOST_MULT` lift, so the AI ladders toward high-value
/// techs instead of stalling on cheap leaves.
/// 3. **Availability filter**: pick the highest final-scoring tech whose
/// prerequisites are all met and that is not already researched.
///
/// `candidates` must be the full tech list with `requires` /
/// `already_researched` populated; the bridge no longer pre-filters. Returns
/// `None` when the list is empty or every tech is researched/blocked.
pub fn pick_tech(
&self,
candidates: &[AiTechCandidate],
@ -294,9 +310,43 @@ impl ScoringEvaluator {
if candidates.is_empty() {
return None;
}
// Pass 1: raw score for every tech (ignoring availability).
let raw: HashMap<&str, f32> = candidates
.iter()
.map(|t| (t.id.as_str(), score_tech(t, state, strategic)))
.collect();
// Pass 2: prerequisites of any high-value tech get a ×1.5 boost.
let mut prereq_mult: HashMap<&str, f32> = HashMap::new();
for t in candidates {
if raw.get(t.id.as_str()).copied().unwrap_or(0.0) < PREREQ_BOOST_THRESHOLD {
continue;
}
for req in &t.requires {
let entry = prereq_mult.entry(req.as_str()).or_insert(1.0);
if PREREQ_BOOST_MULT > *entry {
*entry = PREREQ_BOOST_MULT;
}
}
}
// Pass 3: pick the highest final-scoring available tech.
let researched: std::collections::HashSet<&str> = candidates
.iter()
.filter(|t| t.already_researched)
.map(|t| t.id.as_str())
.collect();
candidates
.iter()
.map(|t| (t, score_tech(t, state, strategic)))
.filter(|t| !t.already_researched)
.filter(|t| t.requires.iter().all(|r| researched.contains(r.as_str())))
.map(|t| {
let base = raw.get(t.id.as_str()).copied().unwrap_or(0.0);
let boost = prereq_mult.get(t.id.as_str()).copied().unwrap_or(1.0);
(t, base * boost)
})
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(t, _)| t.id.clone())
}
@ -531,92 +581,118 @@ fn score_item(
score
}
/// Score threshold above which a tech's prerequisites earn the pass-2 boost.
/// Mirrors GDScript `raw_score >= 20.0`.
const PREREQ_BOOST_THRESHOLD: f32 = 20.0;
/// Multiplier applied to prerequisites of high-value techs (GDScript `1.5x`).
const PREREQ_BOOST_MULT: f32 = 1.5;
/// Numerator of the cost-dominant base term (`1000 / cost`). Cheaper techs
/// score proportionally higher, matching GDScript `1000.0 / float(cost)`.
const COST_BASE_NUMERATOR: f32 = 1000.0;
/// Boost when a tech unlocks a tier-4+ unit (GDScript `sc *= 3.0`).
const HIGH_TIER_UNIT_MULT: f32 = 3.0;
/// Catch-up boost on military/metallurgy when ≥2 eras behind (GDScript `* 1.5`).
const CATCHUP_PILLAR_MULT: f32 = 1.5;
/// Normalise a raw 1..=10 personality axis to [0, 1].
///
/// Neutral 5 → 0.444, minimum 1 → 0.0, maximum 10 → 1.0. Missing keys default
/// to 5 (neutral). Mirrors `auto_play.gd::_norm_axis`.
fn norm_axis(axes: &HashMap<String, i32>, key: &str) -> f32 {
let raw = *axes.get(key).unwrap_or(&5) as f32;
(raw.clamp(1.0, 10.0) - 1.0) / 9.0
}
/// Raw per-tech score (GDScript pass 1). The dominant term is `1000 / cost`;
/// the per-pillar personality multiplier, the tier-4-unit unlock boost, and the
/// tier-3 mercantile penalty are all multiplicative on top — exactly matching
/// `auto_play.gd::_pick_research`. `_strategic` is unused here (the per-pillar
/// weighting is driven directly from the raw 1..=10 axes in
/// `state.strategic_axes`, as in GDScript) but kept for signature symmetry with
/// the other scorers.
fn score_tech(
tech: &AiTechCandidate,
state: &AiPlayerState,
strategic: &StrategicWeights,
_strategic: &StrategicWeights,
) -> f32 {
let mut score = 0.5_f32;
let cost = (tech.cost.max(1)) as f32;
let mut score = COST_BASE_NUMERATOR / cost;
let axes = &state.strategic_axes;
let agg = norm_axis(axes, "aggression");
let prod = norm_axis(axes, "production");
let wlth = norm_axis(axes, "wealth");
let trd = norm_axis(axes, "trade_willingness");
let exp = norm_axis(axes, "expansion");
// Per-pillar personality multiplier (range ~1.0..=2.0). Base 1.0 ensures
// clans with low axes still research every pillar. Mirrors `pillar_mult`.
// military → aggression (blackhammer rushes military)
// metallurgy → production (ironhold/deepforge prioritise smithing)
// agriculture → expansion (blackhammer/runesmith expand aggressively)
// civics → (wealth + trade_willingness)/2 (goldvein)
// scholarship → (wealth + production)/2 (goldvein science income)
// ecology → (expansion + production)/2 (deepforge tall-empire)
let mut mil_mult = 1.0 + agg;
let mut met_mult = 1.0 + prod;
let agr_mult = 1.0 + exp * 0.8;
let civ_mult = 1.0 + (wlth + trd) / 2.0 * 0.7;
let sch_mult = 1.0 + (wlth + prod) / 2.0 * 0.6;
let eco_mult = 1.0 + (exp + prod) / 2.0 * 0.5;
// p1-29 catch-up: when ≥2 eras behind the highest opponent, boost military
// + metallurgy ×1.5 and waive the tier-3 mercantile penalty so the losing
// player closes the tech gap instead of minting more era-1 economy techs.
// The cross-player comparison + `MC_DISABLE_CATCHUP_RESEARCH` kill switch
// are resolved GDScript-side into `state.research_behind`.
let is_behind = state.research_behind;
if is_behind {
mil_mult *= CATCHUP_PILLAR_MULT;
met_mult *= CATCHUP_PILLAR_MULT;
}
let pillar_mult = match tech.pillar.as_str() {
"military" => mil_mult,
"metallurgy" => met_mult,
"agriculture" => agr_mult,
"civics" => civ_mult,
"scholarship" => sch_mult,
"ecology" => eco_mult,
_ => 1.0,
};
score *= pillar_mult;
// Tier-3+ penalty for mercantile clans (low aggression AND low production),
// waived when behind. Raw thresholds (≤5) catch goldvein (agg=3) / runesmith
// (agg=5, prod=5) while sparing blackhammer (agg=9) / ironhold (prod=9).
if tech.tier >= 3 && !is_behind {
let agg_raw = *axes.get("aggression").unwrap_or(&5);
let prod_raw = *axes.get("production").unwrap_or(&5);
// GDScript `agg < 0.5 and prod < 0.5` where normalised 5 → 0.444, so the
// raw cutoff is ≤5 (raw 6 normalises to 0.556 > 0.5).
if agg_raw <= 5 && prod_raw <= 5 {
let trade_factor = (wlth + trd) / 2.0; // mercantile bias, [0, 1]
score *= (1.0 - trade_factor * 0.6).max(0.4); // up to 60% penalty
}
}
// Tier-4+ unit unlock: techs that gate elite units are worth ×3 (the bridge
// resolves the max unlocked-unit tier from units/*.json into `max_unit_tier`).
if tech.max_unit_tier >= 4 {
score *= HIGH_TIER_UNIT_MULT;
}
// ── Luxury/strategic unlock bias (p2-54d) ─────────────────────────────────
// The caller (api-gdext bridge) pre-computes per-tech accumulated scores
// from the player's visible-but-yield-gated resources and indicator
// decorations in their territory, then stores them in
// `state.luxury_unlock_scores`. We layer that directly onto the base score
// so a tech that unlocks 3 silk tiles owned by a wealth-10 goldvein clan
// outweighs a generic metallurgy tech the player doesn't need yet.
// The api-gdext bridge pre-computes per-tech accumulated scores from the
// player's visible-but-yield-gated resources and indicator decorations and
// stores them in `state.luxury_unlock_scores`. This is an additive lift the
// GDScript inline scorer never had; it stays additive so a tech unlocking
// luxury tiles a wealth-10 goldvein clan owns outranks a generic tech the
// player doesn't need yet. (Empty in the parity test, so out-of-test-scope.)
if let Some(&luxury_bonus) = state.luxury_unlock_scores.get(&tech.id) {
score += luxury_bonus;
}
// Unlock value: how many useful things does this tech gate?
score += tech.unlocks_buildings.len() as f32 * 0.2;
score += tech.unlocks_units.len() as f32 * 0.15;
// Extract raw per-axis weights from state (1..=10 scale, default 5 = neutral).
// These may come from the clan personality JSON (110) or race axes (-10..+10).
// Normalise both to [0, 1] by clamping to [1, 10] then dividing by 9.
let norm_axis = |key: &str| -> f32 {
let raw = *state.strategic_axes.get(key).unwrap_or(&5) as f32;
(raw.clamp(1.0, 10.0) - 1.0) / 9.0
};
let aggression = norm_axis("aggression");
let production_axis = norm_axis("production");
let wealth_axis = norm_axis("wealth");
let trade_axis = norm_axis("trade_willingness");
let expansion = norm_axis("expansion");
// Strategic fit by actual pillar names in techs/*.json.
//
// Mapping:
// military → aggression (blackhammer: agg=9 → ~0.89)
// metallurgy → production (ironhold: prod=9 → ~0.89; deepforge: prod=8 → ~0.78)
// agriculture → expansion (blackhammer: exp=6; runesmith: exp=6)
// civics → wealth + trade_willingness blend (goldvein)
// scholarship → wealth (goldvein) + production (ironhold/deepforge)
// ecology → expansion × 0.5 + production × 0.5 (deepforge tall-empire)
score += match tech.pillar.as_str() {
"military" => aggression * 0.7,
"metallurgy" => production_axis * 0.7,
"agriculture" => expansion * 0.5,
"civics" => (wealth_axis + trade_axis) / 2.0 * 0.6,
"scholarship" => (wealth_axis + production_axis) / 2.0 * 0.5,
"ecology" => (expansion + production_axis) / 2.0 * 0.4,
_ => 0.2,
};
// Economic emergency: civics/scholarship give commerce-adjacent income boost
if state.gold < 0 && matches!(tech.pillar.as_str(), "civics" | "scholarship") {
score += 0.4;
}
// Aggression bonus for military techs when threat is high
if state.threat_level > 0.5 && tech.pillar == "military" {
score += strategic.aggression * 0.3;
}
// Tier-3+ penalty for low aggression + low production clans (mercantile
// archetype). Guards against goldvein/runesmith racing to tier_peak=6 the
// same as ironhold/blackhammer.
//
// Condition uses raw 1..=10 axis values (inclusive upper bound at 5) so
// that goldvein (agg=3, prod=5) and runesmith (agg=5, prod=5) are caught
// while blackhammer (agg=9) and ironhold (prod=9) are unaffected.
// This mirrors the GDScript `agg < 0.5` threshold where normalised 5 → 0.444.
//
// trade_factor is /18 (max sum for two 1..=10 axes) so goldvein (9+9)
// yields 1.0, neutral (5+5) yields 0.556 → ~33% penalty.
if tech.tier >= 3 {
let agg_raw = *state.strategic_axes.get("aggression").unwrap_or(&5);
let prod_raw = *state.strategic_axes.get("production").unwrap_or(&5);
if agg_raw <= 5 && prod_raw <= 5 {
let wealth_raw = *state.strategic_axes.get("wealth").unwrap_or(&5) as f32;
let trade_raw = *state.strategic_axes.get("trade_willingness").unwrap_or(&5) as f32;
let trade_factor = (wealth_raw + trade_raw) / 18.0; // 18 = max(10+10) - 2 min
score *= (1.0 - trade_factor * 0.6).max(0.4);
}
}
score
}
@ -754,6 +830,7 @@ mod tests {
is_researching: false,
available_candidates: vec![],
tech_candidates: vec![],
research_behind: false,
best_site_score: 25.0,
enemy_city_count: 2,
army_melee: 2,
@ -962,32 +1039,32 @@ mod tests {
}
#[test]
fn tech_scoring_favors_unlocks() {
fn tech_scoring_favors_tier4_unit_unlocks() {
// Under the GDScript-shape scorer the value driver is the tier-4+ unit
// unlock (×3), not a per-unlock additive count. At equal cost+pillar a
// tech gating an elite unit must outscore one that gates none.
let state = dummy_state();
let strategic = dwarf_weights();
let few_unlocks = AiTechCandidate {
id: "mining_1".into(),
pillar: "metallurgy".into(),
cost: 50,
tier: 1,
unlocks_buildings: vec![],
unlocks_units: vec![],
};
let many_unlocks = AiTechCandidate {
id: "mining_2".into(),
pillar: "metallurgy".into(),
let no_elite = AiTechCandidate {
cost: 80,
tier: 1,
unlocks_buildings: vec!["forge".into(), "deep_mine".into()],
unlocks_units: vec!["hammerhands".into()],
max_unit_tier: 2,
..plain_tech("mining_basic", "metallurgy")
};
let unlocks_elite = AiTechCandidate {
cost: 80,
unlocks_units: vec!["iron_vanguard".into()],
max_unit_tier: 4,
..plain_tech("leadership", "metallurgy")
};
let few_score = score_tech(&few_unlocks, &state, &strategic);
let many_score = score_tech(&many_unlocks, &state, &strategic);
let no_elite_score = score_tech(&no_elite, &state, &strategic);
let elite_score = score_tech(&unlocks_elite, &state, &strategic);
assert!(
many_score > few_score,
"more unlocks should score higher: few={few_score} many={many_score}"
elite_score > no_elite_score,
"tier-4 unit unlock should score higher: none={no_elite_score} elite={elite_score}"
);
}
@ -1207,6 +1284,9 @@ mod tests {
tier: 1,
unlocks_buildings: vec![],
unlocks_units: vec![],
requires: vec![],
max_unit_tier: 0,
already_researched: false,
}
}
@ -1276,11 +1356,12 @@ mod tests {
let score_empty = score_tech(&trapping, &state_empty, &strategic);
let score_bias = score_tech(&trapping, &state_with_bias, &strategic);
// Baseline (no observations) must equal the score when the key is absent.
// The biased variant must be strictly higher.
// The luxury bias is an additive lift layered onto the cost-dominant
// base, so the biased score must equal the baseline plus exactly the
// injected 20.0 — and strictly exceed it.
assert!(
(score_empty - 0.5_f32).abs() < 1.0,
"baseline trapping score should be near 0.5 + small pillar bonus, got {score_empty}"
(score_bias - (score_empty + 20.0)).abs() < 1e-3,
"luxury bias must add exactly 20.0: empty={score_empty} bias={score_bias}"
);
assert!(
score_bias > score_empty,

View file

@ -76,6 +76,15 @@ pub struct AiPlayerState {
#[serde(default)]
pub tech_candidates: Vec<AiTechCandidate>,
/// p1-29 catch-up observation: this player's tech ceiling is ≥2 eras behind
/// the highest opponent's. The cross-player tier_peak comparison (and the
/// `MC_DISABLE_CATCHUP_RESEARCH` kill switch) are gathered GDScript-side;
/// only the *decision* (military/metallurgy ×1.5, waive the tier-3
/// mercantile penalty) lives in `score_tech`. The science-yield catch-up
/// multiplier itself remains a non-goal (p1-29/p1-29b).
#[serde(default)]
pub research_behind: bool,
// ── Expansion context ────────────────────────────────────────────────────
/// Score of the best available city founding site (from CitySiteScorer).
#[serde(default)]
@ -179,6 +188,13 @@ pub struct AiProductionCandidate {
}
/// A researchable tech candidate.
///
/// The bridge passes the **full** tech list (not a prereq-filtered subset):
/// `pick_tech` runs all three GDScript passes itself — raw scoring, the
/// prerequisite-of-high-value boost, and the availability filter. The
/// prereq-boost pass (pass 2) needs the high-value techs that the old
/// candidate-only contract filtered out, so the contract carries `requires`
/// + `already_researched` per candidate.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiTechCandidate {
pub id: String,
@ -198,6 +214,20 @@ pub struct AiTechCandidate {
/// Unit IDs this tech unlocks.
#[serde(default)]
pub unlocks_units: Vec<String>,
/// Prerequisite tech IDs (the `requires` array in techs/*.json). Used by
/// the prereq-boost pass and the availability filter in `pick_tech`.
#[serde(default)]
pub requires: Vec<String>,
/// Highest tier among units this tech unlocks. Mirrors the GDScript
/// `udata.tier >= 4 → ×3` boost: the bridge resolves each unlocked unit's
/// tier (from `units/*.json`) and passes the max here so Rust stays the
/// scorer of record without re-reading unit JSON.
#[serde(default)]
pub max_unit_tier: u8,
/// Whether the player has already researched this tech. Lets `pick_tech`
/// run the availability filter (GDScript pass 3) over the full list.
#[serde(default)]
pub already_researched: bool,
}
/// Per-unit snapshot.

View file

@ -0,0 +1,320 @@
//! p0-26b — research-pick parity for the five Age-of-Dwarves clan personalities.
//!
//! Pins that the Rail-1 Rust scorer (`ScoringEvaluator::pick_tech`, the port of
//! `auto_play.gd::_pick_research`) reproduces the per-pillar personality intent
//! from the live tech JSON + `ai_personalities.json` strategic axes — and the
//! p1-29 catch-up flip. Assertions are *discriminating properties* (which
//! pillar each clan opens on, how a behind player re-prioritises), NOT
//! "Rust matches Rust": each expectation is derived from the GDScript formula
//! applied to the real tech costs/pillars.
//!
//! The luxury-unlock bias (p2-54d) is exercised by unit tests in
//! `evaluator.rs`; it is empty here (no territory observations), so this test
//! intentionally does not cover it.
use std::collections::HashMap;
use std::path::PathBuf;
use mc_ai::evaluator::ScoringEvaluator;
use mc_ai::game_state::{AiPlayerState, AiTechCandidate, StrategicWeights};
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(4)
.expect("mc-ai crate must sit four directories below the repo root")
.to_path_buf()
}
/// A tech as loaded from `public/resources/techs/*.json`, carrying only the
/// fields the scorer needs.
struct TechDef {
id: String,
pillar: String,
cost: u16,
tier: u8,
requires: Vec<String>,
max_unit_tier: u8,
}
/// Load the full Age-of-Dwarves tech tree plus a unit-id → tier map, resolving
/// each tech's highest unlocked-unit tier (the bridge's `max_unit_tier`).
fn load_tech_tree() -> Vec<TechDef> {
let root = repo_root();
let res = root.join("public").join("resources");
// unit id → tier
let mut unit_tier: HashMap<String, u8> = HashMap::new();
let units_dir = res.join("units");
for entry in std::fs::read_dir(&units_dir).expect("units dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let raw = std::fs::read_to_string(&path).expect("read unit");
let v: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => continue,
};
// Files are either a single unit object or an array of units.
let items: Vec<&serde_json::Value> = match &v {
serde_json::Value::Array(a) => a.iter().collect(),
obj @ serde_json::Value::Object(_) => vec![obj],
_ => vec![],
};
for it in items {
if let (Some(id), Some(tier)) = (
it.get("id").and_then(|x| x.as_str()),
it.get("tier").and_then(|x| x.as_u64()),
) {
unit_tier.insert(id.to_string(), tier as u8);
}
}
}
let mut techs = Vec::new();
let techs_dir = res.join("techs");
for entry in std::fs::read_dir(&techs_dir).expect("techs dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if path.file_name().and_then(|f| f.to_str()) == Some("manifest.json") {
continue;
}
let raw = std::fs::read_to_string(&path).expect("read tech");
let v: serde_json::Value = serde_json::from_str(&raw).expect("parse tech");
let items: Vec<serde_json::Value> = match v {
serde_json::Value::Array(a) => a,
obj @ serde_json::Value::Object(_) => vec![obj],
_ => vec![],
};
for it in items {
let id = match it.get("id").and_then(|x| x.as_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => continue,
};
let pillar = it
.get("pillar")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let cost = it.get("cost").and_then(|x| x.as_u64()).unwrap_or(1).max(1) as u16;
let tier = it.get("tier").and_then(|x| x.as_u64()).unwrap_or(0) as u8;
let requires = it
.get("requires")
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|r| r.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let mut max_unit_tier = 0u8;
if let Some(units) = it
.get("unlocks")
.and_then(|u| u.get("units"))
.and_then(|u| u.as_array())
{
for uid in units {
if let Some(t) = uid.as_str().and_then(|s| unit_tier.get(s)) {
max_unit_tier = max_unit_tier.max(*t);
}
}
}
techs.push(TechDef {
id,
pillar,
cost,
tier,
requires,
max_unit_tier,
});
}
}
assert!(techs.len() > 50, "expected the full Age-of-Dwarves tech tree, got {}", techs.len());
techs
}
/// Build the full candidate list (the bridge contract), marking which techs the
/// player has already researched.
fn candidates(tree: &[TechDef], researched: &[&str]) -> Vec<AiTechCandidate> {
tree.iter()
.map(|t| AiTechCandidate {
id: t.id.clone(),
pillar: t.pillar.clone(),
cost: t.cost,
tier: t.tier,
unlocks_buildings: vec![],
unlocks_units: vec![],
requires: t.requires.clone(),
max_unit_tier: t.max_unit_tier,
already_researched: researched.contains(&t.id.as_str()),
})
.collect()
}
/// Canonical clan strategic axes from `ai_personalities.json` (1..=10 scale).
fn clan_axes(clan: &str) -> HashMap<String, i32> {
let (agg, exp, prod, wlth, trd) = match clan {
"ironhold" => (6, 4, 9, 3, 3),
"blackhammer" => (9, 6, 7, 2, 2),
"goldvein" => (3, 5, 5, 9, 9),
"deepforge" => (4, 2, 8, 5, 4),
"runesmith" => (5, 6, 5, 6, 7),
other => panic!("unknown clan {other}"),
};
HashMap::from([
("aggression".into(), agg),
("expansion".into(), exp),
("production".into(), prod),
("wealth".into(), wlth),
("trade_willingness".into(), trd),
])
}
fn pick(clan: &str, tree: &[TechDef], researched: &[&str], behind: bool) -> String {
let axes = clan_axes(clan);
let mut state = AiPlayerState::default();
state.strategic_axes = axes.clone();
state.research_behind = behind;
let strategic = StrategicWeights::from_race_axes(&axes);
let evaluator = ScoringEvaluator::default();
let cands = candidates(tree, researched);
evaluator
.pick_tech(&cands, &state, &strategic)
.expect("a tech must be pickable from the full tree")
}
fn pillar_of<'a>(tree: &'a [TechDef], id: &str) -> &'a str {
tree.iter()
.find(|t| t.id == id)
.map(|t| t.pillar.as_str())
.unwrap_or("")
}
// ── First-pick divergence (era 1, nothing researched) ─────────────────────────
#[test]
fn blackhammer_opens_on_military() {
let tree = load_tech_tree();
let first = pick("blackhammer", &tree, &[], false);
assert_eq!(
pillar_of(&tree, &first),
"military",
"blackhammer (agg=9) must open on a military tech, got {first}"
);
}
#[test]
fn ironhold_opens_on_metallurgy() {
let tree = load_tech_tree();
let first = pick("ironhold", &tree, &[], false);
assert_eq!(
pillar_of(&tree, &first),
"metallurgy",
"ironhold (prod=9) must open on a metallurgy tech, got {first}"
);
}
#[test]
fn deepforge_opens_on_metallurgy() {
let tree = load_tech_tree();
let first = pick("deepforge", &tree, &[], false);
assert_eq!(
pillar_of(&tree, &first),
"metallurgy",
"deepforge (prod=8) must open on a metallurgy tech, got {first}"
);
}
#[test]
fn goldvein_opens_on_a_commerce_pillar_not_military() {
// goldvein (wealth=9, trade=9, agg=3) weights civics highest; it must NOT
// open on military the way blackhammer does.
let tree = load_tech_tree();
let first = pick("goldvein", &tree, &[], false);
let p = pillar_of(&tree, &first);
assert_ne!(p, "military", "goldvein must not open on military, got {first}");
assert_eq!(
p, "civics",
"goldvein (wealth+trade=9) must open on civics, got {first} (pillar {p})"
);
}
#[test]
fn clans_diverge_on_first_pick() {
// The five clans must not all pick the same opening tech — research order
// divergence is the property p1-29 depends on.
let tree = load_tech_tree();
let picks: Vec<String> = ["ironhold", "blackhammer", "goldvein", "deepforge", "runesmith"]
.iter()
.map(|c| pick(c, &tree, &[], false))
.collect();
let distinct: std::collections::HashSet<&String> = picks.iter().collect();
assert!(
distinct.len() >= 3,
"expected ≥3 distinct opening techs across clans, got {picks:?}"
);
}
// ── Catch-up flip (p1-29 cycle 3) ─────────────────────────────────────────────
#[test]
fn behind_goldvein_flips_toward_military_or_metallurgy() {
// A mercantile clan (goldvein) opening on civics flips to closing the tech
// gap (military/metallurgy ×1.5) once it is ≥2 eras behind.
let tree = load_tech_tree();
let normal = pick("goldvein", &tree, &[], false);
let behind = pick("goldvein", &tree, &[], true);
let np = pillar_of(&tree, &normal);
let bp = pillar_of(&tree, &behind);
assert_ne!(
normal, behind,
"catch-up must change goldvein's pick: normal={normal} behind={behind}"
);
assert!(
bp == "military" || bp == "metallurgy",
"behind goldvein must prioritise military/metallurgy, got {behind} (pillar {bp}, normal pillar {np})"
);
}
#[test]
fn behind_waives_tier3_mercantile_penalty() {
// With the tier-3 mercantile penalty waived while behind, a behind goldvein
// values a deep military/metallurgy line at least as highly as when not
// behind — confirmed transitively by the pick flipping toward those pillars
// even though they are gated behind tier-1 prereqs.
let tree = load_tech_tree();
// Pre-research the tier-1 openers so tier-2+ military/metallurgy unlock.
let researched = ["military_doctrine", "mining", "stonecutting", "clan_law", "scholarship"];
let behind = pick("goldvein", &tree, &researched, true);
let bp = pillar_of(&tree, &behind);
assert!(
bp == "military" || bp == "metallurgy",
"behind goldvein with openers researched must push a military/metallurgy line, got {behind} (pillar {bp})"
);
}
// ── Availability filter (pass 3) ──────────────────────────────────────────────
#[test]
fn never_picks_an_already_researched_or_blocked_tech() {
let tree = load_tech_tree();
// Research every tier-1 opener; the next pick must be a tier-2 tech whose
// prereqs are now met, and must not be one of the researched ids.
let researched = ["military_doctrine", "mining", "stonecutting", "clan_law", "scholarship", "husbandry", "surveying"];
let next = pick("blackhammer", &tree, &researched, false);
assert!(
!researched.contains(&next.as_str()),
"must not re-pick a researched tech, got {next}"
);
// Its prereqs must all be in the researched set (availability filter).
let def = tree.iter().find(|t| t.id == next).expect("picked tech exists");
for req in &def.requires {
assert!(
researched.contains(&req.as_str()),
"picked {next} but prereq {req} is not researched (availability filter broken)"
);
}
}