feat(world-map): Implement deterministic tiebreaker cascade (score, cities, population, units) for victory conditions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-11 06:09:23 -07:00
parent d5938ca7df
commit 2915bba1dc

View file

@ -212,36 +212,75 @@ func _on_victory(player_index: int, victory_type: String) -> void:
func _pick_winner_by_score() -> int:
## Highest score wins; ties resolved deterministically by lower player index.
## Tiebreaker cascade: score → cities → population → units → true draw (-1).
## Without the cascade, strict `>` on score alone makes player 0 win every
## tie — and because both arena players run the same heuristic as the same
## race, scores always match. A true draw returns -1 and is serialized as
## victory_type "draw" by _finish / _build_result_dict.
var game_map: RefCounted = GameState.get_game_map()
var best_idx: int = 0
var best_score: int = -1
var vm: VictoryManagerScript = _victory_manager as VictoryManagerScript
var leaders: Array[int] = []
var leader_stats: Array = []
for player: Variant in GameState.players:
if not player is PlayerScript:
continue
var s: int = (_victory_manager as VictoryManagerScript).calculate_score(
player, game_map
)
if s > best_score:
best_score = s
best_idx = player.index
return best_idx
var stats: Array = [
vm.calculate_score(player, game_map),
player.cities.size(),
_total_population(player),
player.units.size(),
]
if leaders.is_empty():
leaders = [player.index]
leader_stats = stats
continue
var cmp: int = _compare_stats(stats, leader_stats)
if cmp > 0:
leaders = [player.index]
leader_stats = stats
elif cmp == 0:
leaders.append(player.index)
if leaders.size() != 1:
return -1
return leaders[0]
func _compare_stats(a: Array, b: Array) -> int:
## Lexicographic comparison across the tiebreaker axes.
## Returns +1 if a beats b, -1 if b beats a, 0 if identical on every axis.
for i: int in range(a.size()):
var av: int = int(a[i])
var bv: int = int(b[i])
if av > bv:
return 1
if av < bv:
return -1
return 0
# ── Result writing ───────────────────────────────────────────────────
func _finish(winner_index: int, victory_type: String) -> void:
## winner_index == -1 signals a true draw — _build_result_dict translates
## that into winner_name: "" and victory_type: "draw" regardless of the
## caller-supplied type, so downstream consumers (ai-arena.sh summary,
## tournament scorers) don't have to special-case both axes.
_finished = true
var result: Dictionary = _build_result_dict(winner_index, victory_type)
_write_result(result)
var winner_display: String = str(result.get("winner_name", "?"))
if winner_display.is_empty():
winner_display = "<draw>"
print(
(
"[AI ARENA] match=%s finished: winner=%s type=%s turn=%d"
% [
_match_id,
str(result.get("winner_name", "?")),
victory_type,
winner_display,
str(result.get("victory_type", victory_type)),
int(result.get("final_turn", 0)),
]
)
@ -278,6 +317,14 @@ func _build_result_dict(winner_index: int, victory_type: String) -> Dictionary:
if player.index == winner_index:
winner_name = player.player_name
# winner_index == -1 is the canonical "no winner" sentinel from the
# tiebreaker cascade. Force victory_type to "draw" so the result file
# is internally consistent even if the caller passed "score".
var final_victory_type: String = victory_type
if winner_index == -1:
final_victory_type = "draw"
winner_name = ""
var duration_seconds: float = float(
Time.get_ticks_msec() - _start_ticks_msec
) / 1000.0
@ -287,7 +334,7 @@ func _build_result_dict(winner_index: int, victory_type: String) -> Dictionary:
"seed": _seed,
"winner_index": winner_index,
"winner_name": winner_name,
"victory_type": victory_type,
"victory_type": final_victory_type,
"final_turn": GameState.turn_number,
"turn_limit": _turn_limit,
"players": players_payload,