diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd index bd581238..df6ea65f 100644 --- a/src/game/engine/src/modules/management/turn_processor.gd +++ b/src/game/engine/src/modules/management/turn_processor.gd @@ -153,7 +153,15 @@ func _process_research(player: RefCounted) -> void: # Player return # Per-yield difficulty multiplier (composed by GameState). + # p1-29 cycle 4: research-yield catch-up boost. When this player is at least + # 2 eras behind the highest opponent on the tech ladder, multiply science + # yield by 1.5× — composes multiplicatively with cycle-3's tech-PICK boost in + # auto_play.gd::_pick_research. Cycle 3 batch confirmed pick-side multiplier + # alone doesn't move tier_peak_gap because the loser is research-OUTPUT + # starved, not just choosing wrong techs. The output boost addresses that + # directly. Inert when both players are at similar eras (`is_behind=false`). var sci_modifier: float = GameState.get_effective_yield_mult(player, "research") + sci_modifier *= _catchup_research_mult(player) # Per-city science yields the Rust side will sum. var game_map: RefCounted = GameState.get_game_map() @@ -497,3 +505,64 @@ static func _player_owns_resource(player: RefCounted, resource_id: String) -> bo if tile != null and str(tile.get("resource_id")) == resource_id: return true return false + + +# ── p1-29 cycle 4: research-output catch-up dynamics ───────────── +# +# Helpers below mirror the static functions added to auto_play.gd in cycle 3 +# (`_player_tier_peak`, `_max_opponent_tier_peak`). Duplicated here rather than +# referenced via AutoPlayScript so that turn_processor's _process_research path +# is self-contained — auto_play.gd is an autoload that may not be loaded in +# every code path that ends up calling this module (live games, GUT tests, +# headless MCTS). + + +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. + 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. Returns 0 when self is the only player or no + ## opponents have researched anything yet. + 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 + + +static func _catchup_research_mult(player: RefCounted) -> float: + ## Returns 1.5× when this player is at least 2 eras behind the highest + ## opponent's tech ceiling, 1.0× otherwise. Composes multiplicatively with + ## the configured base research multiplier (set by AI_DIFFICULTY etc.) and + ## with auto_play.gd::_pick_research's pillar-multiplier boost. (p1-29 cycle 4) + if player == null: + return 1.0 + var self_tp: int = _player_tier_peak(player) + var opp_tp: int = _max_opponent_tier_peak(player) + if opp_tp >= self_tp + 2: + return 1.5 + return 1.0