"""HTML rendering of the boards (pure strings, no JS).""" from __future__ import annotations import html from tetris import COLOR_ID, HEIGHT, ROTATIONS, WIDTH MEDALS = {1: "🥇", 2: "🥈", 3: "🥉"} CSS = """ .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;} .pcard {width:100%; max-width:214px; min-width:0; justify-self:center; display:flex; flex-direction:column; gap:6px;} .pname {font-weight:700; font-size:13px; line-height:1.25; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--body-text-color);} .pmeta {font-size:11px; color:var(--body-text-color-subdued); display:flex; justify-content:space-between; align-items:center; gap:6px; min-height:18px;} /* ---- 8-bit game look: square bevelled blocks, black well, grey frame, pixel font (like the original) ---- */ .pstats {display:grid; grid-template-columns:repeat(3,1fr); gap:4px;} .pstats div {background:#000; border:2px solid #6b6b6b; box-shadow:inset 0 0 0 1px #000, 0 0 0 1px #000; padding:5px 2px 4px; text-align:center; min-width:0;} .pstats b {display:block; font:400 10px/1.25 'Press Start 2P',ui-monospace,monospace; color:#fff; letter-spacing:-.02em; overflow:hidden; text-overflow:clip; white-space:nowrap;} .pstats span {display:block; margin-top:3px; font:400 6px/1 'Press Start 2P',ui-monospace,monospace; text-transform:uppercase; color:#9a9a9a;} .board {position:relative; display:grid; grid-template-columns:repeat(10,1fr); grid-template-rows:repeat(20,1fr); gap:0; padding:0; background:#000; border:3px solid #a8a8a8; border-radius:0; aspect-ratio:1/2; box-shadow:0 0 0 2px #000, 0 0 0 4px #5a5a5a; margin:4px; image-rendering:pixelated;} .board i {display:block; background:#000; box-shadow:inset 0 0 0 1px #0c0c0c;} .board i[class^="c"] {box-shadow:inset 2px 2px 0 rgba(255,255,255,.55), inset -2px -2px 0 rgba(0,0,0,.5), inset 0 0 0 1px rgba(0,0,0,.65);} .board i.c1, .pnext i.c1 {background:#00d8f0;} .board i.c2, .pnext i.c2 {background:#f0d800;} .board i.c3, .pnext i.c3 {background:#a000f0;} .board i.c4, .pnext i.c4 {background:#00c800;} .board i.c5, .pnext i.c5 {background:#e80000;} .board i.c6, .pnext i.c6 {background:#1840ff;} .board i.c7, .pnext i.c7 {background:#f08c00;} .board i.n {filter:brightness(1.35); animation:px-flash .4s steps(2) 1;} @keyframes px-flash {0% {filter:brightness(2.2);} 100% {filter:brightness(1.35);}} .board .over {position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; background:rgba(0,0,0,.74); gap:8px; text-align:center; padding:6px; font:400 11px/1.4 'Press Start 2P',ui-monospace,monospace;} .board .over, .board .over * {color:#fff !important; opacity:1 !important;} .board .over .medal {font-size:44px; line-height:1; font-family:initial;} .board .over small {font:600 12px/1.3 'DM Sans',Arial,sans-serif;} .pnext {display:inline-flex; align-items:center; gap:5px; font:400 7px/1 'Press Start 2P',ui-monospace,monospace; color:var(--body-text-color-subdued);} .pnext .mini {display:inline-grid; gap:0; background:#000; padding:2px; border:1px solid #6b6b6b;} .pnext .mini i {display:block; width:6px; height:6px;} .pnext .mini i[class^="c"] {box-shadow:inset 1px 1px 0 rgba(255,255,255,.55), inset -1px -1px 0 rgba(0,0,0,.5);} @media (prefers-reduced-motion:reduce) {.board i.n {animation:none;}} .pmove {font-size:11px; line-height:1.4; color:var(--body-text-color-subdued); min-height:4.3em; display:flex; flex-direction:column; gap:1px; cursor:help; overflow-wrap:anywhere;} .pmove .act {color:var(--body-text-color); font-variant-numeric:tabular-nums;} .pmove b {color:var(--body-text-color);} .pmove .vg {color:#16a34a; font-weight:700;} .pmove .vb {color:#dc2626; font-weight:700;} .results {width:100%; max-width:760px; margin:4px auto 0; border-collapse:collapse !important; font-size:13px; border:none !important;} .results th, .results td {padding:6px 5px !important; border:none !important; border-bottom:1px solid var(--border-color-primary) !important; text-align:right; color:var(--body-text-color); font-variant-numeric:tabular-nums; background:transparent !important;} .results td.p {min-width:8.5em;} @media (max-width:480px) {.results .sm-hide {display:none;} .results {font-size:12px;}} .results th {font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--body-text-color-subdued);} .results td.l, .results th.l {text-align:left; overflow-wrap:break-word;} .results .up {color:#16a34a;} .results .down {color:#dc2626;} .rnote {font-size:12px; color:var(--body-text-color-subdued); margin:8px auto 0; max-width:760px; line-height:1.45;} .rnote a {color:var(--link-text-color);} .arena-empty {text-align:center; padding:40px 12px; color:var(--body-text-color-subdued); font-size:14px; border:1px dashed var(--border-color-primary); border-radius:10px;} """ def _board(game) -> str: hl = set(game.last_cells) cells = [] for r in range(HEIGHT): row = game.grid[r] for c in range(WIDTH): v = row[c] if v: cells.append(f'') else: cells.append("") return "".join(cells) def _next_html(piece) -> str: """Tiny NEXT box with the upcoming piece, like the original game's side panel.""" cells = ROTATIONS[piece][0] rows = 1 + max(r for r, _ in cells) cols = 1 + max(c for _, c in cells) filled = set(cells) color = COLOR_ID[piece] blocks = "".join(f'' if (r, c) in filled else "" for r in range(rows) for c in range(cols)) return (f'NEXT{blocks}') def _last_move(game, player): """Two short lines: the action on the board, then the player's answer. Returns (html, tooltip) where the tooltip is the full outcome description.""" if not game.last_piece: return "Waiting for the first piece…", "" action = f"{game.last_piece} → col {game.last_col}" if game.last_rotations > 1: action += f" · rot {game.last_rotation + 1}/{game.last_rotations}" if game.last_lines == 4: action += " · TETRIS!" elif game.last_lines: action += f" · +{game.last_lines} line{'s' if game.last_lines > 1 else ''}" n = game.last_options of_n = f" · {n} option{'s' if n != 1 else ''}" if n else "" pid = getattr(player, "model_id", "") if pid == "baseline/random": answer = f"Random: uniform pick{of_n}" elif pid.startswith("baseline/"): answer = f"Rules: fixed priorities{of_n}" elif game.last_value is not None: v = game.last_value verdict = "good move" if v > 0 else "bad move" answer = f'Model: 0 else "vb"}">“{verdict}” {v:+.2f}{of_n}' else: answer = "" return f'{action}{answer}', html.escape(game.last_description) def player_card(game, player, final_rank=None, elo=None) -> str: name = html.escape(player.display) params = "baseline" if not player.n_params else f"{player.n_params / 1e6:.1f}M params" over = "" if final_rank is not None: medal = MEDALS.get(final_rank, f"#{final_rank}") sub = "survived" if game.alive else "" elo_txt = "" if elo is not None: elo_txt = f"Elo {elo[0]:.0f} → {elo[1]:.0f} ({elo[1] - elo[0]:+.1f})" over = f'
{medal}{sub}{elo_txt}
' elif not game.alive: over = '
GAME OVER
' move, tip = _last_move(game, player) nxt = _next_html(game.current_piece) if game.alive else 'NEXT –' return ( f'
{name}
' f'
{params}{nxt}
' f'
{game.score}score
' f'
{game.lines}lines
{game.pieces}pieces
' f'
{_board(game)}{over}
' f'
{move}
' ) def arena_html(games, players, ranks=None, elos=None) -> str: cards = [] for i, (g, p) in enumerate(zip(games, players)): cards.append(player_card(g, p, ranks[i] if ranks else None, elos[i] if elos else None)) return f'
{"".join(cards)}
' def empty_html(msg="Pick the models and press Start match. The boards will appear here.") -> str: return f'
{msg}
' def results_html(order, ranks, players, games, elos, note_html) -> str: rows = [] for i in order: p, g = players[i], games[i] if elos and elos[i] is not None: d = elos[i][1] - elos[i][0] cls = "up" if d > 0.05 else "down" if d < -0.05 else "" elo = f'{elos[i][1]:.0f} ({d:+.1f})' else: elo = "–" medal = MEDALS.get(ranks[i], str(ranks[i])) alive = " ✓" if g.alive else "" rows.append( f'{medal}{html.escape(p.display).replace("/", "/")}{g.score}' f'{g.lines}{g.pieces}{alive}{elo}' ) head = ('#PlayerScoreLines' "PiecesElo") return f'{head}{"".join(rows)}
{note_html}
'