Spaces:
Running
Running
Add board renderer
Browse files
render.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTML rendering of the boards (pure strings, no JS)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import html
|
| 5 |
+
|
| 6 |
+
from tetris import HEIGHT, WIDTH
|
| 7 |
+
|
| 8 |
+
MEDALS = {1: "🥇", 2: "🥈", 3: "🥉"}
|
| 9 |
+
|
| 10 |
+
CSS = """
|
| 11 |
+
.arena {display:grid; grid-template-columns:repeat(auto-fit, minmax(min(128px, 100%), 1fr)); gap:12px 10px; padding:4px 0; max-width:960px; margin:0 auto;}
|
| 12 |
+
.pcard {width:100%; max-width:214px; min-width:0; justify-self:center; display:flex; flex-direction:column; gap:6px;}
|
| 13 |
+
.pname {font-weight:700; font-size:13px; line-height:1.25; overflow:hidden; text-overflow:ellipsis;
|
| 14 |
+
white-space:nowrap; color:var(--body-text-color);}
|
| 15 |
+
.pmeta {font-size:11px; color:var(--body-text-color-subdued); display:flex; justify-content:space-between; gap:6px;}
|
| 16 |
+
.pstats {display:grid; grid-template-columns:repeat(3,1fr); gap:4px;}
|
| 17 |
+
.pstats div {background:var(--block-background-fill); border:1px solid var(--border-color-primary);
|
| 18 |
+
border-radius:6px; padding:3px 4px; text-align:center;}
|
| 19 |
+
.pstats b {display:block; font-size:15px; color:var(--body-text-color); font-variant-numeric:tabular-nums;}
|
| 20 |
+
.pstats span {font-size:10px; text-transform:uppercase; letter-spacing:.04em; color:var(--body-text-color-subdued);}
|
| 21 |
+
.board {position:relative; display:grid; grid-template-columns:repeat(10,1fr); gap:1px; padding:3px;
|
| 22 |
+
background:#0b1020; border-radius:8px; border:1px solid #273048; aspect-ratio:1/2;}
|
| 23 |
+
.board i {display:block; border-radius:2px; background:#141b2e;}
|
| 24 |
+
.board i.c1 {background:#22d3ee;} .board i.c2 {background:#facc15;} .board i.c3 {background:#a855f7;}
|
| 25 |
+
.board i.c4 {background:#22c55e;} .board i.c5 {background:#ef4444;} .board i.c6 {background:#3b82f6;}
|
| 26 |
+
.board i.c7 {background:#f97316;}
|
| 27 |
+
.board i.n {box-shadow:inset 0 0 0 2px rgba(255,255,255,.85);}
|
| 28 |
+
.board .over {position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
| 29 |
+
background:rgba(8,10,20,.78); font-weight:800; font-size:18px; border-radius:8px; gap:6px; text-align:center; padding:6px;}
|
| 30 |
+
.board .over, .board .over * {color:#fff !important; opacity:1 !important;}
|
| 31 |
+
.board .over .medal {font-size:44px; line-height:1;}
|
| 32 |
+
.board .over small {font-weight:600; font-size:12px;}
|
| 33 |
+
.pmove {font-size:11px; line-height:1.35; color:var(--body-text-color-subdued); min-height:4.1em;
|
| 34 |
+
display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden;}
|
| 35 |
+
.pmove b {color:var(--body-text-color);}
|
| 36 |
+
.results {width:100%; max-width:760px; margin:4px auto 0; border-collapse:collapse !important; font-size:13px; border:none !important;}
|
| 37 |
+
.results th, .results td {padding:6px 5px !important; border:none !important; border-bottom:1px solid var(--border-color-primary) !important;
|
| 38 |
+
text-align:right; color:var(--body-text-color); font-variant-numeric:tabular-nums; background:transparent !important;}
|
| 39 |
+
.results td.p {min-width:8.5em;}
|
| 40 |
+
@media (max-width:480px) {.results .sm-hide {display:none;} .results {font-size:12px;}}
|
| 41 |
+
.results th {font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--body-text-color-subdued);}
|
| 42 |
+
.results td.l, .results th.l {text-align:left; overflow-wrap:break-word;}
|
| 43 |
+
.results .up {color:#16a34a;} .results .down {color:#dc2626;}
|
| 44 |
+
.rnote {font-size:12px; color:var(--body-text-color-subdued); margin:8px auto 0; max-width:760px; line-height:1.45;}
|
| 45 |
+
.rnote a {color:var(--link-text-color);}
|
| 46 |
+
.arena-empty {text-align:center; padding:40px 12px; color:var(--body-text-color-subdued); font-size:14px;
|
| 47 |
+
border:1px dashed var(--border-color-primary); border-radius:10px;}
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _board(game) -> str:
|
| 52 |
+
hl = set(game.last_cells)
|
| 53 |
+
cells = []
|
| 54 |
+
for r in range(HEIGHT):
|
| 55 |
+
row = game.grid[r]
|
| 56 |
+
for c in range(WIDTH):
|
| 57 |
+
v = row[c]
|
| 58 |
+
if v:
|
| 59 |
+
cells.append(f'<i class="c{v}{" n" if (r, c) in hl else ""}"></i>')
|
| 60 |
+
else:
|
| 61 |
+
cells.append("<i></i>")
|
| 62 |
+
return "".join(cells)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def player_card(game, player, final_rank=None, elo=None) -> str:
|
| 66 |
+
name = html.escape(player.display)
|
| 67 |
+
params = "baseline" if not player.n_params else f"{player.n_params / 1e6:.1f}M params"
|
| 68 |
+
over = ""
|
| 69 |
+
if final_rank is not None:
|
| 70 |
+
medal = MEDALS.get(final_rank, f"#{final_rank}")
|
| 71 |
+
sub = "survived" if game.alive else "topped out"
|
| 72 |
+
elo_txt = ""
|
| 73 |
+
if elo is not None:
|
| 74 |
+
elo_txt = f"<small>Elo {elo[0]:.0f} → {elo[1]:.0f} ({elo[1] - elo[0]:+.1f})</small>"
|
| 75 |
+
over = f'<div class="over"><span class="medal">{medal}</span><small>{sub}</small>{elo_txt}</div>'
|
| 76 |
+
elif not game.alive:
|
| 77 |
+
over = '<div class="over">GAME OVER</div>'
|
| 78 |
+
if game.last_description:
|
| 79 |
+
v = "" if game.last_value is None else f" <b>({game.last_value:+.2f})</b>"
|
| 80 |
+
move = f"<b>Last:</b> {html.escape(game.last_description)}{v}"
|
| 81 |
+
else:
|
| 82 |
+
move = "Waiting for the first piece…"
|
| 83 |
+
nxt = game.current_piece if game.alive else "–"
|
| 84 |
+
return (
|
| 85 |
+
f'<div class="pcard"><div class="pname" title="{name}">{name}</div>'
|
| 86 |
+
f'<div class="pmeta"><span>{params}</span><span>next: {nxt}</span></div>'
|
| 87 |
+
f'<div class="pstats"><div><b>{game.score}</b><span>score</span></div>'
|
| 88 |
+
f'<div><b>{game.lines}</b><span>lines</span></div><div><b>{game.pieces}</b><span>pieces</span></div></div>'
|
| 89 |
+
f'<div class="board">{_board(game)}{over}</div>'
|
| 90 |
+
f'<div class="pmove">{move}</div></div>'
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def arena_html(games, players, ranks=None, elos=None) -> str:
|
| 95 |
+
cards = []
|
| 96 |
+
for i, (g, p) in enumerate(zip(games, players)):
|
| 97 |
+
cards.append(player_card(g, p, ranks[i] if ranks else None, elos[i] if elos else None))
|
| 98 |
+
return f'<div class="arena">{"".join(cards)}</div>'
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def empty_html(msg="Pick the models and press <b>Start match</b>. The boards will appear here.") -> str:
|
| 102 |
+
return f'<div class="arena-empty">{msg}</div>'
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def results_html(order, ranks, players, games, elos, note_html) -> str:
|
| 106 |
+
rows = []
|
| 107 |
+
for i in order:
|
| 108 |
+
p, g = players[i], games[i]
|
| 109 |
+
if elos:
|
| 110 |
+
d = elos[i][1] - elos[i][0]
|
| 111 |
+
cls = "up" if d > 0.05 else "down" if d < -0.05 else ""
|
| 112 |
+
elo = f'{elos[i][1]:.0f} <span class="{cls}">({d:+.1f})</span>'
|
| 113 |
+
else:
|
| 114 |
+
elo = "–"
|
| 115 |
+
medal = MEDALS.get(ranks[i], str(ranks[i]))
|
| 116 |
+
alive = " ✓" if g.alive else ""
|
| 117 |
+
rows.append(
|
| 118 |
+
f'<tr><td class="l">{medal}</td><td class="l p">{html.escape(p.display).replace("/", "/<wbr>")}</td><td>{g.score}</td>'
|
| 119 |
+
f'<td class="sm-hide">{g.lines}</td><td>{g.pieces}{alive}</td><td>{elo}</td></tr>'
|
| 120 |
+
)
|
| 121 |
+
head = ('<tr><th class="l">#</th><th class="l">Player</th><th>Score</th><th class="sm-hide">Lines</th>'
|
| 122 |
+
"<th>Pieces</th><th>Elo</th></tr>")
|
| 123 |
+
return f'<table class="results">{head}{"".join(rows)}</table><div class="rnote">{note_html}</div>'
|