File size: 10,039 Bytes
8d91c6f
 
 
 
 
4a8ef44
8d91c6f
 
 
 
 
 
 
 
4a8ef44
 
8d91c6f
4a8ef44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d91c6f
4a8ef44
 
8d91c6f
4a8ef44
 
 
 
 
 
 
4484c39
 
 
8d91c6f
4484c39
8d91c6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a8ef44
 
 
 
 
 
 
 
 
 
 
 
4484c39
 
 
 
 
 
 
 
 
 
 
 
 
d8b324c
4484c39
 
 
 
 
c41ac50
 
 
 
4484c39
 
 
 
 
8d91c6f
 
 
 
 
 
b9b3be5
8d91c6f
 
 
b9b3be5
8d91c6f
 
4484c39
4a8ef44
8d91c6f
 
4a8ef44
8d91c6f
 
 
4484c39
8d91c6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
978c431
8d91c6f
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
"""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'<i class="c{v}{" n" if (r, c) in hl else ""}"></i>')
            else:
                cells.append("<i></i>")
    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'<i class="c{color}"></i>' if (r, c) in filled else "<i></i>" for r in range(rows) for c in range(cols))
    return (f'<span class="pnext" title="next piece: {piece}">NEXT<span class="mini" '
            f'style="grid-template-columns:repeat({cols},6px)">{blocks}</span></span>')


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 += " · <b>TETRIS!</b>"
    elif game.last_lines:
        action += f" · <b>+{game.last_lines} line{'s' if game.last_lines > 1 else ''}</b>"
    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"<b>Random:</b> uniform pick{of_n}"
    elif pid.startswith("baseline/"):
        answer = f"<b>Rules:</b> 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'<b>Model:</b> <span class="{"vg" if v > 0 else "vb"}">“{verdict}”</span> {v:+.2f}{of_n}'
    else:
        answer = ""
    return f'<span class="act">{action}</span><span class="ans">{answer}</span>', 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 = "<small>survived</small>" if game.alive else ""
        elo_txt = ""
        if elo is not None:
            elo_txt = f"<small>Elo {elo[0]:.0f} → {elo[1]:.0f} ({elo[1] - elo[0]:+.1f})</small>"
        over = f'<div class="over"><span class="medal">{medal}</span>{sub}{elo_txt}</div>'
    elif not game.alive:
        over = '<div class="over">GAME OVER</div>'
    move, tip = _last_move(game, player)
    nxt = _next_html(game.current_piece) if game.alive else '<span class="pnext">NEXT –</span>'
    return (
        f'<div class="pcard"><div class="pname" title="{name}">{name}</div>'
        f'<div class="pmeta"><span>{params}</span>{nxt}</div>'
        f'<div class="pstats"><div><b>{game.score}</b><span>score</span></div>'
        f'<div><b>{game.lines}</b><span>lines</span></div><div><b>{game.pieces}</b><span>pieces</span></div></div>'
        f'<div class="board">{_board(game)}{over}</div>'
        f'<div class="pmove" title="{tip}">{move}</div></div>'
    )


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'<div class="arena">{"".join(cards)}</div>'


def empty_html(msg="Pick the models and press <b>Start match</b>. The boards will appear here.") -> str:
    return f'<div class="arena-empty">{msg}</div>'


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} <span class="{cls}">({d:+.1f})</span>'
        else:
            elo = "–"
        medal = MEDALS.get(ranks[i], str(ranks[i]))
        alive = " ✓" if g.alive else ""
        rows.append(
            f'<tr><td class="l">{medal}</td><td class="l p">{html.escape(p.display).replace("/", "/<wbr>")}</td><td>{g.score}</td>'
            f'<td class="sm-hide">{g.lines}</td><td>{g.pieces}{alive}</td><td>{elo}</td></tr>'
        )
    head = ('<tr><th class="l">#</th><th class="l">Player</th><th>Score</th><th class="sm-hide">Lines</th>'
            "<th>Pieces</th><th>Elo</th></tr>")
    return f'<table class="results">{head}{"".join(rows)}</table><div class="rnote">{note_html}</div>'