1- """`codeclash ladder` subcommands: build a ladder (make) and climb it (run)."""
1+ """`codeclash ladder` subcommands: build a ladder (make) and climb it (run).
2+
3+ Thin CLI adapter over :mod:`codeclash.tournaments.ladder`, which holds all ladder logic.
4+ """
25
3- import copy
4- import getpass
5- import json
6- import time
7- from concurrent .futures import ThreadPoolExecutor , as_completed
86from pathlib import Path
97
108import typer
119import yaml
1210
1311from codeclash import CONFIG_DIR
14- from codeclash .constants import LOCAL_LOG_DIR
15- from codeclash .tournaments .pvp import PvpTournament
16- from codeclash .utils .log import get_logger
12+ from codeclash .tournaments .ladder import LadderTournament , build_ladder , resolve_ladder_rules # noqa: F401
1713from codeclash .utils .yaml_utils import resolve_includes
1814
19- logger = get_logger ("ladder" )
20-
21-
22- def _player_slug (branch_init : str ) -> str :
23- """Turn a ``human/<author>/<bot>`` init branch into a bare, filesystem-safe player name:
24- strip the ``human/`` prefix and join the rest with ``_`` (e.g. ``human/aleksiy325/snek-two``
25- -> ``aleksiy325_snek-two``).
26-
27- RoboCode also uses this name as a Java *package* (``<name>.MyTank``), which must be a bare
28- identifier that does not start with the engine's reserved ``robocode`` namespace. The RoboCode
29- ladder branches are named so the plain slug already satisfies that (``human/robo_code/walls``
30- -> ``robo_code__walls``), so no game-specific handling is needed here.
31- """
32- return branch_init .replace ("human/" , "" ).replace ("/" , "__" )
33-
34-
35- def _resolve_ladder_rules (ladder_rules : dict , rounds : int ) -> tuple [int , int ]:
36- """Validate the required ``ladder_rules`` block and return ``(min_round_wins, win_last_k)``.
37-
38- Both keys must be specified explicitly in the config (no defaults):
39- - ``min_round_wins``: the whole number of *agent* rounds the player must win to advance
40- (a ``>=`` threshold). Must be ``1 <= min_round_wins <= rounds``.
41- - ``win_last_k``: the player must win the last ``win_last_k`` round(s). ``1`` means just the final
42- round; ``0`` disables the trailing-rounds requirement entirely. Must be ``<= min_round_wins``.
43-
44- The baseline round 0 (identical, un-edited codebases) is excluded from the count — it reflects
45- game variance, not the agent — so wins are counted over the ``rounds`` rounds the agent actually
46- edits (rounds 1..``rounds``).
47- """
48- if "min_round_wins" not in ladder_rules :
49- typer .echo ("ladder_rules.min_round_wins is required; specify it explicitly in the config." )
50- raise typer .Exit (1 )
51- if "win_last_k" not in ladder_rules :
52- typer .echo ("ladder_rules.win_last_k is required; specify it explicitly in the config." )
53- raise typer .Exit (1 )
54- min_round_wins = ladder_rules ["min_round_wins" ]
55- win_last_k = ladder_rules ["win_last_k" ]
56-
57- # min_round_wins: whole number of agent rounds the player must win (round 0 excluded).
58- if isinstance (min_round_wins , bool ) or not isinstance (min_round_wins , int ):
59- typer .echo (f"ladder_rules.min_round_wins must be an integer, got { min_round_wins !r} ." )
60- raise typer .Exit (1 )
61- if not 1 <= min_round_wins <= rounds :
62- typer .echo (f"ladder_rules.min_round_wins must be in [1, { rounds } ] (tournament.rounds), got { min_round_wins } ." )
63- raise typer .Exit (1 )
64-
65- # win_last_k: number of trailing rounds the player must win (1 == just the final round, 0 == disabled).
66- if isinstance (win_last_k , bool ) or not isinstance (win_last_k , int ):
67- typer .echo (f"ladder_rules.win_last_k must be an integer, got { win_last_k !r} ." )
68- raise typer .Exit (1 )
69- if win_last_k < 0 :
70- typer .echo (
71- f"ladder_rules.win_last_k must be >= 0, got { win_last_k } . "
72- "Use 0 to disable the trailing-rounds requirement, or 1 to require winning only the final round."
73- )
74- raise typer .Exit (1 )
75- if win_last_k > min_round_wins :
76- typer .echo (
77- f"ladder_rules.win_last_k ({ win_last_k } ) cannot exceed ladder_rules.min_round_wins ({ min_round_wins } )."
78- )
79- raise typer .Exit (1 )
80-
81- return min_round_wins , win_last_k
82-
83-
8415ladder_app = typer .Typer (
8516 no_args_is_help = True ,
8617 add_completion = False ,
@@ -89,6 +20,12 @@ def _resolve_ladder_rules(ladder_rules: dict, rounds: int) -> tuple[int, int]:
8920)
9021
9122
23+ def _load_config (config_path : Path ) -> dict :
24+ yaml_content = config_path .read_text ()
25+ preprocessed_yaml = resolve_includes (yaml_content , base_dir = CONFIG_DIR )
26+ return yaml .safe_load (preprocessed_yaml )
27+
28+
9229@ladder_app .command ("make" )
9330def make (
9431 config_path : Path = typer .Argument (..., help = "Path to the ladder (round-robin) config file." ),
@@ -100,46 +37,7 @@ def make(
10037
10138 [dim]• codeclash ladder make configs/ladder/make_battlesnake.yaml[/dim]
10239 """
103- yaml_content = config_path .read_text ()
104- preprocessed_yaml = resolve_includes (yaml_content , base_dir = CONFIG_DIR )
105- config = yaml .safe_load (preprocessed_yaml )
106-
107- players = config ["players" ]
108- num_players = len (players )
109-
110- # Build one fully independent (deep-copied) config per pair up front so concurrent runs
111- # never share or mutate the same player/config dicts.
112- jobs : list [tuple [dict , Path ]] = []
113- for i in range (num_players ):
114- for j in range (i + 1 , num_players ):
115- player1 = copy .deepcopy (players [i ])
116- player1 ["name" ] = _player_slug (player1 ["branch_init" ])
117- player2 = copy .deepcopy (players [j ])
118- player2 ["name" ] = _player_slug (player2 ["branch_init" ])
119- pvp_config = {** copy .deepcopy (config ), "players" : [player1 , player2 ]}
120- vs = f"PvpTournament.{ player1 ['name' ]} _vs_{ player2 ['name' ]} " .replace ("/" , "_" )
121- output_dir = LOCAL_LOG_DIR / "ladder" / config ["game" ]["name" ] / vs
122- jobs .append ((pvp_config , output_dir ))
123-
124- def run_pair (pvp_config : dict , output_dir : Path ) -> None :
125- try :
126- tournament = PvpTournament (pvp_config , output_dir = output_dir )
127- except FileExistsError :
128- return # already completed by a previous invocation
129- # A single failing pair must not abort the rest of a long round-robin.
130- try :
131- tournament .run ()
132- except Exception :
133- logger .exception (f"Pair failed, skipping: { output_dir .name } " )
134-
135- if workers <= 1 :
136- for pvp_config , output_dir in jobs :
137- run_pair (pvp_config , output_dir )
138- else :
139- with ThreadPoolExecutor (max_workers = workers ) as executor :
140- futures = [executor .submit (run_pair , c , d ) for c , d in jobs ]
141- for f in as_completed (futures ):
142- f .result ()
40+ build_ladder (_load_config (config_path ), workers = workers )
14341
14442
14543@ladder_app .command ("run" )
@@ -156,124 +54,16 @@ def run(
15654
15755 [dim]• codeclash ladder run path/to/ladder_config.yaml -c # clean up after each rung[/dim]
15856 """
159- yaml_content = config_path .read_text ()
160- preprocessed_yaml = resolve_includes (yaml_content , base_dir = CONFIG_DIR )
161- config = yaml .safe_load (preprocessed_yaml )
162- ladder , player , rounds , sims = (
163- config ["ladder" ],
164- config ["player" ],
165- config ["tournament" ]["rounds" ],
166- config ["game" ]["sims_per_round" ],
167- )
168- min_round_wins , win_last_k = _resolve_ladder_rules (config .get ("ladder_rules" , {}), rounds )
169- timestamp = time .strftime ("%y%m%d%H%M%S" )
170- del config ["player" ]
171- del config ["ladder" ]
172- config .pop ("ladder_rules" , None )
173-
174- last_k_rule = "disabled" if win_last_k == 0 else f"win the last { win_last_k } round(s)"
175- advancement_rule = (
176- f"Ladder advancement rule: win >= { min_round_wins } of { rounds } agent rounds "
177- f"(baseline round 0 excluded) and { last_k_rule } ."
178- )
179- print (advancement_rule )
180- logger .info (advancement_rule )
181- ladder_folder = f"LadderTournament.{ config ['game' ]['name' ]} .r{ rounds } .s{ sims } .{ player ['name' ]} .{ timestamp } "
182- player ["branch" ] = ladder_folder
183- parent_dir = LOCAL_LOG_DIR / getpass .getuser () / ladder_folder
184-
185- rungs_cleared = 0
186- advanced = False
187- for idx , opponent in enumerate (ladder ):
188- opponent_rank = len (ladder ) - idx
189- opponent ["name" ] = _player_slug (opponent ["branch_init" ])
190- if "branch_init" in player and idx > 0 :
191- # After first opponent, remove branch_init so that player continues from previous tournament's codebase
192- del player ["branch_init" ]
193- c = {
194- ** config ,
195- "players" : [
196- player ,
197- opponent ,
198- ],
199- }
200-
201- players = [p ["name" ] for p in c ["players" ]]
202- p_num = len (players )
203- p_list = "." .join (players )
204- suffix_part = f".{ suffix } " if suffix else ""
205- folder_name = f"PvpTournament.{ c ['game' ]['name' ]} .r{ rounds } .s{ sims } .p{ p_num } .{ p_list } { suffix_part } "
206-
207- tournament_dir = parent_dir / folder_name if output_dir is None else output_dir / folder_name
208- tournament = PvpTournament (
209- c ,
210- output_dir = tournament_dir ,
57+ config = _load_config (config_path )
58+ try :
59+ tournament = LadderTournament (
60+ config ,
61+ output_dir = output_dir ,
62+ suffix = suffix ,
21163 cleanup = cleanup ,
21264 keep_containers = keep_containers ,
21365 )
214- tournament .run ()
215-
216- # Get results
217- metadata_path = tournament_dir / "metadata.json"
218- with open (metadata_path ) as f :
219- metadata = yaml .safe_load (f )
220- round_winners = [r ["winner" ] for k , r in metadata ["round_stats" ].items () if int (k ) != 0 ]
221-
222- # Advancement rule (required via `ladder_rules`): win at least `min_round_wins` of the
223- # agent rounds AND win the last `win_last_k` rounds. win_last_k == 0 disables the
224- # trailing-rounds requirement.
225- player_wins = sum (1 for w in round_winners if w == player ["name" ])
226- won_majority = player_wins >= min_round_wins
227- won_last_k = win_last_k == 0 or all (w == player ["name" ] for w in round_winners [- win_last_k :])
228- advanced = won_majority and won_last_k
229-
230- # Record this rung's outcome in its metadata.json (durable gameplay log). The rule itself
231- # (min_round_wins, win_last_k) is constant across the run and lives in the ladder summary.
232- metadata ["ladder_advancement" ] = {
233- "player_wins" : player_wins ,
234- "won_last_k" : won_last_k ,
235- "cleared" : advanced ,
236- }
237- with open (metadata_path , "w" ) as f :
238- json .dump (metadata , f , indent = 2 )
239-
240- if not advanced :
241- # Player failed the advancement rule; the ladder challenge ends here.
242- print ("=" * 10 )
243- print (
244- f"{ player ['name' ]} did not clear { opponent ['name' ]} "
245- f"(rank { opponent_rank } /{ len (ladder )} ): won { player_wins } /{ len (round_winners )} agent rounds "
246- f"(needed >= { min_round_wins } ), last { win_last_k } round(s) won: { won_last_k } .\n "
247- "Ladder challenge ends."
248- )
249- print ("=" * 10 )
250- break
251-
252- rungs_cleared += 1
253- print ("=" * 10 )
254- print (
255- f"{ player ['name' ]} successfully beat { opponent ['name' ]} (rank { opponent_rank } /{ len (ladder )} ) "
256- f"in { player_wins } /{ len (round_winners )} rounds.\n "
257- "Ladder challenge continuing"
258- )
259- print ("=" * 10 )
260-
261- # Persist the overall climb result to a ladder-level metadata.json in the run's parent dir.
262- ladder_summary = {
263- "player" : player ["name" ],
264- "game" : config ["game" ]["name" ],
265- "rounds" : rounds ,
266- "min_round_wins" : min_round_wins ,
267- "win_last_k" : win_last_k ,
268- "ladder_size" : len (ladder ),
269- "rungs_cleared" : rungs_cleared ,
270- "final_opponent" : opponent ["name" ],
271- "final_opponent_rank" : opponent_rank ,
272- "cleared_ladder" : rungs_cleared == len (ladder ),
273- }
274- parent_dir .mkdir (parents = True , exist_ok = True )
275- with open (parent_dir / "metadata.json" , "w" ) as f :
276- json .dump (ladder_summary , f , indent = 2 )
277-
278- print (f"Ladder tournament complete. Logs saved to { parent_dir } " )
279- print (f"Final opponent faced: { opponent ['name' ]} (rank { opponent_rank } /{ len (ladder )} in ladder)" )
66+ except ValueError as e :
67+ typer .echo (str (e ))
68+ raise typer .Exit (1 )
69+ tournament .run ()
0 commit comments