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 => ``).join('') : ''; // 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 ? `${secs(d.ms)}${d.actions > 1 ? ` · 连做 ${d.actions} 步` : ''}` : ''; return `
  • ${badge}${describeAction(s.action)} → ${describeResult(s.result)}${s.note ? `${s.note}` : ''}
  • `; }).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]) => `
    ${k}
    ${v}
    `).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]) => `
    ${k}
    ${v}
    `).join(''); $('rating').innerHTML = runObj.rating ? RATING_QUESTIONS.map(q => `
    ${q.key}
    ${runObj.rating[q.key] ?? '—'}
    `).join('') : '
    尚未评分
    —
    '; if (runObj.status === 'gave-up') $('rating').insertAdjacentHTML('afterbegin', `
    放弃原因
    ${runObj.reason || '—'}
    `); } // ---------- 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);