Skip to content

Commit fb83d89

Browse files
committed
Simplify replay
1 parent b29d0d6 commit fb83d89

6 files changed

Lines changed: 138 additions & 177 deletions

File tree

codeclash/arenas/battlesnake/replay.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,3 @@ def parse(self, raw: bytes, players=None) -> ReplayData:
118118
draw=result.get("isDraw", False),
119119
extra={"colors": colors},
120120
)
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)

codeclash/arenas/robotrumble/replay.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -155,27 +155,3 @@ def parse(self, raw: bytes, players=None) -> ReplayData:
155155
"errors": data.get("errors", {}),
156156
},
157157
)
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/replay.py

Lines changed: 11 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,30 @@
1-
"""`codeclash replay` — turn a tournament log folder into browsable game playback.
1+
"""`codeclash replay` — serve a tournament folder and watch game replays in the browser.
22
33
Points at a tournament folder (the one holding ``metadata.json`` + ``rounds/``); the arena
4-
is read from the metadata (or the folder name) and dispatched to its renderer. Games are
5-
discovered under ``rounds/`` (including ``round_<R>.tar.gz`` archives).
4+
is read from the metadata (or the folder name). Games are discovered under ``rounds/``
5+
(including ``round_<R>.tar.gz`` archives) and each replay is rendered on demand — nothing
6+
is written to disk.
67
"""
78

89
from __future__ import annotations
910

10-
import webbrowser
1111
from pathlib import Path
1212

1313
import typer
1414

15-
from codeclash.replay import base, get_replayer, load_tournament
16-
17-
# Cap on how many games `--all` will pre-generate before it stops and says so.
18-
ALL_CAP = 300
19-
20-
21-
def _bad(msg: str):
22-
typer.echo(f"Error: {msg}", err=True)
23-
raise typer.Exit(2)
15+
from codeclash.replay import get_replayer, load_tournament
16+
from codeclash.replay.serve import run_server
2417

2518

2619
def replay(
2720
folder: Path = typer.Argument(..., help="Tournament log folder (contains metadata.json + rounds/)."),
28-
round: int | None = typer.Option(None, "--round", "-r", help="Round to replay (alone: every sim in the round)."),
29-
sim: int | None = typer.Option(None, "--sim", "-s", help="Sim index (alone: that sim across every round)."),
30-
all_games: bool = typer.Option(False, "--all", help="Pre-generate a playback page for every game."),
31-
ascii_out: bool = typer.Option(False, "--ascii", help="Dump a single game to the terminal instead of HTML."),
32-
open_browser: bool = typer.Option(False, "--open", "-o", help="Open the result in a browser when done."),
21+
port: int = typer.Option(8000, "--port", "-p", help="Port to serve on (falls back to a free one if taken)."),
3322
):
34-
"""Build a browsable replay site (index + on-demand per-game pages) for a tournament.
23+
"""Serve a tournament folder and lazily watch game replays in the browser.
3524
36-
[dim]• codeclash replay logs/<tournament-folder> # build the browsable index[/dim]
37-
[dim]• codeclash replay logs/<folder> -r 1 -s 0 --open # one game, open in browser[/dim]
38-
[dim]• codeclash replay logs/<folder> -r 1 # every sim in round 1[/dim]
39-
[dim]• codeclash replay logs/<folder> --all # pre-generate every game[/dim]
40-
[dim]• codeclash replay logs/<folder> --ascii # dump a game to the terminal[/dim]
25+
[dim]• codeclash replay logs/<tournament-folder>[/dim]
26+
[dim]• codeclash replay logs/<folder> -p 9000[/dim]
4127
"""
42-
# Reject contradictory modes up front, rather than silently picking one.
43-
if ascii_out and all_games:
44-
_bad("use either --ascii or --all, not both.")
45-
if ascii_out and open_browser:
46-
_bad("--ascii writes nothing to open; drop --open.")
47-
if all_games and (round is not None or sim is not None):
48-
_bad("--all replays every game — drop -r/-s (or omit --all to pick specific games).")
49-
5028
tour = load_tournament(folder)
5129
renderer = get_replayer(tour.arena)
5230
if renderer is None:
@@ -55,74 +33,4 @@ def replay(
5533
if not tour.games:
5634
typer.echo(f"No sim files found under {folder} (looked for {renderer.sim_glob}).")
5735
raise typer.Exit(1)
58-
59-
def tour_payload(r: int, s: int) -> dict:
60-
return {
61-
"arena": tour.arena,
62-
"players": tour.players,
63-
"round": r,
64-
"sim": s,
65-
"round_winner": tour.round_winners.get(r),
66-
}
67-
68-
def load(game):
69-
return renderer.parse(base.read_sim(game), tour.players)
70-
71-
# ascii: a single game to the terminal, no files written.
72-
if ascii_out:
73-
g0 = tour.games[0]
74-
r = round if round is not None else g0.round
75-
s = sim if sim is not None else g0.sim
76-
game = next((g for g in tour.games if g.round == r and g.sim == s), None)
77-
if game is None:
78-
typer.echo(f"No game at round {r}, sim {s}.")
79-
raise typer.Exit(1)
80-
typer.echo(renderer.ascii(load(game)))
81-
return
82-
83-
# Which games to generate as HTML pages:
84-
# -r R -s S -> that one game
85-
# -r R -> every sim in round R
86-
# -s S -> sim S across every round
87-
# --all -> every game (capped)
88-
# (none) -> index only
89-
if all_games:
90-
todo = tour.games[:ALL_CAP]
91-
elif round is not None and sim is not None:
92-
todo = [g for g in tour.games if g.round == round and g.sim == sim]
93-
elif round is not None:
94-
todo = [g for g in tour.games if g.round == round]
95-
elif sim is not None:
96-
todo = [g for g in tour.games if g.sim == sim]
97-
else:
98-
todo = []
99-
100-
if (round is not None or sim is not None) and not todo:
101-
typer.echo(f"No games match round={round} sim={sim}. Run `codeclash replay {folder}` to list games.")
102-
raise typer.Exit(1)
103-
104-
replay_dir = folder / "replay"
105-
base.write_assets(replay_dir, renderer)
106-
for g in todo:
107-
(replay_dir / f"r{g.round}_s{g.sim}.html").write_text(
108-
base.build_sim_stub(load(g), tour_payload(g.round, g.sim), renderer)
109-
)
110-
# Always (re)build the index so newly generated pages show as watchable.
111-
index = replay_dir / "index.html"
112-
index.write_text(base.build_index(tour, replay_dir))
113-
114-
if todo:
115-
typer.echo(f"Generated {len(todo)} game page(s). Index: {index}")
116-
if all_games and len(tour.games) > ALL_CAP:
117-
typer.echo(f"Note: capped at {ALL_CAP} of {len(tour.games)} games — pass -r/-s for the rest.")
118-
else:
119-
typer.echo(f"Replay index: {index}")
120-
typer.echo(f"{len(tour.games)} games found. Open a specific one with `-r <round> [-s <sim>]`.")
121-
122-
if open_browser:
123-
# Open the single generated page if there's exactly one, else the index.
124-
target = replay_dir / f"r{todo[0].round}_s{todo[0].sim}.html" if len(todo) == 1 else index
125-
try:
126-
webbrowser.open(target.resolve().as_uri())
127-
except Exception:
128-
typer.echo(f"(couldn't open a browser automatically — open {target} manually)")
36+
run_server(folder, tour, renderer, port=port)

codeclash/replay/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,9 @@
1717
ReplayRenderer,
1818
TournamentInfo,
1919
build_index,
20-
build_sim_stub,
20+
build_page,
2121
discover_games,
2222
read_sim,
23-
write_assets,
2423
)
2524

2625
__all__ = [
@@ -29,12 +28,11 @@
2928
"ReplayRenderer",
3029
"TournamentInfo",
3130
"build_index",
32-
"build_sim_stub",
31+
"build_page",
3332
"discover_games",
3433
"get_replayer",
3534
"load_tournament",
3635
"read_sim",
37-
"write_assets",
3836
]
3937

4038

codeclash/replay/base.py

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,6 @@ class ReplayRenderer(ABC):
9191
def parse(self, raw: bytes, players: list[dict] | None = None) -> ReplayData:
9292
"""Decode raw sim bytes into a :class:`ReplayData`. Input format is opaque."""
9393

94-
def ascii(self, data: ReplayData) -> str:
95-
"""Optional terminal rendering; arenas override."""
96-
raise NotImplementedError(f"{self.arena} has no ascii renderer")
97-
9894

9995
# --------------------------------------------------------------------------------------
10096
# Sim discovery + reading
@@ -130,7 +126,7 @@ def discover_games(folder: Path, sim_glob: str) -> list[GameRef]:
130126
archived.add(rnum)
131127
with tarfile.open(entry) as tf:
132128
for m in tf.getmembers():
133-
if m.isfile() and fnmatch.fnmatch(Path(m.name).name, sim_glob):
129+
if m.isfile() and m.size > 0 and fnmatch.fnmatch(Path(m.name).name, sim_glob):
134130
games.append(
135131
GameRef(round=rnum, sim=_sim_index(Path(m.name).name), archive=entry, member=m.name)
136132
)
@@ -140,11 +136,11 @@ def discover_games(folder: Path, sim_glob: str) -> list[GameRef]:
140136
if entry.is_dir() and _round_num(entry.name) not in archived:
141137
rnum = _round_num(entry.name)
142138
for f in sorted(entry.iterdir()):
143-
if f.is_file() and fnmatch.fnmatch(f.name, sim_glob):
139+
if f.is_file() and f.stat().st_size > 0 and fnmatch.fnmatch(f.name, sim_glob):
144140
games.append(GameRef(round=rnum, sim=_sim_index(f.name), path=f))
145141
else:
146142
for f in sorted(folder.iterdir()):
147-
if f.is_file() and fnmatch.fnmatch(f.name, sim_glob):
143+
if f.is_file() and f.stat().st_size > 0 and fnmatch.fnmatch(f.name, sim_glob):
148144
games.append(GameRef(round=0, sim=_sim_index(f.name), path=f))
149145
games.sort(key=lambda g: (g.round, g.sim))
150146
return games
@@ -279,49 +275,45 @@ def _doc(head_extra: str, scripts: str) -> str:
279275
)
280276

281277

282-
def build_sim_stub(data: ReplayData, tour: dict, renderer: ReplayRenderer) -> str:
283-
"""Thin per-sim page: inlines only this game's data, references shared assets."""
284-
slug = renderer.arena.lower()
285-
head = f'<title>{renderer.arena} replay</title><link rel="stylesheet" href="assets/replay.css">'
278+
def build_page(data: ReplayData, tour: dict, renderer: ReplayRenderer) -> str:
279+
"""A complete, self-contained replay page (CSS + player + arena JS + data inlined).
280+
281+
Built on demand by the server and served straight from memory — nothing is written to
282+
disk.
283+
"""
284+
head = f"<title>{renderer.arena} replay</title><style>{SHELL_CSS}{renderer.CSS}</style>"
286285
scripts = (
287286
"<script>window.G="
288287
+ _script_safe(data.payload)
289288
+ ";window.TOUR="
290289
+ _script_safe(tour)
291290
+ ";</script>\n"
292-
+ f'<script src="assets/{slug}.js"></script>\n'
293-
+ '<script src="assets/player.js"></script>'
291+
+ "<script>"
292+
+ renderer.DRAW_JS
293+
+ "</script>\n"
294+
+ "<script>"
295+
+ PLAYER_JS
296+
+ "</script>"
294297
)
295298
return _doc(head, scripts)
296299

297300

298-
def write_assets(replay_dir: Path, renderer: ReplayRenderer) -> None:
299-
"""Write the shared player assets once (idempotent)."""
300-
adir = replay_dir / "assets"
301-
adir.mkdir(parents=True, exist_ok=True)
302-
(adir / "replay.css").write_text(SHELL_CSS + renderer.CSS)
303-
(adir / "player.js").write_text(PLAYER_JS)
304-
(adir / f"{renderer.arena.lower()}.js").write_text(renderer.DRAW_JS)
305-
306-
307-
def build_index(tour: TournamentInfo, replay_dir: Path) -> str:
308-
"""Index page listing every game (round x sim) with its winner and a watch link."""
301+
def build_index(tour: TournamentInfo) -> str:
302+
"""Index page: every game (round x sim) with its winner, linking to its replay."""
309303
players = " vs ".join(p["name"] for p in tour.players)
310304
rows = []
311305
for g in tour.games:
312-
href = f"r{g.round}_s{g.sim}.html"
313306
winner = tour.round_winners.get(g.round, "")
314-
if (replay_dir / href).exists():
315-
cell = f'<a href="{href}">&#9654; watch</a>'
316-
else:
317-
cell = f'<span class="muted">not generated &mdash; <code>codeclash replay . -r {g.round} -s {g.sim}</code></span>'
307+
cell = f'<a class="btn" href="game?r={g.round}&s={g.sim}">&#9654; watch</a>'
318308
rows.append(f"<tr><td>{g.round}</td><td>{g.sim}</td><td>{winner}</td><td>{cell}</td></tr>")
319309
style = (
320310
"body{background:#0d1117;color:#e6edf3;font:14px system-ui,sans-serif;margin:0;padding:24px}"
321311
"h1{font-size:18px}.muted{color:#8b949e}"
322312
"table{border-collapse:collapse;margin-top:12px}"
323313
"td,th{padding:6px 14px;border-bottom:1px solid #21262d;text-align:left}"
324-
"a{color:#58a6ff}code{background:#161b22;padding:1px 5px;border-radius:4px}"
314+
"a{color:#58a6ff}"
315+
".btn{display:inline-block;padding:3px 10px;border:1px solid #30363d;border-radius:6px;"
316+
"background:#21262d;color:#e6edf3;text-decoration:none}.btn:hover{background:#30363d}"
325317
)
326318
header = (
327319
f"<h1>{tour.arena} &middot; {players}</h1>"

0 commit comments

Comments
 (0)