Skip to content

Commit c6cd11f

Browse files
committed
Merge branch 'main' into john/robocode-ladder
2 parents d590a9a + 441403b commit c6cd11f

2 files changed

Lines changed: 321 additions & 232 deletions

File tree

codeclash/cli/ladder.py

Lines changed: 22 additions & 232 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,17 @@
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
86
from pathlib import Path
97

108
import typer
119
import yaml
1210

1311
from 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
1713
from 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-
8415
ladder_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")
9330
def 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

Comments
 (0)