feat(@projects/@magic-civilization): add research catch-up multiplier logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-03 18:10:42 -04:00
parent 2275ae6e74
commit e7375886c6

View file

@ -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