From fbed62f60fb9991f7a3fcd3aadd41f26bb3d7420 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 3 Jun 2026 05:02:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(mc-ai):=20=E2=9C=A8=20Add=20parity=20tests?= =?UTF-8?q?=20for=20research=20picking=20and=20enhance=20evaluator=20logic?= =?UTF-8?q?=20in=20Monte=20Carlo=20AI=20simulations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-ai/src/evaluator.rs | 281 +++++++++------ src/simulator/crates/mc-ai/src/game_state.rs | 30 ++ .../mc-ai/tests/pick_research_parity.rs | 320 ++++++++++++++++++ 3 files changed, 531 insertions(+), 100 deletions(-) create mode 100644 src/simulator/crates/mc-ai/tests/pick_research_parity.rs diff --git a/src/simulator/crates/mc-ai/src/evaluator.rs b/src/simulator/crates/mc-ai/src/evaluator.rs index 6f669804..591a200d 100644 --- a/src/simulator/crates/mc-ai/src/evaluator.rs +++ b/src/simulator/crates/mc-ai/src/evaluator.rs @@ -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, 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 (1–10) 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, diff --git a/src/simulator/crates/mc-ai/src/game_state.rs b/src/simulator/crates/mc-ai/src/game_state.rs index 4cd5d00e..1fa580bf 100644 --- a/src/simulator/crates/mc-ai/src/game_state.rs +++ b/src/simulator/crates/mc-ai/src/game_state.rs @@ -76,6 +76,15 @@ pub struct AiPlayerState { #[serde(default)] pub tech_candidates: Vec, + /// 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, + /// 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, + /// 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. diff --git a/src/simulator/crates/mc-ai/tests/pick_research_parity.rs b/src/simulator/crates/mc-ai/tests/pick_research_parity.rs new file mode 100644 index 00000000..2ee774f8 --- /dev/null +++ b/src/simulator/crates/mc-ai/tests/pick_research_parity.rs @@ -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, + 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 { + let root = repo_root(); + let res = root.join("public").join("resources"); + + // unit id → tier + let mut unit_tier: HashMap = 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 = 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 { + 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 { + 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 = ["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)" + ); + } +}