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:
parent
88dffec277
commit
2605712a61
12 changed files with 156 additions and 28 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
/// {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue