diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd index c2c377f6..d893811b 100644 --- a/src/game/engine/src/modules/management/turn_processor.gd +++ b/src/game/engine/src/modules/management/turn_processor.gd @@ -139,8 +139,19 @@ func _process_research(player: RefCounted) -> void: # Player ## to Rust `GdTechWeb::process_research` (warcouncil p1-39 port, ## 2026-04-27). GDScript only assembles input JSON + applies ## completion side-effects (tech_researched signal, resource reveals). + ## + ## 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 + ## naturally instead of sitting idle. if player.researching.is_empty(): - return + _auto_pick_research(player) + if player.researching.is_empty(): + return # Per-yield difficulty multiplier (composed by GameState). # p1-29 cycle 4: research-yield catch-up boost. When this player is at least @@ -202,6 +213,62 @@ 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)