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:
parent
de7b8df166
commit
fbed62f60f
3 changed files with 531 additions and 100 deletions
|
|
@ -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 (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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
320
src/simulator/crates/mc-ai/tests/pick_research_parity.rs
Normal file
320
src/simulator/crates/mc-ai/tests/pick_research_parity.rs
Normal 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue