Skip to content

Commit 99a3ee6

Browse files
committed
Merge branch 'main' of https://github.com/CodeClash-ai/CodeClash into ants
# Conflicts: # codeclash/replay/__init__.py
2 parents 34d64f5 + 3fc2249 commit 99a3ee6

10 files changed

Lines changed: 389 additions & 14 deletions

File tree

codeclash/agents/player.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ def __init__(
3030
self.environment = environment
3131
self.game_context = game_context
3232
self.push = config.get("push", False)
33+
# Force the branch push (used on ladder resume, to overwrite an interrupted rung's partial
34+
# pushes). Safe here because a run's push branch has a single writer. Uses --force-with-lease.
35+
self._force_push = config.get("force_push", False)
3336
self.logger = get_logger(
3437
self.name,
3538
log_path=self.game_context.log_local / "players" / self.name / "player.log",
@@ -140,8 +143,9 @@ def post_run_hook(self, *, round: int) -> None:
140143
self._write_changes_to_file(round=round)
141144

142145
if self.push:
146+
force = " --force-with-lease" if self._force_push else ""
143147
for cmd in [
144-
f"git push -u origin {self._branch_name}",
148+
f"git push{force} -u origin {self._branch_name}",
145149
"git push origin --tags",
146150
]:
147151
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)

codeclash/arenas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from codeclash.arenas.halite2.halite2 import Halite2Arena
1717
from codeclash.arenas.halite3.halite3 import Halite3Arena
1818
from codeclash.arenas.huskybench.huskybench import HuskyBenchArena
19+
from codeclash.arenas.lightcycles.lightcycles import LightCyclesArena
1920
from codeclash.arenas.paintvolley.paintvolley import PaintVolleyArena
2021
from codeclash.arenas.robocode.robocode import RoboCodeArena
2122
from codeclash.arenas.robotrumble.robotrumble import RobotRumbleArena
@@ -39,6 +40,7 @@
3940
Halite2Arena,
4041
Halite3Arena,
4142
HuskyBenchArena,
43+
LightCyclesArena,
4244
PaintVolleyArena,
4345
RoboCodeArena,
4446
RobotRumbleArena,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
FROM ubuntu:22.04
2+
3+
ENV DEBIAN_FRONTEND=noninteractive
4+
5+
# Install Python 3.10, pip, and prerequisites
6+
RUN apt-get update \
7+
&& apt-get install -y --no-install-recommends \
8+
curl ca-certificates python3.10 python3.10-venv \
9+
python3-pip python-is-python3 wget git build-essential jq curl locales \
10+
&& rm -rf /var/lib/apt/lists/*
11+
12+
# Clone LightCycles game repository
13+
RUN git clone https://github.com/CodeClash-ai/LightCycles.git /workspace \
14+
&& cd /workspace \
15+
&& git remote set-url origin https://github.com/CodeClash-ai/LightCycles.git
16+
WORKDIR /workspace
17+
18+
# No additional dependencies needed - engine uses only the standard library

codeclash/arenas/lightcycles/__init__.py

Whitespace-only changes.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import re
2+
3+
from codeclash.agents.player import Player
4+
from codeclash.arenas.arena import CodeArena, RoundStats
5+
from codeclash.constants import RESULT_TIE
6+
from codeclash.utils.environment import assert_zero_exit_code
7+
8+
LIGHTCYCLES_LOG = "result.log"
9+
10+
11+
class LightCyclesArena(CodeArena):
12+
name: str = "LightCycles"
13+
submission: str = "main.py"
14+
description: str = """Your bot (`main.py`) drives a cycle in LightCycles, a Tron light-cycles game. Each
15+
player rides around a bordered grid leaving a solid trail behind. The board may contain static rock
16+
obstacles too. Every tick all cycles move one cell simultaneously; you crash (and are eliminated) if you
17+
move into a wall/border, a rock, any trail (yours or an opponent's), or the same cell as another cycle (a
18+
head-on). The last cycle riding wins; if all remaining cycles crash on the same tick it's a draw; at the
19+
tick cap the most territory (trail cells) wins.
20+
21+
Your bot must implement:
22+
def get_move(obs: dict) -> str
23+
24+
Return one of "N", "S", "E", "W" (up/down/right/left). Reversing into your own neck is ignored (you go
25+
straight); an invalid/crashing/slow move also makes you continue straight.
26+
27+
`obs` gives the full deterministic state each tick: obs["tick"]/["max_ticks"], obs["width"]/["height"],
28+
obs["you"] (your id), obs["players"] (list of {id, x, y, dir, alive}), and obs["grid"]
29+
(grid[y][x] = player id >=0, -1 empty, or -2 rock; off-grid is a wall). Origin is top-left; N is -y.
30+
"""
31+
32+
def __init__(self, config, **kwargs):
33+
super().__init__(config, **kwargs)
34+
assert len(config["players"]) >= 2, "LightCycles needs at least two players"
35+
36+
def execute_round(self, agents: list[Player]) -> None:
37+
args = [f"/{agent.name}/{self.submission}" for agent in agents]
38+
cmd = (
39+
f"python engine.py {' '.join(args)} -r {self.game_config['sims_per_round']} "
40+
f"-o {self.log_env} > {self.log_env / LIGHTCYCLES_LOG};"
41+
)
42+
self.logger.info(f"Running game: {cmd}")
43+
assert_zero_exit_code(self.environment.execute(cmd))
44+
45+
def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
46+
with open(self.log_round(round_num) / LIGHTCYCLES_LOG) as f:
47+
round_log = f.read()
48+
lines = round_log.split("FINAL_RESULTS")[-1].splitlines()
49+
50+
# Engine prints: "Bot_<i>: <wins> games won (<name>)". Score = games won.
51+
scores: dict[str, float] = {}
52+
for line in lines:
53+
match = re.search(r"Bot_(\d+):\s+(\d+)\s+games\s+won", line)
54+
if match:
55+
bot_id = int(match.group(1))
56+
wins = int(match.group(2))
57+
if 1 <= bot_id <= len(agents):
58+
scores[agents[bot_id - 1].name] = wins
59+
60+
draw_match = re.search(r"Draws:\s+(\d+)", round_log)
61+
if draw_match and int(draw_match.group(1)) > 0:
62+
scores[RESULT_TIE] = int(draw_match.group(1))
63+
64+
if scores:
65+
real = {k: v for k, v in scores.items() if k != RESULT_TIE}
66+
max_score = max(real.values()) if real else 0
67+
winners = [k for k, v in real.items() if v == max_score]
68+
stats.winner = winners[0] if len(winners) == 1 else RESULT_TIE
69+
else:
70+
stats.winner = "unknown"
71+
72+
stats.scores = scores
73+
for player, score in scores.items():
74+
if player != RESULT_TIE:
75+
stats.player_stats[player].score = score
76+
77+
def validate_code(self, agent: Player) -> tuple[bool, str | None]:
78+
if self.submission not in agent.environment.execute("ls")["output"]:
79+
return False, f"No {self.submission} file found in the root directory"
80+
81+
bot_content = agent.environment.execute(f"cat {self.submission}")["output"]
82+
if "def get_move(" not in bot_content:
83+
return (
84+
False,
85+
f"{self.submission} must define a get_move(obs) function. "
86+
"See the game description for the required signature.",
87+
)
88+
89+
return True, None
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""LightCycles replay renderer.
2+
3+
Parses a per-game ``sim_*.json`` written by the engine and renders the Tron grid:
4+
each cycle's growing trail plus its bright head, with a side panel of who's alive.
5+
6+
The JSON format (see engine.py ``write_replay``)::
7+
8+
{"width":40, "height":30, "num_players":2, "names":["p1","p2"],
9+
"winner": 0 | null,
10+
"frames":[{"t":0, "heads":[[x,y,alive], ...]}, ...]}
11+
12+
Frames store only each cycle's head (``[x, y, alive]``, ``alive`` is 1/0) per tick;
13+
the trail for a player is the set of every head cell it has occupied so far, which
14+
the renderer rebuilds by accumulating heads up to the frame being shown. ``winner``
15+
is the winning player id, or ``null`` for a draw.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import json
21+
22+
from codeclash.replay.base import ReplayData, ReplayRenderer
23+
24+
DRAW_JS = """
25+
const ARENA = (function(){
26+
const PAL = ['#e5484d','#4593ff','#46a758','#f5d90a','#8e4ec6','#f76b15','#e93d82','#12a594'];
27+
const BG = '#0d1117', GRID_LINE = 'rgba(255,255,255,0.05)', ROCK_COL = '#4b5563';
28+
let W, H, CELL;
29+
function col(i){ return PAL[i % PAL.length]; }
30+
function setup(cv, G){
31+
W = G.w; H = G.h;
32+
CELL = Math.max(6, Math.min(20, Math.floor(640 / W)));
33+
cv.width = W * CELL; cv.height = H * CELL;
34+
}
35+
function draw(ctx, cv, G, i){
36+
ctx.fillStyle = BG; ctx.fillRect(0, 0, cv.width, cv.height);
37+
// faint grid
38+
ctx.strokeStyle = GRID_LINE; ctx.lineWidth = 1;
39+
for(let x=0;x<=W;x++){ ctx.beginPath(); ctx.moveTo(x*CELL,0); ctx.lineTo(x*CELL,cv.height); ctx.stroke(); }
40+
for(let y=0;y<=H;y++){ ctx.beginPath(); ctx.moveTo(0,y*CELL); ctx.lineTo(cv.width,y*CELL); ctx.stroke(); }
41+
// static rock obstacles
42+
ctx.fillStyle = ROCK_COL;
43+
(G.rocks || []).forEach(r => ctx.fillRect(r[0]*CELL, r[1]*CELL, CELL, CELL));
44+
const n = (G.frames[0].heads || []).length;
45+
// rebuild trails by accumulating every head cell up to frame i
46+
for(let p=0;p<n;p++){
47+
ctx.fillStyle = col(p); ctx.globalAlpha = 0.55;
48+
for(let f=0;f<=i;f++){
49+
const h = G.frames[f].heads[p];
50+
ctx.fillRect(h[0]*CELL, h[1]*CELL, CELL, CELL);
51+
}
52+
ctx.globalAlpha = 1;
53+
}
54+
// heads on top (bright, outlined); crashed cycles get an X
55+
const fr = G.frames[i];
56+
for(let p=0;p<n;p++){
57+
const h = fr.heads[p], hx = h[0]*CELL, hy = h[1]*CELL;
58+
ctx.fillStyle = col(p);
59+
ctx.fillRect(hx, hy, CELL, CELL);
60+
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2;
61+
ctx.strokeRect(hx+1, hy+1, CELL-2, CELL-2);
62+
if(!h[2]){ // crashed
63+
ctx.strokeStyle = '#0d1117'; ctx.lineWidth = 2;
64+
ctx.beginPath();
65+
ctx.moveTo(hx+3, hy+3); ctx.lineTo(hx+CELL-3, hy+CELL-3);
66+
ctx.moveTo(hx+CELL-3, hy+3); ctx.lineTo(hx+3, hy+CELL-3);
67+
ctx.stroke();
68+
}
69+
}
70+
}
71+
function side(G, i){
72+
const fr = G.frames[i], NM = G.names || [], n = fr.heads.length;
73+
const done = i === G.frames.length - 1;
74+
let rows = '';
75+
for(let p=0;p<n;p++){
76+
const alive = fr.heads[p][2];
77+
// territory = distinct cells this cycle has occupied through frame i
78+
const cells = new Set();
79+
for(let f=0;f<=i;f++){ const h=G.frames[f].heads[p]; cells.add(h[0]+','+h[1]); }
80+
rows += `<div class="team ${alive?'':'tdead'}"><div class="tname">`
81+
+ `<span class="sw" style="background:${col(p)}"></span>`
82+
+ `${NM[p] || ('player'+(p+1))} ${alive?'':'&#10007;'}</div>`
83+
+ `<div class="stat"><span>trail</span><b>${cells.size}</b></div></div>`;
84+
}
85+
const res = done ? (G.draw ? 'DRAW' : (G.winner || '\\u2014')) : '';
86+
return rows
87+
+ `<div class="stat"><span>tick</span><b>${fr.turn} / ${G.max_ticks||''}</b></div>`
88+
+ (done ? `<div class="stat"><span>result</span><b>${res}</b></div>` : '');
89+
}
90+
return {setup, draw, side};
91+
})();
92+
"""
93+
94+
95+
class LightCyclesReplayer(ReplayRenderer):
96+
arena = "LightCycles"
97+
sim_glob = "sim_*.json"
98+
DRAW_JS = DRAW_JS
99+
100+
def parse(self, raw: bytes, players: list[dict] | None = None) -> ReplayData:
101+
log = json.loads(raw.decode(errors="replace"))
102+
w = log.get("width", 40)
103+
h = log.get("height", 30)
104+
n = log.get("num_players", 2)
105+
106+
names = list(log.get("names", []))
107+
if players:
108+
names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)]
109+
while len(names) < n:
110+
names.append(f"player{len(names) + 1}")
111+
112+
frames = [{"turn": fr.get("t", idx), "heads": fr["heads"]} for idx, fr in enumerate(log.get("frames", []))]
113+
114+
win = log.get("winner")
115+
draw = win is None
116+
winner = None if draw else names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win)
117+
118+
return ReplayData(
119+
w=w,
120+
h=h,
121+
frames=frames,
122+
winner=winner,
123+
draw=draw,
124+
extra={
125+
"names": names,
126+
"num_players": n,
127+
"max_ticks": log.get("max_ticks"),
128+
"rocks": log.get("rocks", []),
129+
},
130+
)
131+
132+
def peek_winner(self, raw: bytes, players: list[dict] | None = None) -> tuple[str | None, bool] | None:
133+
log = json.loads(raw.decode(errors="replace"))
134+
win = log.get("winner")
135+
if win is None:
136+
return (None, True)
137+
names = list(log.get("names", []))
138+
if players:
139+
names = [p.get("name", names[i] if i < len(names) else f"player{i + 1}") for i, p in enumerate(players)]
140+
name = names[win] if isinstance(win, int) and 0 <= win < len(names) else str(win)
141+
return (name, False)

codeclash/cli/ladder.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,18 @@ def run(
4949
keep_containers: bool = typer.Option(
5050
False, "--keep-containers", "-k", help="Do not remove containers after games/agent finish."
5151
),
52+
resume_from: Path | None = typer.Option(
53+
None,
54+
"--resume",
55+
"-r",
56+
help="Resume an interrupted run: pass its log dir. Skips cleared rungs and seeds from the "
57+
"last cleared rung's pushed codebase. Requires push: True and the same config.",
58+
),
5259
):
5360
"""Send a model up a ranked ladder, rung by rung, until it loses.
5461
5562
[dim]• codeclash ladder run path/to/ladder_config.yaml -c # clean up after each rung[/dim]
63+
[dim]• codeclash ladder run path/to/ladder_config.yaml -r logs/<user>/LadderTournament... # resume[/dim]
5664
"""
5765
config = _load_config(config_path)
5866
try:
@@ -62,6 +70,7 @@ def run(
6270
suffix=suffix,
6371
cleanup=cleanup,
6472
keep_containers=keep_containers,
73+
resume_from=resume_from,
6574
)
6675
except ValueError as e:
6776
typer.echo(str(e))

codeclash/replay/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ def get_replayer(arena: str) -> ReplayRenderer | None:
6161
from codeclash.arenas.paintvolley.replay import PaintVolleyReplayer
6262

6363
return PaintVolleyReplayer()
64+
if arena == "LightCycles":
65+
from codeclash.arenas.lightcycles.replay import LightCyclesReplayer
66+
67+
return LightCyclesReplayer()
6468
if arena == "Ants":
6569
from codeclash.arenas.ants.replay import AntsReplayer
6670

0 commit comments

Comments
 (0)