11"""`codeclash ladder` subcommands: build a ladder (make) and climb it (run)."""
22
3+ import copy
34import getpass
45import time
6+ from concurrent .futures import ThreadPoolExecutor , as_completed
57from pathlib import Path
68
79import typer
1012from codeclash import CONFIG_DIR
1113from codeclash .constants import LOCAL_LOG_DIR
1214from codeclash .tournaments .pvp import PvpTournament
15+ from codeclash .utils .log import get_logger
1316from codeclash .utils .yaml_utils import resolve_includes
1417
18+ logger = get_logger ("ladder" )
19+
1520ladder_app = typer .Typer (
1621 no_args_is_help = True , add_completion = False , context_settings = {"help_option_names" : ["-h" , "--help" ]}
1722)
2025@ladder_app .command ("make" )
2126def make (
2227 config_path : Path = typer .Argument (..., help = "Path to the ladder (round-robin) config file." ),
28+ workers : int = typer .Option (
29+ 1 , "--workers" , "-w" , help = "Pairwise tournaments to run concurrently (each pair is independent)."
30+ ),
2331):
2432 """Build a ladder: run PvP tournaments across all pairs of players (for ranking)."""
2533 yaml_content = config_path .read_text ()
@@ -28,24 +36,40 @@ def make(
2836
2937 players = config ["players" ]
3038 num_players = len (players )
39+
40+ # Build one fully independent (deep-copied) config per pair up front so concurrent runs
41+ # never share or mutate the same player/config dicts.
42+ jobs : list [tuple [dict , Path ]] = []
3143 for i in range (num_players ):
3244 for j in range (i + 1 , num_players ):
33- player1 = players [i ]
45+ player1 = copy . deepcopy ( players [i ])
3446 player1 ["name" ] = player1 ["branch_init" ]
35- player2 = players [j ]
47+ player2 = copy . deepcopy ( players [j ])
3648 player2 ["name" ] = player2 ["branch_init" ]
37- pvp_config = {
38- ** config ,
39- "players" : [player1 , player2 ],
40- }
41-
49+ pvp_config = {** copy .deepcopy (config ), "players" : [player1 , player2 ]}
4250 vs = f"PvpTournament.{ player1 ['name' ]} _vs_{ player2 ['name' ]} " .replace ("/" , "_" )
4351 output_dir = LOCAL_LOG_DIR / "ladder" / config ["game" ]["name" ] / vs
44- try :
45- tournament = PvpTournament (pvp_config , output_dir = output_dir )
46- except FileExistsError :
47- continue
52+ jobs .append ((pvp_config , output_dir ))
53+
54+ def run_pair (pvp_config : dict , output_dir : Path ) -> None :
55+ try :
56+ tournament = PvpTournament (pvp_config , output_dir = output_dir )
57+ except FileExistsError :
58+ return # already completed by a previous invocation
59+ # A single failing pair must not abort the rest of a long round-robin.
60+ try :
4861 tournament .run ()
62+ except Exception :
63+ logger .exception (f"Pair failed, skipping: { output_dir .name } " )
64+
65+ if workers <= 1 :
66+ for pvp_config , output_dir in jobs :
67+ run_pair (pvp_config , output_dir )
68+ else :
69+ with ThreadPoolExecutor (max_workers = workers ) as executor :
70+ futures = [executor .submit (run_pair , c , d ) for c , d in jobs ]
71+ for f in as_completed (futures ):
72+ f .result ()
4973
5074
5175@ladder_app .command ("run" )
0 commit comments