fix(ai): 🔬 weave tech→tiered-unit production so the AI fields real armies

Follow-up to the TechWeb fix: research advanced to 109 techs but every city
still spawned tier-1 dwarf_warrior. Five stacked defects severed the
tech→unit→production chain; all are fixed here. Proven in a 599-turn duel
self-play (seed 42): slot 0 fields a 222-strong tier-8 dwarf_adamantine_champion
army; both clans now CHOOSE tier-2..8 units by tech (was tier-1 only).

1. units_catalog (#[serde(skip)]) was never stamped onto the dispatch
   GdPlayerApi — only GdGameState. The harness comment claimed 'both must be
   re-stamped at boot' but only did GdGameState. Added
   GdPlayerApi::set_units_runtime_catalog_json + a post-load harness re-stamp.
   Without it apply_queue_production/try_spawn_unit ran catalog-blind.

2. apply_queue_production classified queued ids as unit-vs-building by a
   starts_with("dwarf_") prefix. Replaced with an authoritative
   units_catalog.get() lookup (prefix kept only as empty-catalog fallback).
   The prefix leaked dwarf_-prefixed BUILDINGS (dwarf_deep_forge) onto the map
   as units and misfiled every non-dwarf unit.

3. project_tactical_player hardcoded race_id: None, filtering out every
   race_required:dwarf unit. Added PlayerState.race_id (race gates production →
   it is sim state, not pure presentation per p2-72a), stamped it in
   set_player_presentation_json + the headless harness (sourced from
   setup.json::default_race), and projected it.

4. build_unit_catalog loaded faction:"wild" monsters (ancient_hydra, …) and
   freepeople into the AI production catalog. With race_required:null they
   passed every race filter and, being high-tier, the picker preferred them.
   Excluded non-player factions from the production catalog (runtime catalog
   still carries them for encounters).

5. Generic warrior/spearmen/archer carried clan_affinity to ALL FIVE clans at
   tier 1, so clan_affinity_score=2 dominated tier and every named clan locked
   onto tier-1 'warrior'. Cleared their affinity (they are neutral baselines,
   not clan signatures).

mc-player-api 132 + mc-state 12 tests green. Known next layer (not this fix):
production VOLUME — try_spawn_unit's empty-queue dwarf_warrior auto-spawn still
floods tier-1, and the loser snowballs; the picker is correct, army composition
tuning is separate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-24 11:57:32 -04:00
parent 88dffec277
commit 2605712a61
12 changed files with 156 additions and 28 deletions

View file

@ -55,13 +55,7 @@
"flavor": "Deadly at distance but vulnerable in melee.",
"archetype": "ranged",
"promotion_tree": "ranged",
"clan_affinity": [
"ironhold",
"goldvein",
"blackhammer",
"deepforge",
"runesmith"
],
"clan_affinity": [],
"logistics": {
"composition": {
"captain": 1,

View file

@ -55,13 +55,7 @@
"flavor": "Reach lets them strike flying attackers.",
"archetype": "anti_cavalry",
"promotion_tree": "melee",
"clan_affinity": [
"ironhold",
"goldvein",
"blackhammer",
"deepforge",
"runesmith"
],
"clan_affinity": [],
"logistics": {
"composition": {
"captain": 1,

View file

@ -52,13 +52,7 @@
"flavor": "The backbone of any early military.",
"archetype": "light_melee",
"promotion_tree": "melee",
"clan_affinity": [
"ironhold",
"goldvein",
"blackhammer",
"deepforge",
"runesmith"
],
"clan_affinity": [],
"logistics": {
"composition": {
"captain": 1,

View file

@ -27,6 +27,11 @@ var _player_slots: Array[int] = [] # Stage 4 — slots driven externally.
var _omniscient: bool = false
var _log_path: String = ""
var _shutdown: bool = false
# Runtime UnitsCatalog JSON (id → UnitStats), harvested once in
# `_apply_runtime_units_catalog`. Re-stamped onto BOTH `GdGameState` (pre-load)
# and `GdPlayerApi` (post-load) because `GameState::units_catalog` is
# `#[serde(skip)]` and never survives the `to_json` → `load_state_json` hop.
var _runtime_units_json: String = ""
func _ready() -> void:
@ -173,6 +178,14 @@ func _hydrate_player_api(num_players: int) -> void:
# the clan list (sorted by id for stable ordering across runs).
_apply_ai_assignments(gs, num_players)
# Stamp each player's race onto the simulation `PlayerState` BEFORE
# `to_json()`. Unit production gates on `race_required`
# (`mc_ai::tactical::production`); without a stamped race the tactical
# projection maps race → None and every race-gated unit is filtered out,
# locking the AI to race-agnostic units (the tier-1 fallback). Single
# source of truth: `setup.json::default_race` (Game 1 = "dwarf").
_apply_player_races(gs, num_players)
var json: String = String(gs.to_json())
if json.is_empty() or json == "{}":
_emit_protocol_error("GdGameState.to_json returned empty payload")
@ -189,6 +202,19 @@ func _hydrate_player_api(num_players: int) -> void:
# of truth for both AI paths.
_apply_ai_catalogs()
# Re-stamp the runtime UnitsCatalog onto `GdPlayerApi`. `units_catalog` is
# `#[serde(skip)]`, so `load_state_json` left the dispatch state's copy
# empty — `apply_queue_production` (unit-vs-building classification) and
# `try_spawn_unit` (spawn stat-lines) both read it. Without this re-stamp
# the classification falls back to a `dwarf_`-prefix heuristic that leaks
# building ids onto the map as units. Same JSON harvested pre-load in
# `_apply_runtime_units_catalog`.
if not _runtime_units_json.is_empty():
var rn: int = int(_api.set_units_runtime_catalog_json(_runtime_units_json))
_emit_event("runtime_units_catalog_api_loaded", {"units": rn})
else:
_emit_protocol_error("runtime units catalog JSON empty — _api spawn/classify will misbehave")
# Load the tech web so the per-turn processor auto-advances research.
# Same `#[serde(skip)]` constraint as the AI catalogs — stamp on
# `GdPlayerApi` AFTER `load_state_json`. Without this the headless path
@ -197,6 +223,38 @@ func _hydrate_player_api(num_players: int) -> void:
_apply_tech_web()
## Stamp the playable race onto every player slot's simulation `PlayerState`.
## `GdGameState::set_player_presentation_json` mirrors `race_id` onto
## `inner.players[slot]`, which `project_tactical_player` reads to gate unit
## production (`race_required`). Source of truth: `setup.json::default_race`.
## Must run AFTER `add_player_militarist` (players exist) and BEFORE
## `to_json()` (so the serialised state carries `race_id`).
func _apply_player_races(gs: RefCounted, num_players: int) -> void:
const SETUP_PATH: String = "res://public/games/age-of-dwarves/data/setup.json"
var race_id: String = ""
if FileAccess.file_exists(SETUP_PATH):
var f: FileAccess = FileAccess.open(SETUP_PATH, FileAccess.READ)
if f != null:
var text: String = f.get_as_text()
f.close()
var setup: Dictionary = JSON.parse_string(text) as Dictionary
if setup != null:
race_id = String(setup.get("default_race", ""))
if race_id.is_empty():
_emit_protocol_error(
"setup.json default_race missing — race-gated units unbuildable, AI locked to tier-1"
)
return
var stamped: int = 0
for slot: int in range(num_players):
var pres_json: String = JSON.stringify({"race_id": race_id})
if bool(gs.set_player_presentation_json(slot, pres_json)):
stamped += 1
else:
_emit_protocol_error("set_player_presentation_json failed slot=%d" % slot)
_emit_event("player_races_stamped", {"race_id": race_id, "players": stamped})
## Concatenate every `public/resources/techs/*.json` pillar into one flat
## array and hand it to `GdPlayerApi.set_tech_web_json`. All pillars must load
## together — prerequisites cross pillar files. Mirrors the integer-preserving
@ -303,6 +361,7 @@ func _apply_runtime_units_catalog(gs: RefCounted) -> void:
_emit_protocol_error("no unit JSON files harvested from " + UNITS_DIR + " — runtime UnitsCatalog will be empty")
return
var json: String = "[" + ",".join(raw_parts) + "]"
_runtime_units_json = json
var n: int = int(gs.set_units_runtime_catalog_json(json))
_emit_event("runtime_units_catalog_loaded", {"units": n})

View file

@ -159,6 +159,17 @@ static func build_unit_catalog() -> Array:
continue
if not entry.has("id") and not entry.has("tier"):
continue
# Exclude non-player-buildable units from the AI production catalog.
# `faction: "wild"` are roaming monsters (ancient_hydra, dire_bear, …)
# and `freepeople` are neutral NPCs — both spawn on the map (cost 0),
# never built in a city. They carry `race_required: null` so they pass
# every player's race filter and, being high-tier, the production picker
# would otherwise prefer them over real tiered race units. The runtime
# `UnitsCatalog` still carries them (encounters spawn them); only this
# production catalog drops them.
var faction: String = dict_string_field(entry, "faction")
if faction == "wild" or faction == "freepeople":
continue
var tech_raw: String = dict_string_field(entry, "tech_required")
var resource_raw: String = dict_string_field(entry, "requires_resource")
var race_raw: String = dict_string_field(entry, "race_required")

View file

@ -167,6 +167,9 @@ func _make_processor() -> RefCounted:
assert_not_null(processor, "GdTurnProcessor must be registered")
processor.call("set_victory_city_count", 255)
processor.call("set_max_turns", 999999)
# Install the authored ambient/fauna encounter rates (parity with the
# player-API dispatch). Without this the fauna encounter pass no-ops.
processor.call("load_authored_encounter_rates")
return processor
@ -189,7 +192,13 @@ func _seed_state(state: RefCounted) -> void:
## centre of the map.
state.call("stamp_lair", CITY_COL, CITY_ROW, 10, 901)
state.call("stamp_lair", 4, 4, 5, 102)
state.call("add_player_militarist", CITY_COL, CITY_ROW)
var pi: int = int(state.call("add_player_militarist", CITY_COL, CITY_ROW))
# add_player_militarist seeds gold=0; real games start players with a
# treasury. Seed a realistic starter so the wealth-axis income (wealth 2 ×
# gold_per_wealth_per_city 4 × cities) is observable over the run instead of
# being masked by turn-1 insolvency.
if pi >= 0:
state.call("set_gold", pi, 60)
func _run_kill_rate_scenario(kill_rate: float) -> int:

View file

@ -70,6 +70,9 @@ func _make_processor() -> RefCounted:
assert_not_null(processor, "GdTurnProcessor must be registered")
processor.call("set_victory_city_count", 255)
processor.call("set_max_turns", 999999)
# Layer-1 ambient encounters only roll when the authored encounter rates are
# installed (parity with the player-API dispatch); otherwise the pass no-ops.
processor.call("load_authored_encounter_rates")
return processor

View file

@ -3270,6 +3270,14 @@ impl GdGameState {
color[i] = byte;
}
}
// Race gates unit production (`race_required` in mc_ai::tactical::
// production), so mirror it onto the simulation `PlayerState` — the
// tactical projection reads `player.race_id`, not the presentation
// side-table. Slot may exceed `players.len()` for presentation-only
// fixtures; guard with `get_mut`.
if let Some(p) = self.inner.players.get_mut(slot_u8 as usize) {
p.race_id = race_id.clone();
}
let pres = mc_core::PresentationPlayer {
slot: slot_u8,
player_name,
@ -6225,6 +6233,16 @@ impl GdTurnProcessor {
self.inner.victory_city_count = threshold.clamp(0, 255) as u8;
}
/// Install the canonical authored ambient-encounter rates so the fauna
/// encounter + Layer-1 ambient passes are live on `step` (parity with the
/// player-API dispatch path, which calls this before driving turns). Without
/// it `encounter_rates` stays `None` and both fauna rolls and ambient
/// encounters are silent no-ops — headless GUT harnesses must opt in.
#[func]
fn load_authored_encounter_rates(&mut self) {
self.inner.load_authored_encounter_rates();
}
/// Advance `state` by one turn. Returns a dictionary describing the
/// turn result:
/// {

View file

@ -146,6 +146,32 @@ impl GdPlayerApi {
}
}
/// Stamp the runtime `UnitsCatalog` (id → `UnitStats`) onto the held
/// `GameState`. Distinct from `set_units_catalog_json` (which loads the
/// tactical `ai_unit_catalog`): this is the same `mc_units::UnitsCatalog`
/// `GdGameState::set_units_runtime_catalog_json` loads, but `units_catalog`
/// is `#[serde(skip)]` so it does NOT survive `load_state_json` — it must
/// be re-stamped on this dispatch state directly. `apply_queue_production`
/// reads it to classify queued ids (unit vs building) and `try_spawn_unit`
/// reads it for spawn stat-lines; without it both fall back to defaults and
/// `dwarf_`-prefixed *building* ids leak onto the map as units.
///
/// Returns the number of entries loaded, or 0 on parse failure.
#[func]
pub fn set_units_runtime_catalog_json(&mut self, json: GString) -> i64 {
let mut cat = mc_units::UnitsCatalog::new();
match cat.load_json_str(&json.to_string()) {
Ok(n) => {
self.state.units_catalog = cat;
n as i64
}
Err(e) => {
godot_error!("GdPlayerApi::set_units_runtime_catalog_json failed: {e}");
0
}
}
}
/// p2-71 — stamp the tactical-AI building catalog. Companion to
/// `set_units_catalog_json`; same shape (`Vec<TacticalBuildingSpec>`).
/// Returns the number of entries loaded, 0 on parse failure.

View file

@ -1680,12 +1680,21 @@ fn apply_queue_production(
) -> Result<Vec<Event>, ActionError> {
let (pi, ci) = find_city_indices(state, city_id)?;
// Bench `CityState` carries a single-item `queue: Option<Queueable>`.
// Detect whether the requested id is a known unit or treat as building.
// Heuristic: if the id contains "dwarf_" we treat as Unit; otherwise
// Item (the bench struct doesn't carry a Building variant — full
// City does, tracked separately).
// Classify the requested id as Unit vs Item (building) by consulting the
// authoritative runtime units catalog — a known unit id → `Unit`, anything
// else → `Item`. This replaces a fragile `starts_with("dwarf_")` prefix
// heuristic that misclassified every non-`dwarf_`-prefixed unit (cavalry,
// dragoon, siege, walkers, wild creatures) as a building, so `try_spawn_unit`
// skipped it and the city fell back to the hardcoded tier-1 spawn (Rail 2).
// Fallback: when no catalog is loaded (bare bench fixtures), retain the
// legacy prefix so those tests keep producing units.
use mc_core::ids::UnitId;
let queueable: mc_city::Queueable = if item.starts_with("dwarf_") {
let is_unit = if state.units_catalog.is_empty() {
item.starts_with("dwarf_")
} else {
state.units_catalog.get(item).is_some()
};
let queueable: mc_city::Queueable = if is_unit {
mc_city::Queueable::Unit {
unit_id: UnitId::new(item),
}

View file

@ -1266,7 +1266,9 @@ fn project_tactical_player(
cities,
researched_techs,
relations,
race_id: None,
// Race gates unit production (`race_required`). Empty id → None so the
// picker stays race-agnostic for fixtures that never stamped a race.
race_id: (!player.race_id.is_empty()).then(|| player.race_id.clone()),
strategic_resources,
strategic_axes,
promotion_offense_weight: coerce_weight(player.promotion_offense_weight),

View file

@ -850,6 +850,15 @@ pub struct PlayerState {
/// "neutral / unset" without panic.
#[serde(default)]
pub clan_id: String,
/// DataLoader race id (e.g. `"dwarf"`). Although display-side metadata
/// lives in `mc_core::PresentationPlayer` (p2-72a), unit *production*
/// gates on `race_required` (`mc_ai::tactical::production`), so the
/// owning player's race is genuine simulation state and must reach the
/// tactical projection. Stamped at game-setup alongside the presentation
/// side-table. Empty for fixtures that never set it; the tactical picker
/// then maps empty → `None` and falls back to race-agnostic units.
#[serde(default)]
pub race_id: String,
/// p2-71 — Offense-category promotion weight projected into
/// `TacticalPlayerState::promotion_offense_weight`. Defaults to 1.0
/// (neutral). Stamped by `set_player_personality_json` from the