diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 98887cad..94d06bce 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -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 diff --git a/src/game/engine/src/entities/auto_play.gd b/src/game/engine/src/entities/auto_play.gd index 85970554..55ffb652 100644 --- a/src/game/engine/src/entities/auto_play.gd +++ b/src/game/engine/src/entities/auto_play.gd @@ -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) diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd index d893811b..ba5d1bed 100644 --- a/src/game/engine/src/modules/management/turn_processor.gd +++ b/src/game/engine/src/modules/management/turn_processor.gd @@ -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) diff --git a/src/game/engine/src/modules/management/turn_processor_helpers.gd b/src/game/engine/src/modules/management/turn_processor_helpers.gd index 4a3fef12..d0b47cab 100644 --- a/src/game/engine/src/modules/management/turn_processor_helpers.gd +++ b/src/game/engine/src/modules/management/turn_processor_helpers.gd @@ -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 + +