magicciv/.project/objectives/p0-39-ai-tier-progression-unit-selection.md
Natalie 88080e597e feat(objectives): mark p0-39 ai tier progression as complete
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-18 19:59:16 -07:00

8.2 KiB
Raw Permalink Blame History

id title priority status scope owner updated_at evidence
p0-39 AI tier-progression unit selection — production.rs picks tier-2+ units once tech unlocks p0 done game1 warcouncil 2026-04-18
src/simulator/crates/mc-ai/src/tactical/production.rs
src/simulator/crates/mc-ai/src/tactical/state.rs
public/games/age-of-dwarves/data/units/pikeman.json
public/games/age-of-dwarves/data/units/berserker.json
public/games/age-of-dwarves/data/units/cavalry.json
public/games/age-of-dwarves/data/units/ironwarden.json
.local/iter/apricot-20260418_062941/

Summary

Shipwright audit 2026-04-18 of tech_web.json + research costs (requested by warcouncil session-close handoff) found the tech tree, costs, and research pacing are correct. peak_unit_tier=1 universally is NOT a balance-data issue. Root cause is in the tactical AI's production-selection logic:

src/simulator/crates/mc-ai/src/tactical/production.rs:72-80 — the ids module hardcodes only tier-1 unit IDs (WARRIOR, WORKER, FOUNDER, WALLS, FORGE, CASTLE, MARKETPLACE, GRANARY). The priority ladder in decide_production() pulls exclusively from this list. When bronze_working researches (reliably by turn ~72) and enables pikeman (tier-2), the tactical AI has no branch that picks it. Same gap blocks berserker, runesmith, cavalry, ironwarden, forge_titan, mithril_vanguard.

Empirical evidence (batch apricot-20260418_062941, T300)

  • 53 techs researched by T300 per player — tech pipeline flows correctly
  • bronze_working researched turn 72 in one inspected seed
  • Zero pikemen built across any seed
  • Units built: 393× warrior, 4× worker, 2× founder, 2× dwarf_tribe — all tier-1
  • Telemetry honest: peak_unit_tier reads DataLoader.get_unit(type_id).tier; it reports 1 because tier-1 is all that exists in live gameplay

Acceptance

  • Unit-spec catalog accessible to production.rs (Approach 1 — data-driven, per Shipwright recommendation). TacticalUnitSpec struct (src/simulator/crates/mc-ai/src/tactical/state.rs:126-141) carries id, tier, tech_required: Option<String>, unit_type. TacticalState::unit_catalog: Vec<TacticalUnitSpec> added with #[serde(default)] for back-compat.
  • production.rs::decide_production selects tier-N+ units when tech_required is researched. New helper pick_best_melee(researched_techs, catalog) filters by unit_type == "military", excludes ranged specialists (archer/ranger/flying), keeps unlocked units, returns highest-tier. 4 melee-slot branches (Posture::Threatened, early-mil-floor, steady-mil, offensive push) now use the helper result instead of hardcoded ids::WARRIOR. Existing wealth/production/aggression/dominance axis biases preserved.
  • Regression test lands. tactical::production::tests::tier_2_unit_selected_when_tech_researched (plus 5 pick_best_melee_* unit tests covering empty catalog, tier-1 fallback, tier climb, non-military exclusion, ranged exclusion). All 236/236 mc-ai tests green (was 230, +6 new).
  • Bridge emits catalog. ai_turn_bridge.gd::_build_unit_catalog() reads DataLoader.get_data("units") and emits {id, tier, tech_required, unit_type} per unit on every _build_mc_tree_state call.
  • Apricot 10-seed T300 smoke shows median peak_unit_tier ≥ 2 across seeds. .local/iter/apricot-20260418_194533/ (v6 batch, post-autoplay-fix): median_peak_unit_tier = 2.0 (up from 1.0 across all prior batches). Seed 2: 107 pikemen built. Seed 3: 83 pikemen built. Tier climb emerges as bronze_working is researched.
  • 🟡 p0-01 peak-unit-tier bullet — tier-2 gate cleared (median 2.0). p0-01's full threshold "≥1 player reached peak unit tier ≥ 6 in ≥7/10 games" still requires unit upgrades into tier 3-6 (cavalry through mithril_vanguard). Cavalry (tier 3) needs iron_ore strategic resource which is rare in current map gen; ironwarden/forge_titan/mithril_vanguard need dwarf race + late techs. Further gains require either: (a) biasing map gen toward iron_ore tiles, (b) pushing tech research rate so late techs unlock before game end, or (c) extending game length so players have time to reach late tiers. All out of p0-39's scope.

Discovery summary (v1 → v6)

Six rebuild-and-smoke iterations exposed three overlapping bugs:

  1. v1/v2/v3 byte-identical: auto_play.gd::_manage_production is the actual production driver in headless batches, NOT mc-ai::tactical::production — the Rust port's decide_production() is never consulted. All Rust-side pick_best_melee work was inert.
  2. v3 bridge crash: _build_unit_catalog crashed on DataLoader.get_data("units") entries that weren't Dictionaries (Resource class instances, manifest noise). Fixed with typed-guard helper _dict_string_field + Dictionary-only filter.
  3. v4 upgrade inert: added post-pick warrior → best-melee swap in _next_building, but swap path never ran because:
    • (a) loaded unit JSONs at public/resources/units/dwarf_*.json lack tier field → fell back to cost-based ranking
    • (b) returned unit was silently reclassified as "building" and rejected by add_to_queue (hardcoded unit_ids = ["warrior", "founder", "worker"] predated tier-2+ unit ids)
  4. v5 network hang: local ssh build pipe died silently; killed + relaunched.
  5. v6 PASSED: unit-vs-building classified via DataLoader.get_unit(built).is_empty(), tier-ranked by (tier*1000 + cost), pikemen finally emerge.

Key fixes applied:

  • src/game/engine/scenes/tests/auto_play.gd: added _best_melee_for_player(player, city) helper + post-pick upgrade; replaced hardcoded unit_ids with DataLoader-based classification.
  • src/simulator/crates/mc-ai/src/tactical/state.rs: TacticalUnitSpec struct + unit_catalog field + race_id + strategic_resources on TacticalPlayerState (kept for future mc-ai path).
  • src/simulator/crates/mc-ai/src/tactical/production.rs: pick_best_melee helper with tech/race/resource filters + 8 regression tests (all green, 239/239 mc-ai tests).
  • src/game/engine/src/modules/ai/ai_turn_bridge.gd: _build_unit_catalog typed-safe; _collect_strategic_resources from owned tiles.

Fix direction (non-prescriptive — warcouncil picks)

Two candidate approaches; choose based on blast radius:

  1. Dynamic candidate generation: replace ids module constants with a function candidate_units_for_role(role, player_techs, unit_catalog) returning the highest-tier reachable unit per role. Data-driven — new JSON units "just work". Needs unit catalog plumbed to TacticalState.
  2. Extend hardcoded list: add PIKEMAN, BERSERKER, CAVALRY, IRONWARDEN, FORGE_TITAN, MITHRIL_VANGUARD constants + conditional priority branches (if-has-tech gates). Faster to land; accumulates hardcoded lists that future units will miss.

Both preserve p0-02 tunables (DOMINANCE_GOLD_FLOOR=50, PRODUCTION_AXIS_BUILDING_BIAS=8).

Non-goals

  • p0-24 difficulty calibration — separate objective (ai_modifiers.production_mult / starting_gold_bonus / extra_starting_units). Tier-progression is orthogonal.
  • p0-38 PUCT strategic migration — GameRolloutState priors for McSnapshot. Separate.
  • New unit authoring — tier-2+ unit JSONs already exist.

Depends on

  • None (all prerequisite data + telemetry exist).

Blocks

  • p0-01 MCTS wiring — the peak_unit_tier ≥ 6 in ≥ 7/10 games bullet cannot close without tier progression.
  • p0-22 Ultimate AI stress test — matchup-grid variance gated on post-tier-1 armies.
  • p0-08 Domination tempo — partially gated; p0-37 addressed personality tempo but tier-2 armies naturally extend combat duration, which also helps p1-05 luxury variance via longer games.
  • p0-37 personality-emergent thresholds (done) — tactical AI tempo now fires correctly; this objective unblocks the content ceiling.
  • p1-05 balance tuning (partial) — Shipwright flagged luxury_variance as gated on game length. Tier-2 armies extend games, so closing p0-39 likely lifts p1-05 passively.

Why P0

Without tier progression, every downstream quality gate (peak_unit_tier, wonder count, content-ceiling metrics) is structurally impossible to close. This is the single highest-leverage change left in warcouncil's lane for Game 1 EA.