Skip to content

Commit b29d0d6

Browse files
committed
Add replay subcommand
1 parent 42d0900 commit b29d0d6

14 files changed

Lines changed: 931 additions & 953 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""BattleSnake replay renderer.
2+
3+
Parses a recorded ``sim_*.jsonl`` (v1 API state frames) into normalized playback data
4+
and draws the board — snakes with heads/eyes, food, hazards, per-snake health.
5+
Ported from the former ``scripts/replay_battlesnake.py``.
6+
7+
The jsonl format: per-turn v1 state frames ({game, turn, board, you}) and a final result
8+
line ({winnerName, isDraw}).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
15+
from codeclash.replay.base import ReplayData, ReplayRenderer
16+
17+
PALETTE = ["#3B78FF", "#E5484D", "#30A46C", "#F5A623", "#8E4EC6", "#12A594", "#E93D82", "#F76B15"]
18+
19+
DRAW_JS = """
20+
const ARENA = (function(){
21+
let W, H, CELL, PAD, px, py;
22+
function setup(cv, G){
23+
W = G.w; H = G.h;
24+
CELL = Math.max(18, Math.min(44, Math.floor(560 / Math.max(W, H)))); PAD = CELL * 0.12;
25+
cv.width = W * CELL; cv.height = H * CELL;
26+
px = (x) => x * CELL; py = (y) => (H - 1 - y) * CELL; // v1 y-up -> canvas y-down
27+
}
28+
function draw(ctx, cv, G, i){
29+
const f = G.frames[i], COL = G.colors;
30+
ctx.clearRect(0, 0, cv.width, cv.height);
31+
ctx.strokeStyle = '#21262d';
32+
for(let x=0;x<=W;x++){ctx.beginPath();ctx.moveTo(x*CELL,0);ctx.lineTo(x*CELL,H*CELL);ctx.stroke();}
33+
for(let y=0;y<=H;y++){ctx.beginPath();ctx.moveTo(0,y*CELL);ctx.lineTo(W*CELL,y*CELL);ctx.stroke();}
34+
f.hazards.forEach(([x,y])=>{ctx.fillStyle='rgba(245,166,35,0.15)';ctx.fillRect(px(x),py(y),CELL,CELL);});
35+
f.food.forEach(([x,y])=>{ctx.fillStyle='#ff5252';ctx.beginPath();ctx.arc(px(x)+CELL/2,py(y)+CELL/2,CELL*0.22,0,7);ctx.fill();});
36+
f.snakes.forEach(s=>{
37+
const c = COL[s.name] || '#888';
38+
s.body.forEach(([x,y],j)=>{
39+
ctx.fillStyle=c; ctx.globalAlpha=j===0?1:0.85;
40+
const r=j===0?CELL*0.5:CELL*0.32;
41+
ctx.beginPath(); ctx.roundRect(px(x)+PAD,py(y)+PAD,CELL-2*PAD,CELL-2*PAD, r); ctx.fill();
42+
});
43+
ctx.globalAlpha=1;
44+
const [hx,hy]=s.body[0]; ctx.fillStyle='#0d1117';
45+
ctx.beginPath();ctx.arc(px(hx)+CELL*0.62,py(hy)+CELL*0.38,CELL*0.08,0,7);ctx.fill();
46+
});
47+
}
48+
function side(G, i){
49+
const f = G.frames[i], COL = G.colors;
50+
const alive = new Set(f.snakes.map(s=>s.name));
51+
return Object.keys(COL).map(nm=>{
52+
const s = f.snakes.find(x=>x.name===nm); const hp = s?s.health:0; const dead = !alive.has(nm);
53+
return `<div class="sn ${dead?'dead':''}"><span class="sw" style="background:${COL[nm]}"></span>
54+
<span style="min-width:80px">${nm}</span>
55+
<span class="hb"><span class="hf" style="width:${hp}%;background:${COL[nm]}"></span></span>
56+
<span>${dead?'\\u2620':hp}</span></div>`;
57+
}).join('');
58+
}
59+
return {setup, draw, side};
60+
})();
61+
"""
62+
63+
64+
class BattleSnakeReplayer(ReplayRenderer):
65+
arena = "BattleSnake"
66+
sim_glob = "sim_*.jsonl"
67+
DRAW_JS = DRAW_JS
68+
69+
def parse(self, raw: bytes, players=None) -> ReplayData:
70+
rows = [json.loads(line) for line in raw.decode().splitlines() if line.strip()]
71+
# per-turn board states (dedupe: keep the last frame seen for each turn)
72+
by_turn = {}
73+
for r in rows:
74+
if isinstance(r, dict) and "board" in r and "turn" in r:
75+
by_turn[r["turn"]] = r["board"]
76+
turns = sorted(by_turn)
77+
result = next((r for r in reversed(rows) if isinstance(r, dict) and "winnerName" in r), {})
78+
if not turns:
79+
return ReplayData(w=0, h=0, frames=[], winner=result.get("winnerName"), draw=result.get("isDraw", False))
80+
81+
# stable color per snake name (prefer the snake's own color from the log if present)
82+
names, colors = [], {}
83+
for t in turns:
84+
for s in by_turn[t]["snakes"]:
85+
if s["name"] not in names:
86+
names.append(s["name"])
87+
for i, nm in enumerate(names):
88+
colors[nm] = PALETTE[i % len(PALETTE)]
89+
for t in turns:
90+
for s in by_turn[t]["snakes"]:
91+
if s.get("color"):
92+
colors[s["name"]] = s["color"]
93+
94+
b0 = by_turn[turns[0]]
95+
frames = []
96+
for t in turns:
97+
b = by_turn[t]
98+
frames.append(
99+
{
100+
"turn": t,
101+
"food": [[c["x"], c["y"]] for c in b.get("food", [])],
102+
"hazards": [[c["x"], c["y"]] for c in b.get("hazards", [])],
103+
"snakes": [
104+
{
105+
"name": s["name"],
106+
"health": s.get("health", 0),
107+
"body": [[c["x"], c["y"]] for c in s["body"]],
108+
}
109+
for s in b["snakes"]
110+
],
111+
}
112+
)
113+
return ReplayData(
114+
w=b0["width"],
115+
h=b0["height"],
116+
frames=frames,
117+
winner=result.get("winnerName"),
118+
draw=result.get("isDraw", False),
119+
extra={"colors": colors},
120+
)
121+
122+
def ascii(self, data: ReplayData) -> str:
123+
out = []
124+
for f in data.frames:
125+
grid = [["." for _ in range(data.w)] for _ in range(data.h)]
126+
for fx, fy in f["food"]:
127+
grid[fy][fx] = "*"
128+
for s in f["snakes"]:
129+
ch = s["name"][0].upper()
130+
for j, (x, y) in enumerate(s["body"]):
131+
grid[y][x] = ch if j else ch.lower() # head lowercase-ish marker
132+
out.append(f"\n--- turn {f['turn']} --- " + " ".join(f"{s['name']}:{s['health']}" for s in f["snakes"]))
133+
for row in reversed(grid): # y-up: print top row last
134+
out.append(" ".join(row))
135+
out.append(f"\nWinner: {'TIE' if data.draw else data.winner}")
136+
return "\n".join(out)
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""RobotRumble replay renderer.
2+
3+
Parses a raw RobotRumble sim JSON (``rumblebot run term --raw``) into normalized playback
4+
data and draws the grid — terrain/walls, units colored by team with health-as-brightness,
5+
move/attack indicators, per-team unit + health totals. Ported from the former
6+
``scripts/replay_robotrumble.py``.
7+
8+
The raw JSON: a single object {winner, errors, turns:[...]}. Each turn has ``state.objs``
9+
(grid objects: Terrain/Wall and Unit/Soldier, each with ``coords`` [x,y], ``team``,
10+
``health``), a ``state.turn`` index, and ``robot_actions`` (unit id ->
11+
{"Ok": {"type": "Move"|"Attack", "direction": ...}} or {"Ok": "None"}). Blue is the first
12+
(blue) bot, Red is the second.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import json
18+
19+
from codeclash.replay.base import ReplayData, ReplayRenderer
20+
21+
MAX_HEALTH = 5
22+
TEAM_COLORS = {"Blue": "#3B78FF", "Red": "#E5484D"}
23+
24+
DRAW_JS = """
25+
const ARENA = (function(){
26+
let W, H, CELL, PAD, COL, NAMES, MAXHP, WALLS, px, py;
27+
const DIRV = {North:[0,-1], South:[0,1], East:[1,0], West:[-1,0]};
28+
function setup(cv, G){
29+
W = G.w; H = G.h; COL = G.colors; NAMES = G.names; MAXHP = G.maxhp; WALLS = G.walls;
30+
CELL = Math.max(16, Math.min(40, Math.floor(640 / Math.max(W, H)))); PAD = CELL * 0.14;
31+
cv.width = W * CELL; cv.height = H * CELL;
32+
px = (x) => x * CELL; py = (y) => y * CELL; // RobotRumble: y=0 at top, render straight down
33+
}
34+
function draw(ctx, cv, G, i){
35+
const f = G.frames[i];
36+
ctx.clearRect(0, 0, cv.width, cv.height);
37+
ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1;
38+
for(let x=0;x<=W;x++){ctx.beginPath();ctx.moveTo(x*CELL,0);ctx.lineTo(x*CELL,H*CELL);ctx.stroke();}
39+
for(let y=0;y<=H;y++){ctx.beginPath();ctx.moveTo(0,y*CELL);ctx.lineTo(W*CELL,y*CELL);ctx.stroke();}
40+
ctx.fillStyle = '#2d333b';
41+
WALLS.forEach(([x,y])=>{ctx.fillRect(px(x)+1,py(y)+1,CELL-2,CELL-2);});
42+
f.units.forEach(u=>{
43+
const base = COL[u.team] || '#888';
44+
const frac = Math.max(0.28, u.hp / MAXHP); // health -> opacity (full hp brightest)
45+
ctx.globalAlpha = frac;
46+
ctx.fillStyle = base;
47+
const r = CELL * 0.28;
48+
ctx.beginPath(); ctx.roundRect(px(u.x)+PAD,py(u.y)+PAD,CELL-2*PAD,CELL-2*PAD,r); ctx.fill();
49+
ctx.globalAlpha = 1;
50+
const cx = px(u.x)+CELL/2, cy = py(u.y)+CELL/2;
51+
if(u.act === 'Attack'){
52+
ctx.strokeStyle = '#ffd21f'; ctx.lineWidth = 2;
53+
ctx.beginPath(); ctx.arc(cx,cy,CELL*0.42,0,7); ctx.stroke();
54+
const d = DIRV[u.dir]; if(d){
55+
ctx.fillStyle = '#ffd21f';
56+
ctx.beginPath(); ctx.arc(cx+d[0]*CELL*0.5, cy+d[1]*CELL*0.5, CELL*0.12,0,7); ctx.fill();
57+
}
58+
}
59+
if(u.act === 'Move'){
60+
const d = DIRV[u.dir];
61+
if(d){
62+
ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 2;
63+
ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(cx+d[0]*CELL*0.28, cy+d[1]*CELL*0.28); ctx.stroke();
64+
}
65+
}
66+
const bw = CELL-2*PAD, bx = px(u.x)+PAD, by = py(u.y)+CELL-PAD*0.5;
67+
ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fillRect(bx,by-3,bw,3);
68+
ctx.fillStyle = '#eafff0'; ctx.fillRect(bx,by-3,bw*(u.hp/MAXHP),3);
69+
});
70+
}
71+
function side(G, i){
72+
const f = G.frames[i], COL = G.colors, NAMES = G.names, MAXHP = G.maxhp;
73+
const agg = {Blue:{n:0,hp:0}, Red:{n:0,hp:0}};
74+
f.units.forEach(u=>{ if(agg[u.team]){ agg[u.team].n++; agg[u.team].hp += u.hp; } });
75+
return ['Blue','Red'].map(tm=>{
76+
const a = agg[tm], dead = a.n===0, maxhp = 8*MAXHP;
77+
return `<div class="team ${dead?'tdead':''}">
78+
<div class="tname"><span class="sw" style="background:${COL[tm]}"></span>${NAMES[tm]} <span class="muted">(${tm})</span></div>
79+
<div class="stat"><span>units</span><b>${a.n}</b></div>
80+
<div class="stat"><span>total health</span><b>${a.hp}</b></div>
81+
<div class="hb"><span class="hf" style="width:${Math.min(100,100*a.hp/maxhp)}%;background:${COL[tm]}"></span></div>
82+
</div>`;
83+
}).join('') + '<div class="row muted" style="font-size:12px">move arrow \\u00b7 \\u2726 attacking \\u00b7 health = brightness</div>';
84+
}
85+
return {setup, draw, side};
86+
})();
87+
"""
88+
89+
90+
class RobotRumbleReplayer(ReplayRenderer):
91+
arena = "RobotRumble"
92+
sim_glob = "sim*.json" # matches both sim_<i>.json and an ad-hoc flat sim.json
93+
DRAW_JS = DRAW_JS
94+
95+
def parse(self, raw: bytes, players=None) -> ReplayData:
96+
data = json.loads(raw.decode())
97+
turns_raw = data.get("turns", [])
98+
blue_name = players[0]["name"] if players and len(players) > 0 else None
99+
red_name = players[1]["name"] if players and len(players) > 1 else None
100+
101+
# Walls (terrain) are static across the game — grab them from the first frame.
102+
walls = []
103+
max_x = max_y = 0
104+
if turns_raw:
105+
for o in turns_raw[0]["state"]["objs"].values():
106+
x, y = o["coords"]
107+
max_x, max_y = max(max_x, x), max(max_y, y)
108+
if o["obj_type"] == "Terrain":
109+
walls.append([x, y])
110+
111+
frames = []
112+
for t in turns_raw:
113+
objs = t["state"]["objs"]
114+
actions = t.get("robot_actions") or {}
115+
units = []
116+
for oid, o in objs.items():
117+
if o["obj_type"] != "Unit":
118+
continue
119+
act = actions.get(oid)
120+
atype, adir = None, None
121+
if isinstance(act, dict) and "Ok" in act:
122+
ok = act["Ok"]
123+
if isinstance(ok, dict):
124+
atype, adir = ok.get("type"), ok.get("direction")
125+
units.append(
126+
{
127+
"team": o.get("team"),
128+
"hp": o.get("health", 0),
129+
"x": o["coords"][0],
130+
"y": o["coords"][1],
131+
"act": atype,
132+
"dir": adir,
133+
}
134+
)
135+
frames.append({"turn": t["state"].get("turn"), "units": units})
136+
137+
winner_raw = data.get("winner")
138+
names = {"Blue": blue_name or "Blue", "Red": red_name or "Red"}
139+
if winner_raw in ("Blue", "Red"):
140+
winner, draw = names[winner_raw], False
141+
else:
142+
winner, draw = None, True
143+
144+
return ReplayData(
145+
w=max_x + 1,
146+
h=max_y + 1,
147+
frames=frames,
148+
winner=winner,
149+
draw=draw,
150+
extra={
151+
"walls": walls,
152+
"names": names,
153+
"colors": TEAM_COLORS,
154+
"maxhp": MAX_HEALTH,
155+
"errors": data.get("errors", {}),
156+
},
157+
)
158+
159+
def ascii(self, data: ReplayData) -> str:
160+
W, H = data.w, data.h
161+
names = data.extra["names"]
162+
wall_set = {(x, y) for x, y in data.extra["walls"]}
163+
out = []
164+
for f in data.frames:
165+
grid = [["#" if (x, y) in wall_set else "." for x in range(W)] for y in range(H)]
166+
for u in f["units"]:
167+
grid[u["y"]][u["x"]] = "B" if u["team"] == "Blue" else "R"
168+
counts = {"Blue": 0, "Red": 0}
169+
hp = {"Blue": 0, "Red": 0}
170+
for u in f["units"]:
171+
counts[u["team"]] += 1
172+
hp[u["team"]] += u["hp"]
173+
out.append(
174+
f"\n--- turn {f['turn']} --- "
175+
f"{names['Blue']}(B): {counts['Blue']} units / {hp['Blue']} hp "
176+
f"{names['Red']}(R): {counts['Red']} units / {hp['Red']} hp"
177+
)
178+
for row in grid:
179+
out.append(" ".join(row))
180+
out.append(f"\nWinner: {'TIE' if data.draw else data.winner}")
181+
return "\n".join(out)

codeclash/cli/app.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
codeclash ladder make <config> build a ladder (round-robin ranking)
66
codeclash ladder run <config> send a model up a ranked ladder
77
codeclash rank {win-rate,elo,matrix} ... compute standings from logs
8+
codeclash replay <log-folder> browse/animate recorded games
89
"""
910

1011
import getpass
@@ -19,6 +20,7 @@
1920
from codeclash import CONFIG_DIR
2021
from codeclash.cli.ladder import ladder_app
2122
from codeclash.cli.rank import rank_app
23+
from codeclash.cli.replay import replay
2224
from codeclash.constants import LOCAL_LOG_DIR
2325
from codeclash.tournaments.pvp import PvpTournament
2426
from codeclash.utils.aws import is_running_in_aws_batch
@@ -27,11 +29,13 @@
2729
app = typer.Typer(
2830
no_args_is_help=True,
2931
add_completion=False,
32+
rich_markup_mode="rich", # enables the [dim] markup used in the Examples blocks
3033
context_settings={"help_option_names": ["-h", "--help"]},
3134
help="CodeClash: run coding-game tournaments, build ladders, and rank players.",
3235
)
3336
app.add_typer(ladder_app, name="ladder", help="Build and run CC:Ladder tournaments.")
3437
app.add_typer(rank_app, name="rank", help="Compute player standings from game logs.")
38+
app.command("replay")(replay)
3539

3640

3741
@app.command()
@@ -44,7 +48,11 @@ def run(
4448
False, "--keep-containers", "-k", help="Do not remove containers after games/agent finish."
4549
),
4650
):
47-
"""Run a PvP tournament from a config file."""
51+
"""Run a PvP tournament from a config file.
52+
53+
[dim]• codeclash run configs/test/battlesnake_pvp_test.yaml[/dim]
54+
[dim]• codeclash run path/to/config.yaml -c -o out/ # cleanup + custom output dir[/dim]
55+
"""
4856
yaml_content = config_path.read_text()
4957
preprocessed_yaml = resolve_includes(yaml_content, base_dir=CONFIG_DIR)
5058
config = yaml.safe_load(preprocessed_yaml)

codeclash/cli/ladder.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,21 @@
1313
from codeclash.utils.yaml_utils import resolve_includes
1414

1515
ladder_app = typer.Typer(
16-
no_args_is_help=True, add_completion=False, context_settings={"help_option_names": ["-h", "--help"]}
16+
no_args_is_help=True,
17+
add_completion=False,
18+
rich_markup_mode="rich", # enables the [dim] markup used in the Examples blocks
19+
context_settings={"help_option_names": ["-h", "--help"]},
1720
)
1821

1922

2023
@ladder_app.command("make")
2124
def make(
2225
config_path: Path = typer.Argument(..., help="Path to the ladder (round-robin) config file."),
2326
):
24-
"""Build a ladder: run PvP tournaments across all pairs of players (for ranking)."""
27+
"""Build a ladder: run PvP tournaments across all pairs of players (for ranking).
28+
29+
[dim]• codeclash ladder make configs/ablations/ladder/make_battlesnake.yaml[/dim]
30+
"""
2531
yaml_content = config_path.read_text()
2632
preprocessed_yaml = resolve_includes(yaml_content, base_dir=CONFIG_DIR)
2733
config = yaml.safe_load(preprocessed_yaml)
@@ -58,7 +64,10 @@ def run(
5864
False, "--keep-containers", "-k", help="Do not remove containers after games/agent finish."
5965
),
6066
):
61-
"""Send a model up a ranked ladder, rung by rung, until it loses."""
67+
"""Send a model up a ranked ladder, rung by rung, until it loses.
68+
69+
[dim]• codeclash ladder run path/to/ladder_config.yaml -c # clean up after each rung[/dim]
70+
"""
6271
yaml_content = config_path.read_text()
6372
preprocessed_yaml = resolve_includes(yaml_content, base_dir=CONFIG_DIR)
6473
config = yaml.safe_load(preprocessed_yaml)

0 commit comments

Comments
 (0)