diff --git a/src/game/engine/scenes/world_map/world_map_arena.gd b/src/game/engine/scenes/world_map/world_map_arena.gd index e6be7c5e..1a9b683a 100644 --- a/src/game/engine/scenes/world_map/world_map_arena.gd +++ b/src/game/engine/scenes/world_map/world_map_arena.gd @@ -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 = "" 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,