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:
parent
864095c136
commit
de7b8df166
4 changed files with 141 additions and 274 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue