Files
HexTrails/labs/bench/app.js
cabinwandClaude Opus 5 47be0ad9c4 feat(bench): live observer page
Watch an agent play in real time, replayed with the main game's renderer and
animations. The board holds the stage with the live panel beside it: a
millisecond stopwatch, how long the current step has been thinking, steps,
decisions, pieces cleared, dead taps and rotations, then the agent's running
commentary. A banner over the board names who is playing, self-reported by the
agent via --agent.

The page polls unconditionally; the checkbox only decides whether the cursor
chases the newest step, and it follows the agent into its next run once this
one ends. Clicking any step in the feed replays the board at that moment, and
the arrow keys walk it — there is no scrub bar, since the feed already is one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:00:33 +08:00

332 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Game, trace } from '../../src/core.js';
import { metrics, decisionTimeline, describeAction, describeResult, RATING_QUESTIONS } from '../../src/bench.js';
import { layout } from '../../src/geometry.js';
import { CanvasPainter, drawScene } from '../../src/renderer.js';
import { TILE_ATLAS } from '../../src/sprites.js';
import { exitTiming } from '../../src/exit-animation.js';
import { MOTION } from '../../src/design-tokens.js';
// Where the run files and the tile atlas live, relative to this page. The
// standalone kit builder (scripts/bench-kit.mjs) rewrites these two lines, so
// keep them as plain string literals.
const DATA = '../../bench/runs', ASSET_BASE = '../../';
const $ = id => document.getElementById(id);
const canvas = $('board'), context = canvas.getContext('2d'), painter = new CanvasPainter(context);
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
let runObj = null, game = null, cursor = 0, view, width, height, animation = null, turning = null, hint = null, path = null, frame = 0, liveTimer = null, clockFrame = 0;
// ---------- data ----------
// Data always refreshes; the checkbox only decides whether the cursor chases
// the newest step. Hiding the refresh behind a toggle meant a page opened from
// the dropdown sat at 0/0 while the agent played.
const following = () => $('live').checked;
// The board column carries no running commentary — the feed already says what
// happened, step by step. This line exists only for "the file is gone".
function notice(text) { const n = $('notice'); n.textContent = text || ''; n.hidden = !text; }
async function loadIndex({ keepSelection = false } = {}) {
const res = await fetch(`${DATA}/index.json?t=` + Date.now()).catch(() => null);
let index = [];
try { index = res && res.ok ? await res.json() : []; } catch { return null; }
index.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
const markup = index.length ? index.map(e => `<option value="${e.run}">${e.run} · ${e.level} · ${e.status} · ${e.steps} 步 · ${e.persona}/${e.model}</option>`).join('') : '<option value="">(还没有任何局:先用 scripts/bench.mjs start)</option>';
// Rebuilding the list on every poll would fight the open dropdown; only
// touch it when the options actually changed.
if (markup !== $('run').innerHTML) {
const selected = keepSelection ? $('run').value : null;
$('run').innerHTML = markup;
if (selected && index.some(e => e.run === selected)) $('run').value = selected;
}
return index;
}
let missing = 0;
async function loadRun(name, { keepCursor = false, fromStart = false } = {}) {
let fresh;
try {
const res = await fetch(`${DATA}/${name}.json?t=` + Date.now());
if (!res.ok) {
// A stale index entry would otherwise 404 once a second forever.
// Drop the board with the data: leaving `game` behind meant the next
// resize redrew a run that no longer exists and read `runObj.steps`.
if (++missing >= 3) { runObj = null; game = null; view = null; missing = 0; notice(`${DATA}/${name}.json 不见了(可能已删除)——从列表里另选一局。`); await loadIndex(); }
else if (!keepCursor) notice(`读不到 ${DATA}/${name}.json`);
return;
}
// The agent may be mid-write; a torn read just means "try again next tick".
fresh = await res.json();
missing = 0; notice('');
} catch { return; }
const sameRun = runObj && runObj.run === fresh.run;
const changed = !sameRun || runObj.steps.length !== fresh.steps.length || runObj.status !== fresh.status || runObj.rating !== fresh.rating;
runObj = fresh;
if (changed) { renderSteps(); renderMetrics(); }
// Hopping to a fresh run: watch it from the opening so the animations play.
if (!sameRun || !keepCursor) rebuild(fromStart ? 0 : runObj.steps.length);
else pump();
// Keep the dropdown and the address bar on the run being watched. An auto-hop
// lands on a run the select may not list yet, and a silent mismatch there
// labels the board with somebody else's game.
if (!sameRun) {
if (![...$('run').options].some(o => o.value === runObj.run)) await loadIndex();
$('run').value = runObj.run;
const url = new URL(location.href); url.searchParams.set('run', runObj.run); history.replaceState(null, '', url);
}
syncUi();
cancelAnimationFrame(clockFrame); paintClock();
if (fromStart) pump();
}
// Following the agent: keep playing whatever steps have arrived until the
// cursor is caught up. draw() calls back here when an animation finishes, so a
// batch of several steps plays through instead of stalling after one.
function pump() {
if (!following() || !runObj) return;
if (animation || turning) return;
if (cursor < runObj.steps.length) advance();
}
// ---------- replay ----------
function rebuild(n) {
cancelAnimationFrame(frame); animation = null; turning = null; hint = null; path = null;
game = new Game(runObj.level); cursor = 0;
for (; cursor < n && cursor < runObj.steps.length; cursor++) applyModel(runObj.steps[cursor]);
resize(); syncUi();
}
function applyModel(step) {
const a = step.action;
if (a.type === 'tap') game.tap(a.id);
else if (a.type === 'rotate') game.rotate(a.direction);
else if (a.type === 'undo') game.undo();
}
// Advance one step with the real animations.
function advance() {
if (!runObj || cursor >= runObj.steps.length || animation || turning) return;
const step = runObj.steps[cursor]; const a = step.action;
hint = null; path = null;
if (a.type === 'tap') {
const result = game.tap(a.id);
if (result.status === 'exit') animation = { route: result, start: performance.now(), duration: reduced ? 100 : exitTiming(result).duration };
else if (result.travelPoints) animation = { route: result, start: performance.now(), duration: reduced ? 150 : MOTION.collisionMs };
} else if (a.type === 'rotate') {
game.rotate(a.direction);
turning = { direction: a.direction, start: performance.now(), duration: reduced ? 100 : MOTION.rotationMs };
} else if (a.type === 'undo') game.undo();
else if (a.type === 'peek') { const tile = game.tiles.find(t => t.id === a.id); if (tile) { hint = tile.id; path = trace(game.level, tile, game.tiles); } }
cursor++;
syncUi(); draw();
}
function syncUi() {
if (!game || !runObj) return;
const behind = runObj.steps.length - cursor;
$('feed-state').textContent = !following() ? '已暂停跟随' : pinned ? '已固定这一局' : runObj.status === 'playing' ? (behind > 0 ? `追 ${behind} 步` : '等待下一步') : '本局已结束';
$('feed-state').style.color = following() && runObj.status === 'playing' ? 'var(--live-ink)' : 'var(--ink3)';
$('who-model').textContent = runObj.model || 'unknown';
$('who-agent').textContent = runObj.agent || '';
$('who-level').textContent = `${runObj.level.id} · ${runObj.level.cells.length} 格 · ${runObj.level.tiles.length} 枚 · 人设 ${runObj.persona}`;
// The jump-back button is the only replay control left: it appears exactly
// when the view is not tracking the agent's live frontier, and says which
// part of that it will resume — the newest step, or the newest run.
const drifted = !following(), locked = pinned && following();
$('end').hidden = !drifted && !locked;
$('end').textContent = pinned ? '↩ 跟回最新一局' : '↩ 回到实时';
document.querySelectorAll('#steps li').forEach((li, i) => li.classList.toggle('current', i === cursor - 1));
scrollFeedTo(document.querySelector('#steps li.current'));
renderScoreboard();
}
// Scroll the feed, never the page. scrollIntoView({block:'nearest'}) walks up
// every scroll container, so on a phone — where the feed sits below the fold —
// each new step yanked the clock off the top of the screen.
function scrollFeedTo(li) {
if (!li) return;
const box = $('steps'), b = box.getBoundingClientRect(), r = li.getBoundingClientRect();
if (r.top < b.top) box.scrollTop += r.top - b.top;
else if (r.bottom > b.bottom) box.scrollTop += r.bottom - b.bottom;
}
// ---------- scoreboard ----------
const STATUS = { playing: ['进行中', 'playing'], cleared: ['通关', 'cleared'], 'gave-up': ['放弃', 'gaveup'], budget: ['预算用尽', 'budget'] };
const pad = (n, w = 2) => String(n).padStart(w, '0');
// A stopwatch, not a duration: minutes:seconds.milliseconds, always the same width.
function stopwatch(ms) {
if (ms == null || ms < 0) return '0:00.000';
const m = Math.floor(ms / 60000), s = Math.floor(ms % 60000 / 1000);
return `${m}:${pad(s)}.${pad(Math.floor(ms % 1000), 3)}`;
}
// metrics() re-solves the level, which is far too heavy to run on every clock
// frame; the numbers only change when a step lands.
let metricsCache = { key: null, value: null };
function runMetrics() {
const key = `${runObj.run}:${runObj.steps.length}:${runObj.status}:${runObj.rating ? 1 : 0}`;
if (metricsCache.key !== key) metricsCache = { key, value: metrics(runObj) };
return metricsCache.value;
}
function renderScoreboard() {
if (!runObj) return;
const m = runMetrics(), total = runObj.level.tiles.length;
const last = runObj.steps[runObj.steps.length - 1];
const left = last ? last.remaining : total;
// Rewound, every number here still describes the whole run while the board
// beside it shows step N. Label the mismatch rather than let them contradict.
const rewound = !following() && cursor < runObj.steps.length;
const [label, cls] = rewound
? [`回看 ${cursor} / ${runObj.steps.length}`, 'rewound']
: STATUS[runObj.status] || [runObj.status, ''];
document.querySelector('.scoreboard').classList.toggle('rewound', rewound);
$('sb-steps').textContent = m.steps;
$('sb-budget').textContent = `/ ${runObj.budget}`;
$('sb-decisions').textContent = m.decisions;
$('sb-per').textContent = m.actionsPerDecision ? `· ${m.actionsPerDecision.toFixed(1)} 步/次` : '—';
$('sb-cleared').textContent = total - left;
$('sb-total').textContent = `/ ${total}`;
$('sb-bar').style.width = `${Math.round((total - left) / Math.max(1, total) * 100)}%`;
$('sb-dead').textContent = m.deadTaps;
$('sb-rot').textContent = `· ${m.rotations} 转${m.minimumRotations != null ? ` / 最少 ${m.minimumRotations}` : ''}`;
$('sb-status').textContent = label;
$('sb-status').className = `pill ${cls}`;
}
// The clock is the point of the page: it runs while the agent is still playing,
// and freezes on the final time the moment the run ends.
function paintClock() {
clockFrame = 0;
if (!runObj) return;
const started = runObj.startedAt ? Date.parse(runObj.startedAt) : null;
const last = runObj.steps[runObj.steps.length - 1];
const lastAt = last?.at ?? started;
const live = runObj.status === 'playing';
const now = Date.now();
$('clock').textContent = stopwatch(started == null ? null : (live ? now : lastAt) - started);
if (live && lastAt != null) {
const thinking = now - lastAt;
$('think').textContent = thinking >= 60000 ? stopwatch(thinking) : `${(thinking / 1000).toFixed(1)}s`;
$('think').classList.toggle('hot', thinking > 15000);
$('think-label').textContent = '本步思考';
} else {
$('think').textContent = last ? stopwatch(runMetrics().timing.maxDecisionMs) : '—';
$('think').classList.remove('hot');
$('think-label').textContent = '最长一步';
}
if (live) clockFrame = requestAnimationFrame(paintClock);
}
const secs = ms => ms == null ? '—' : ms >= 10000 ? `${(ms / 1000).toFixed(0)} s` : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`;
function renderSteps() {
// One badge per decision: how long the agent took before committing this batch.
const latency = new Map(decisionTimeline(runObj).map(d => [d.from, d]));
$('steps').innerHTML = runObj.steps.map(s => {
const d = latency.get(s.n);
const badge = d ? `<em title="这次决定用了多久(含读盘与进程启动)">${secs(d.ms)}${d.actions > 1 ? ` · 连做 ${d.actions} 步` : ''}</em>` : '';
return `<li data-n="${s.n}"${d ? ' class="lead"' : ''}>${badge}<b>${describeAction(s.action)}</b> → ${describeResult(s.result)}${s.note ? `<i>${s.note}</i>` : ''}</li>`;
}).join('');
document.querySelectorAll('#steps li').forEach(li => li.onclick = () => { const n = Number(li.dataset.n); follow(false); rebuild(n); });
}
// The list is one tab stop; the arrows walk it. Without this the step rows are
// mouse-only, and removing the slider left keyboard users with no replay at all.
$('steps').onkeydown = e => {
if (!runObj) return;
const last = runObj.steps.length;
const to = { ArrowDown: cursor + 1, ArrowRight: cursor + 1, ArrowUp: cursor - 1, ArrowLeft: cursor - 1, Home: 0, End: last }[e.key];
if (to === undefined) return;
e.preventDefault();
const n = Math.max(0, Math.min(last, to));
if (n === last) { follow(true); return; }
follow(false); rebuild(n);
}
function renderMetrics() {
const m = runMetrics();
const t = m.timing;
const perPiece = t.totalMs != null ? t.totalMs / runObj.level.tiles.length : null;
$('timing').innerHTML = [
['总耗时', secs(t.totalMs)], ['决策次数', m.actionsPerDecision != null ? `${m.decisions}(${m.actionsPerDecision.toFixed(1)} 步/次)` : `${m.decisions}`],
['每次决策', `中位 ${secs(t.medianDecisionMs)} · 最长 ${secs(t.maxDecisionMs)}`],
['开局读盘', secs(t.firstDecisionMs)], ['每枚棋子', secs(perPiece == null ? null : Math.round(perPiece))],
...(t.thinkMs != null ? [['其中模型思考', secs(t.thinkMs)]] : []),
].map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join('');
const rows = [['状态', m.status], ['步数 / 预算', `${m.steps} / ${runObj.budget}`], ['动作 ÷ 棋子', m.actionsPerPiece?.toFixed(2)], ['点击 / 离场 / 空点', `${m.taps} / ${m.exits} / ${m.deadTaps}`], ['其中擦碰空点', m.grazeDeadTaps], ['最长连续空点', m.longestDeadStreak], ['旋转 / 有效 / 盲转', `${m.rotations} / ${m.effectiveRotations} / ${m.blindRotations}`], ['转错方向', m.wrongWayRotations], ['最长连续盲转', m.longestBlindStreak], ['进入卡住状态', m.stuckEntries], ['首次离场在第', m.stepsToFirstExit ?? '—'], ['撤销 / 查看', `${m.undos} / ${m.peeks}`], ['最少旋转(求解器)', m.minimumRotations ?? '—']];
$('metrics').innerHTML = rows.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join('');
$('rating').innerHTML = runObj.rating ? RATING_QUESTIONS.map(q => `<div><dt>${q.key}</dt><dd>${runObj.rating[q.key] ?? '—'}</dd></div>`).join('') : '<div><dt>尚未评分</dt><dd>—</dd></div>';
if (runObj.status === 'gave-up') $('rating').insertAdjacentHTML('afterbegin', `<div class="giveup"><dt>放弃原因</dt><dd>${runObj.reason || '—'}</dd></div>`);
}
// ---------- drawing ----------
function draw(now = performance.now()) {
cancelAnimationFrame(frame);
if (!view || !game || !runObj) return;
context.clearRect(0, 0, width, height);
const state = { hint, path, reduced };
if (animation) { const p = Math.min(1, (now - animation.start) / animation.duration); state.animation = { route: animation.route, progress: p }; if (p >= 1) animation = null; }
if (turning) { const p = Math.min(1, (now - turning.start) / turning.duration); state.boardAngle = -turning.direction * Math.PI / 3 * Math.pow(1 - p, 3); if (p >= 1) turning = null; }
drawScene(painter, game.level, game.tiles, view, state);
if (animation || turning) frame = requestAnimationFrame(draw);
else if (following() && cursor < runObj.steps.length) setTimeout(pump, 120);
}
function resize() {
if (!game) return;
const rect = canvas.getBoundingClientRect(), dpr = Math.min(devicePixelRatio || 1, 3);
width = rect.width; height = rect.height;
canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
view = layout(game.level.cells, width, height); draw();
}
// ---------- controls ----------
$('run').onchange = () => { pinned = true; loadRun($('run').value); };
$('reload').onclick = async () => { const index = await loadIndex(); if (runObj) $('run').value = runObj.run; else if (index?.[0]) loadRun(index[0].run); };
// Polling is unconditional — a page opened from the dropdown must still track a
// running game. `follow` only decides whether the page chases the agent: the
// newest step within this run, and the agent's next run once this one is done.
function follow(on) {
$('live').checked = on;
if (on) pinned = false;
if (on && runObj) rebuild(runObj.steps.length);
syncUi();
}
// Watching "the agent", not "one run": when our run has finished (or has gone
// quiet while another is clearly active), hop to whatever it started next.
const STALE_MS = 20000;
let lastChangeAt = Date.now();
// Picking a run from the dropdown pins it: auto-hop must not yank the viewer
// away from a game they deliberately opened. 「跟随最新」releases the pin.
let pinned = false;
function nextRun(index) {
if (pinned || !following() || !runObj || !index?.length) return null;
const mine = runObj.status !== 'playing' ? null : Date.now() - lastChangeAt;
if (mine !== null && mine < STALE_MS) return null;
const candidate = index.filter(e => e.run !== runObj.run && e.status === 'playing')
.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))[0];
if (!candidate) return null;
return (candidate.updatedAt || '') > (runObj.updatedAt || '') ? candidate.run : null;
}
let tick = 0;
liveTimer = setInterval(async () => {
if (runObj) {
const before = runObj.steps.length;
await loadRun(runObj.run, { keepCursor: true });
if (runObj && runObj.steps.length !== before) lastChangeAt = Date.now();
}
if (tick++ % 2 === 0) {
const index = await loadIndex({ keepSelection: true });
const hop = nextRun(index);
if (hop) {
await loadRun(hop, { fromStart: true });
lastChangeAt = Date.now();
}
}
}, 1000);
$('live').onchange = () => follow($('live').checked);
// Clicking a step in the feed is the whole replay control; this puts it back.
$('end').onclick = () => { follow(true); };
const atlas = new Image(); atlas.onload = () => { painter.atlas = atlas; draw(); }; atlas.src = ASSET_BASE + TILE_ATLAS;
new ResizeObserver(resize).observe(canvas);
const index = await loadIndex();
const wanted = new URLSearchParams(location.search).get('run');
if (wanted && index.some(e => e.run === wanted)) {
$('run').value = wanted; await loadRun(wanted);
// A link to a finished run is a replay link: don't let auto-hop drag the
// viewer off to whatever happens to be playing. A link to a live run still
// follows the agent into its next game, which is what `start` prints it for.
if (runObj && runObj.status !== 'playing') pinned = true;
}
else if (index[0]) await loadRun(index[0].run);
// live=1 仍然接受(start 打印的链接带它),但跟随本来就是默认开的。
if (new URLSearchParams(location.search).get('live') === '0') follow(false);