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 `