feat(auto-play): Improve AI research selection and auto-play logic with refined turn processing and expanded test coverage

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-03 05:02:38 -07:00
parent 864095c136
commit de7b8df166
4 changed files with 141 additions and 274 deletions

View file

@ -20,6 +20,9 @@ const ImprovementManagerScript = preload(
const HappinessScript = preload("res://engine/src/modules/empire/happiness.gd")
const ItemSystemScript = preload("res://engine/src/modules/management/item_system.gd")
const SaveManagerScript = preload("res://engine/src/core/save_manager.gd")
const TurnProcessorHelpersScript = preload(
"res://engine/src/modules/management/turn_processor_helpers.gd"
)
var _improvement_manager: RefCounted = null
@ -1247,15 +1250,12 @@ func _play_turn() -> void:
func _pick_research(player: RefCounted) -> void:
## p2-43a-followup (5th bullet) / p0-26: scoring logic lives in
## `mc-ai::evaluator::ScoringEvaluator::pick_tech`; this call site is a
## thin Rail-1 bridge delegation through the shared `GdAiController`
## instance held on `GameState`.
##
## Prereq + already-researched filtering is done in GDScript (cheap, needs
## live `player.has_tech` access); the Rust scorer receives a pre-filtered
## `AiTechCandidate` list plus the clan's raw 1..=10 axes and picks the
## highest-scoring tech.
## Rail-1: research selection is scored in
## `mc-ai::ScoringEvaluator::pick_tech` and dispatched through the shared
## `GdAiController` bridge via `TurnProcessorHelpersScript`. GDScript only
## assembles the candidate JSON, reads the clan axes, gathers the p1-29
## catch-up observation, and applies the returned tech id — no scoring logic
## lives here (single source shared with `turn_processor.gd`).
var ctrl: RefCounted = GameState.get_ai_controller()
if ctrl == null:
push_warning(
@ -1263,51 +1263,7 @@ func _pick_research(player: RefCounted) -> void:
+ "(player will retry next turn)"
)
return
# Build prereq-filtered `AiTechCandidate` list. The Rust struct fields:
# {id, pillar, cost, tier, unlocks_buildings, unlocks_units}
var candidates: Array = []
for tech: Dictionary in DataLoader.get_all_techs():
var tid: String = str(tech.get("id", ""))
if tid.is_empty() or player.has_tech(tid):
continue
var met: bool = true
for req: Variant in tech.get("requires", []):
if not player.has_tech(str(req)):
met = false
break
if not met:
continue
var unlocks: Dictionary = tech.get("unlocks", {})
var unlock_buildings: Array = []
for bid: Variant in unlocks.get("buildings", []):
unlock_buildings.append(str(bid))
var unlock_units: Array = []
for uid: Variant in unlocks.get("units", []):
unlock_units.append(str(uid))
candidates.append({
"id": tid,
"pillar": str(tech.get("pillar", "")),
"cost": maxi(int(tech.get("cost", 1)), 1),
"tier": int(tech.get("tier", 0)),
"unlocks_buildings": unlock_buildings,
"unlocks_units": unlock_units,
})
if candidates.is_empty():
return
# Personality axes (raw 1..=10) — bridge normalises internally.
var clan_id: String = str(player.get("clan_id") if player.get("clan_id") != null else "")
var axes: Dictionary = {}
if not clan_id.is_empty():
var personality: Dictionary = DataLoader.get_ai_personality(clan_id)
if personality != null and not personality.is_empty():
axes = personality.get("strategic_axes", {})
var best_id: String = ctrl.pick_research(
JSON.stringify(candidates), JSON.stringify(axes)
)
var best_id: String = TurnProcessorHelpersScript.pick_research_via_bridge(ctrl, player)
if not best_id.is_empty():
player.researching = best_id
player.research_progress = 0

View file

@ -12,6 +12,9 @@ extends Node
## saves/turn_NNNN.save — full GameState.serialize() per turn
const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd")
const TurnProcessorHelpersScript = preload(
"res://engine/src/modules/management/turn_processor_helpers.gd"
)
const PathfinderScript = preload("res://engine/src/map/pathfinder.gd")
const BuildableHelperScript = preload("res://engine/scenes/city/city_buildable_helper.gd")
const ImprovementManagerScript = preload(
@ -1214,129 +1217,20 @@ func _play_turn() -> void:
func _pick_research(player: RefCounted) -> void:
## Score available techs: base = 1000/cost; per-pillar personality multiplier;
## unlocks tier≥4 unit adds ×3; prerequisite of high-value tech adds ×1.5.
## Personality axes drive per-pillar multipliers so clan research orders diverge:
## military → aggression (blackhammer rushes military techs)
## metallurgy → production (ironhold/deepforge prioritise smithing)
## agriculture → expansion (blackhammer/runesmith expand aggressively)
## civics → wealth + trade_willingness (goldvein)
## scholarship → wealth + production blend (goldvein science income)
## ecology → expansion × 0.5 + production × 0.5 (deepforge tall-empire)
##
## Research scoring belongs in mc-ai::ScoringEvaluator::pick_tech (Rail-1).
## This test-harness path reads axes inline; wiring through GdAiController
## requires the tactical bridge to emit research actions (tracked in p0-26).
var all_techs: Array = DataLoader.get_all_techs()
# Load clan personality axes (1..=10). Defaults to 5 (neutral) if clan
# is unset so vanilla scoring degrades gracefully to neutral multipliers.
var clan_id: String = str(player.get("clan_id") if player.get("clan_id") != null else "")
var axes: Dictionary = {}
if not clan_id.is_empty():
var personality: Dictionary = DataLoader.get_ai_personality(clan_id)
if personality != null and not personality.is_empty():
axes = personality.get("strategic_axes", {})
# Normalise raw 1..=10 axis values to [0, 1] (neutral 5 → 0.44).
var agg: float = _norm_axis(axes, "aggression")
var prod: float = _norm_axis(axes, "production")
var wlth: float = _norm_axis(axes, "wealth")
var trd: float = _norm_axis(axes, "trade_willingness")
var exp: float = _norm_axis(axes, "expansion")
# Per-pillar multiplier derived from clan axes (range 1.0..=2.0).
# Base of 1.0 ensures clans with low axes still research every pillar.
var pillar_mult: Dictionary = {
"military": 1.0 + agg,
"metallurgy": 1.0 + prod,
"agriculture": 1.0 + exp * 0.8,
"civics": 1.0 + (wlth + trd) / 2.0 * 0.7,
"scholarship": 1.0 + (wlth + prod) / 2.0 * 0.6,
"ecology": 1.0 + (exp + prod) / 2.0 * 0.5,
}
# p1-29 cycle 3: catch-up dynamics. Compare this player's tech_era ceiling
# against the highest opponent's; when behind by ≥2 eras, boost military +
# metallurgy pillars 1.5× and waive the tier-3 mercantile penalty so the
# losing player prioritises closing the tech gap rather than minting more
# era-1 economy techs (the failure mode in the lever-3+4 batch where p1
# stayed at tier_peak=1 forever, never developing).
var self_tier_peak: int = _player_tier_peak(player)
var opp_tier_peak: int = _max_opponent_tier_peak(player)
var is_behind: bool = (opp_tier_peak >= self_tier_peak + 2)
# p1-29a cycle 6 isolation flag: `MC_DISABLE_CATCHUP_RESEARCH=1` forces
# is_behind to false here (matching the turn_processor.gd::_catchup_research_mult
# kill switch). Used by the combat-only batch to attribute which intervention
# moves tier_peak_gap.
if OS.has_environment("MC_DISABLE_CATCHUP_RESEARCH") \
and OS.get_environment("MC_DISABLE_CATCHUP_RESEARCH") == "1":
is_behind = false
if is_behind:
pillar_mult["military"] = float(pillar_mult["military"]) * 1.5
pillar_mult["metallurgy"] = float(pillar_mult["metallurgy"]) * 1.5
# Pass 1: compute raw score for every tech (ignoring availability).
var raw_score: Dictionary = {}
for tech: Dictionary in all_techs:
var tid: String = str(tech.get("id", ""))
if tid.is_empty():
continue
var cost: int = maxi(int(tech.get("cost", 1)), 1)
var sc: float = 1000.0 / float(cost)
# Apply personality-driven pillar multiplier (replaces hardcoded x2 for military).
var pillar: String = str(tech.get("pillar", ""))
sc *= float(pillar_mult.get(pillar, 1.0))
# Tier-3+ penalty for mercantile clans (low aggression AND low production).
# Guards goldvein/runesmith from racing to tier_peak=6 identically to
# ironhold/blackhammer. agg < 0.5 catches raw ≤5; prod < 0.5 catches raw ≤5.
# p1-29 cycle 3: waive the penalty when this player is behind on tech —
# a losing mercantile player needs to catch up rather than be further
# slowed by their personality bias.
if int(tech.get("tier", 0)) >= 3 and agg < 0.5 and prod < 0.5 and not is_behind:
var trade_factor: float = (wlth + trd) / 2.0 # mercantile bias, [0, 1]
sc *= maxf(0.4, 1.0 - trade_factor * 0.6) # up to 60% penalty for full mercantile clans
for uid: Variant in tech.get("unlocks", {}).get("units", []):
var udata: Dictionary = DataLoader.get_unit(str(uid))
if int(udata.get("tier", 1)) >= 4:
sc *= 3.0
break
raw_score[tid] = sc
# Pass 2: prerequisites of any tech scoring >= 20 get a 1.5x boost.
var prereq_mult: Dictionary = {}
for tech: Dictionary in all_techs:
var tid: String = str(tech.get("id", ""))
if not raw_score.has(tid) or float(raw_score[tid]) < 20.0:
continue
for req: Variant in tech.get("requires", []):
var rid: String = str(req)
prereq_mult[rid] = maxf(float(prereq_mult.get(rid, 1.0)), 1.5)
# Pass 3: pick the highest-scoring available tech.
var best_id: String = ""
var best_score: float = -1.0
for tech: Dictionary in all_techs:
var tid: String = str(tech.get("id", ""))
if tid.is_empty() or player.has_tech(tid):
continue
var reqs: Array = tech.get("requires", [])
var met: bool = true
for req: Variant in reqs:
if not player.has_tech(str(req)):
met = false
break
if not met:
continue
var score: float = float(raw_score.get(tid, 1.0)) * float(prereq_mult.get(tid, 1.0))
if score > best_score:
best_score = score
best_id = tid
## Rail-1: research selection is scored in mc-ai::ScoringEvaluator::pick_tech
## (the port of this function) and dispatched through the shared
## `GdAiController` bridge. GDScript only assembles the candidate JSON, reads
## the clan axes, gathers the p1-29 catch-up observation, and applies the
## returned tech id — no scoring logic lives here.
var ctrl: RefCounted = GameState.get_ai_controller()
if ctrl == null:
return
var best_id: String = TurnProcessorHelpersScript.pick_research_via_bridge(ctrl, player)
if not best_id.is_empty():
player.researching = best_id
player.research_progress = 0
if _turn_count <= 5 or _turn_count % 20 == 0:
print(" Researching: %s (score %.1f)" % [best_id, best_score])
print(" Researching: %s" % best_id)
static func _norm_axis(axes: Dictionary, key: String) -> float:
@ -1347,46 +1241,6 @@ static func _norm_axis(axes: Dictionary, key: String) -> float:
return (clampf(raw, 1.0, 10.0) - 1.0) / 9.0
static func _player_tier_peak(player: RefCounted) -> int:
## Compute the highest tech-era this player has researched. Mirrors the
## per-player tier_peak metric written into turn_stats.jsonl. Used by
## _pick_research's catch-up branch (p1-29 cycle 3) to detect when this
## player is falling behind on the tech ladder.
if player == null:
return 0
var techs: Array = player.researched_techs if player.get("researched_techs") != null else []
var peak: int = 0
for tid: Variant in techs:
var data: Dictionary = DataLoader.get_tech(str(tid))
var era: int = int(data.get("era", 0))
if era > peak:
peak = era
return peak
static func _max_opponent_tier_peak(self_player: RefCounted) -> int:
## Compute the highest opponent's tier_peak across all OTHER players in
## GameState.players. Used by _pick_research's catch-up branch.
## Returns 0 when self is the only player or no opponents have researched
## anything yet (degenerate first-turn case).
var max_peak: int = 0
if GameState == null or GameState.players == null:
return 0
var self_idx: int = -1
if self_player != null and self_player.get("index") != null:
self_idx = int(self_player.index)
for p: Variant in GameState.players:
if p == null:
continue
var pi: int = int(p.index) if p.get("index") != null else -1
if pi == self_idx:
continue
var peak: int = _player_tier_peak(p)
if peak > max_peak:
max_peak = peak
return max_peak
func _score_site(pos: Vector2i, game_map: RefCounted) -> float:
## Score a hex as a city site. Food*2 + production*1.5 + resources.
var tile: Resource = game_map.get_tile(pos)

View file

@ -143,13 +143,18 @@ func _process_research(player: RefCounted) -> void: # Player
## Auto-pick fallback: when a player has no current research target
## (just completed one, or never picked one — the latter case is the
## bug we hit on huge-map 5-clan runs where AI players P1-P4 stalled
## at techs=1 forever because nothing ever called `_pick_research`
## for them; auto_play.gd only picks for its impersonated current
## player slot). Re-pick from the bridge using the same scorer the
## headless autoplay uses, so AI players progress through eras
## at techs=1 forever because nothing ever picked a tech for them;
## auto_play.gd only picks for its impersonated current player slot).
## Re-pick via the shared Rail-1 bridge helper (the same scorer the
## headless autoplay uses), so AI players progress through eras
## naturally instead of sitting idle.
if player.researching.is_empty():
_auto_pick_research(player)
var ctrl: RefCounted = GameState.get_ai_controller()
if ctrl != null:
var picked: String = TurnProcessorHelpersScript.pick_research_via_bridge(ctrl, player)
if not picked.is_empty():
player.researching = picked
player.research_progress = 0
if player.researching.is_empty():
return
@ -213,62 +218,6 @@ func _process_research(player: RefCounted) -> void: # Player
_check_resource_reveals(completed_tech, player.index)
func _auto_pick_research(player: RefCounted) -> void:
## Pick a research target for `player` via the Rust bridge scorer.
##
## Mirrors the candidate-list construction from
## `auto_play.gd::_pick_research`. Runs for every player on every turn
## that finds `researching` empty — so AI players P1..P4 (which
## auto_play does NOT impersonate via `_play_turn`) actually advance
## through eras. Without this, only the auto_play-impersonated slot
## ever picked a tech and the rest sat at techs=1 forever (huge-map
## batch `20260516_212826`).
var ctrl: RefCounted = GameState.get_ai_controller()
if ctrl == null:
return
var candidates: Array = []
for tech: Dictionary in DataLoader.get_all_techs():
var tid: String = str(tech.get("id", ""))
if tid.is_empty() or player.has_tech(tid):
continue
var met: bool = true
for req: Variant in tech.get("requires", []):
if not player.has_tech(str(req)):
met = false
break
if not met:
continue
var unlocks: Dictionary = tech.get("unlocks", {})
var unlock_buildings: Array = []
for bid: Variant in unlocks.get("buildings", []):
unlock_buildings.append(str(bid))
var unlock_units: Array = []
for uid: Variant in unlocks.get("units", []):
unlock_units.append(str(uid))
candidates.append({
"id": tid,
"pillar": str(tech.get("pillar", "")),
"cost": maxi(int(tech.get("cost", 1)), 1),
"tier": int(tech.get("tier", 0)),
"unlocks_buildings": unlock_buildings,
"unlocks_units": unlock_units,
})
if candidates.is_empty():
return
var clan_id: String = str(player.get("clan_id") if player.get("clan_id") != null else "")
var axes: Dictionary = {}
if not clan_id.is_empty():
var personality: Dictionary = DataLoader.get_ai_personality(clan_id)
if personality != null and not personality.is_empty():
axes = personality.get("strategic_axes", {})
var best_id: String = ctrl.pick_research(
JSON.stringify(candidates), JSON.stringify(axes)
)
if not best_id.is_empty():
player.researching = best_id
player.research_progress = 0
func _check_resource_reveals(completed_tech: String, player_index: int) -> void:
TurnProcessorHelpersScript._check_resource_reveals(completed_tech, player_index)

View file

@ -269,3 +269,111 @@ static func _get_healing_rate(
return 5
# ── Research selection (Rail-1: scored in mc-ai, dispatched via GdAiController) ──
static func pick_research_via_bridge(ctrl: RefCounted, player: RefCounted) -> String:
## Single source of research selection for both the autoplay harness
## (`auto_play.gd::_pick_research`) and the per-player auto-pick fallback
## (`turn_processor.gd::_process_research`). Assembles the FULL tech list
## (Rust runs the prereq-boost + availability passes itself), the clan axes,
## and the p1-29 catch-up observation, then delegates scoring to
## `mc-ai::ScoringEvaluator::pick_tech` via the bridge. Returns the picked
## tech id, or "" when nothing is researchable.
if ctrl == null or player == null:
return ""
var candidates: Array = []
for tech: Dictionary in DataLoader.get_all_techs():
var tid: String = str(tech.get("id", ""))
if tid.is_empty():
continue
var unlocks: Dictionary = tech.get("unlocks", {})
var unlock_buildings: Array = []
for bid: Variant in unlocks.get("buildings", []):
unlock_buildings.append(str(bid))
var unlock_units: Array = []
var max_unit_tier: int = 0
for uid: Variant in unlocks.get("units", []):
unlock_units.append(str(uid))
var udata: Dictionary = DataLoader.get_unit(str(uid))
max_unit_tier = maxi(max_unit_tier, int(udata.get("tier", 1)))
var reqs: Array = []
for req: Variant in tech.get("requires", []):
reqs.append(str(req))
candidates.append({
"id": tid,
"pillar": str(tech.get("pillar", "")),
"cost": maxi(int(tech.get("cost", 1)), 1),
"tier": int(tech.get("tier", 0)),
"unlocks_buildings": unlock_buildings,
"unlocks_units": unlock_units,
"requires": reqs,
"max_unit_tier": max_unit_tier,
"already_researched": bool(player.has_tech(tid)),
})
if candidates.is_empty():
return ""
var clan_id: String = str(player.get("clan_id") if player.get("clan_id") != null else "")
var axes: Dictionary = {}
if not clan_id.is_empty():
var personality: Dictionary = DataLoader.get_ai_personality(clan_id)
if personality != null and not personality.is_empty():
axes = personality.get("strategic_axes", {})
var research_behind: bool = _research_behind(player)
return str(ctrl.pick_research(
JSON.stringify(candidates), JSON.stringify(axes), research_behind
))
static func _research_behind(player: RefCounted) -> bool:
## p1-29 catch-up observation: true when this player's tech-era ceiling is at
## least 2 eras below the highest opponent's. The DECISION driven by this flag
## (military/metallurgy ×1.5, waive the tier-3 mercantile penalty) lives in
## mc-ai::score_tech — only the cross-player observation is gathered here.
##
## Kill switch (p1-29a cycle 6 isolation batch): `MC_DISABLE_CATCHUP_RESEARCH=1`
## forces the flag false, matching the science-yield kill switch in
## turn_processor.gd::_catchup_research_mult. Used to attribute which
## intervention moves tier_peak_gap.
if OS.has_environment("MC_DISABLE_CATCHUP_RESEARCH") \
and OS.get_environment("MC_DISABLE_CATCHUP_RESEARCH") == "1":
return false
return _max_opponent_tier_peak(player) >= _player_tier_peak(player) + 2
static func _player_tier_peak(player: RefCounted) -> int:
## Highest tech-era this player has researched (mirrors the per-player
## tier_peak metric in turn_stats.jsonl).
if player == null:
return 0
var techs: Array = player.researched_techs if player.get("researched_techs") != null else []
var peak: int = 0
for tid: Variant in techs:
var data: Dictionary = DataLoader.get_tech(str(tid))
var era: int = int(data.get("era", 0))
if era > peak:
peak = era
return peak
static func _max_opponent_tier_peak(self_player: RefCounted) -> int:
## Highest opponent tier_peak across all OTHER players in GameState.players.
## Returns 0 when self is the only player or no opponents have researched.
var max_peak: int = 0
if GameState == null or GameState.players == null:
return 0
var self_idx: int = -1
if self_player != null and self_player.get("index") != null:
self_idx = int(self_player.index)
for p: Variant in GameState.players:
if p == null:
continue
var pi: int = int(p.index) if p.get("index") != null else -1
if pi == self_idx:
continue
var peak: int = _player_tier_peak(p)
if peak > max_peak:
max_peak = peak
return max_peak