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>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
# 关卡实验台(Agent Bench)
|
||||
|
||||
让任意 Agent **像人一样**玩关卡:只看盘面、只用 tap / rotate / undo(可选 peek)与棋盘交互,每一步写理由,结束后评分。你在观察页看它怎么玩,就能判断关卡对不对;多个 Agent 跑完再看汇总。
|
||||
|
||||
它不是求解器,也不是真人测试。用途是:**抓"体感无解"、找卡点、给关卡做相对排序**。绝对难度仍要少量真人校准(见文末)。
|
||||
|
||||
## 架构:一个 JSON 文件,三种接法
|
||||
|
||||
```
|
||||
Agent ──(shell)──▶ scripts/bench.mjs ──写──▶ bench/runs/<局>.json ◀──轮询── labs/bench/index.html(观察页)
|
||||
Agent ──(API)───▶ scripts/bench-agent.mjs ──写──▶ 同上
|
||||
```
|
||||
|
||||
- `src/bench.js` 会话核心:`observe()` 给的是人眼在屏幕上看到的东西——**每枚棋子当前的路线**(依次经过哪些格、在哪里出界或闭环、路上压着谁 `[tX]`),加朝向和剩余数;**不给求解器,不给旋转后的预测**。它没有替 Agent 做的事:决定先后顺序、判断何时旋转和转哪边、发现擦碰(棋子有宽度,路线上没人也可能被邻格的棋子挡——反馈会告诉它被谁挡)。`act()` 经过真实的 `Game` 规则;`metrics()` 事后用求解器算指标。
|
||||
- 第一版观察只给每格的端口配对表,Haiku 只能靠坐标运算读路,结果是盲猜、连边上的棋子都不点。改成路线之后它 18 步清盘、3 次旋转全部有效,但 3 次空点全是擦碰——路线上没人,却被紧邻的棋子挡。擦碰不在格子数据里(它来自轨道曲线和棋子宽度的扫掠判定),Agent 推不出来;而人看得见线从棋子身边擦过。所以把它作为路线标记直接给出:`紧贴 tX(会擦到)`,用真实碰撞判定算,不用邻格猜。
|
||||
- **一次只报第一个阻挡者是不够的。** 最初 `graze` 只取扫掠碰到的第一个障碍,路上压着一枚棋子时,它身后的擦碰就被吞掉——全库 581 例「路上有阻挡」里有 113 例(约两成)在清掉挡路者之后会冒出从未提示过的擦碰。grok-4.6 玩 18 关的 4 次空点全部由此而来,它自己写的是「路线描述没提前写出这次擦碰」。现在 `route.grazes` 是数组,逐枚单独做扫掠判定,谁也遮不住谁,复测 0 例漏报。盘面读作 `路上有 t6,另外紧贴 t4(会擦到)`,含义是**列出来的全部清掉这枚才能走**。
|
||||
- 因此 `grazeDeadTaps` 的语义在修复前后不同:修复前测的是「系统没告诉它」,修复后测的是「它无视了提示」。跨这条分界线比较历史数据时要注意——按 `agent` 字段区分批次。
|
||||
- [`labs/bench/AGENT.md`](AGENT.md):**给 Agent 读的唯一文件**——规则、读盘、命令、评分、示例都在里面。
|
||||
- `scripts/bench.mjs` 命令行:任何有 shell 的 Agent(Codex、Claude Code、Grok Build、人)一条命令一步。`start` 自动生成局名、自动起服务、打印观察页地址。
|
||||
- `scripts/bench-agent.mjs` 参考循环:任何只有 API 的模型(含弱模型、本地模型),纯文本进、一行 JSON 出,不需要 tool-use。
|
||||
- `labs/bench/`:观察页,用主游戏的渲染和动画重放每一步,可直播。
|
||||
- `scripts/bench-report.mjs`:按关汇总。
|
||||
|
||||
没有服务端,没有依赖。`npm run dev` 的静态服务器已经能提供 `bench/runs/*.json`。
|
||||
|
||||
## 最短路径:给 Agent 一句话
|
||||
|
||||
在仓库根目录启动任意 shell agent(Claude Code / Codex / Grok Build / DeepSeek Harness…),说一句:
|
||||
|
||||
```
|
||||
读 labs/bench/AGENT.md,然后按它说的玩第 1 关。
|
||||
```
|
||||
|
||||
[`AGENT.md`](AGENT.md) 是**唯一**要给 Agent 的文件:规则、读盘方法、命令、评分表、完整示例都在里面。它读完就能开始;`start` 会自动生成局名、自动起本地服务、把观察页地址打印出来让它转告你。人类这边只要打开那个地址看着就行。
|
||||
|
||||
想换关就说"玩第 3 关",想换风格就说"用 reader 人设",想跑正式千关就说"加 `--set=levels/v0.5.json`"。
|
||||
|
||||
---
|
||||
|
||||
## 完整路径
|
||||
|
||||
按"你手上有什么"选:
|
||||
|
||||
| 路径 | 需要什么 | 单局速度 | 用途 |
|
||||
|---|---|---|---|
|
||||
| **A. 规则机器人** | 什么都不需要 | 毫秒级 | 千关粗筛:规则能不能照着清、哪些关盲转多 |
|
||||
| **B. Claude Code / Codex**(shell agent) | 订阅 | 精简协议 + 连点后约 1 分钟 | 看它怎么玩、评分、抓"体感无解" |
|
||||
| C/D. 别的 harness / 便携包 | 对方能跑 Node | 同 B | Grok Build、DeepSeek Harness、任何沙箱 |
|
||||
| E. 直连 API | API key | 每次决策 3–10 秒 | 有 key 时的批量主路径 |
|
||||
|
||||
### A. 规则机器人:先把 1000 关扫一遍
|
||||
|
||||
```bash
|
||||
BENCH_PROVIDER=mock node scripts/bench-batch.mjs --set=levels/v0.5.json --levels=all --concurrency=8 --prefix=bot-v05
|
||||
node scripts/bench-report.mjs
|
||||
```
|
||||
|
||||
它只做协议里写的四条规则:路上没人不紧贴谁就点、否则右转。18 关 D5 原型 <1 秒跑完。它不评分,但它的**盲转数**是个便宜的结构探针——一路右转就能清的关和必须转回来的关,一眼分开。
|
||||
|
||||
### B. Claude Code / Codex:看它玩
|
||||
|
||||
单局——对 agent 说一句:
|
||||
|
||||
```
|
||||
读 labs/bench/AGENT.md,然后按它说的玩第 1 关。
|
||||
```
|
||||
|
||||
多局并行——用非交互模式开多个进程,各玩一关:
|
||||
|
||||
```bash
|
||||
for L in 1 2 3 4 5 6; do
|
||||
claude -p "读 labs/bench/AGENT.md,然后按它说的玩第 $L 关,--model 写 claude。" \
|
||||
--allowedTools "Read,Bash(node scripts/bench.mjs*)" &
|
||||
done; wait
|
||||
node scripts/bench-index.mjs && node scripts/bench-report.mjs --by=model
|
||||
```
|
||||
|
||||
Codex 同理(`codex exec "…"`)。观察页 `刷新列表` 能看到正在跑的局;`?run=<局名>&live=1` 直播。
|
||||
|
||||
提速要点(已经做进 AGENT.md 和 CLI 里):`AGENT.md` 一个文件读完就能开始;`start --no-rules` 不重复打印规则、自动生成局名、自动起服务;`act` 用逗号连点,一次看盘做一串。
|
||||
|
||||
实测(Opus 5,第 1 关,含子代理启动与读盘):逐步 15 次决策 154 s → 连点 4 次决策 93 s → 精简协议 4 次决策 **82 s**。`claude -p` 非交互模式第 2 关:8 次决策 **76 s**。剩余时间大头是进程启动和第一次读盘(约 20–30 s);每次决策约 8–10 s。要更快只能并行:`claude -p` 开 6 个进程,18 关约 4 分钟。
|
||||
|
||||
### C. 别的 agent harness(Grok Build / DeepSeek Harness / Codex …)
|
||||
|
||||
**Grok Build(`grok`)和 DeepSeek Harness(`dsh`)都是本机 CLI,能执行 bash、能读写本地文件**,所以它们和 Claude Code 走同一条路:在仓库根目录启动,把 B 节那段提示给它。区别只是提示词里 `--model=` 写它自己的名字。
|
||||
|
||||
```bash
|
||||
# Grok Build:curl -fsSL https://x.ai/cli/install.sh | bash
|
||||
grok # 然后贴 B 节的提示;注意它默认不开沙箱,需要时用 workspace profile
|
||||
# DeepSeek Harness:npx @deepseek-ai/dsh web (developer preview,接口会变)
|
||||
```
|
||||
|
||||
如果对方拿不到这个仓库(另一台机器、另一个人、只能在自己沙箱里跑的 harness),发**独立实验台**给他(下一节)。
|
||||
|
||||
### D. 独立实验台:发给任何人
|
||||
|
||||
```bash
|
||||
node scripts/bench-kit.mjs --zip # → bench/hex-bench/ 和 bench/hex-bench.zip
|
||||
```
|
||||
|
||||
生成一个**自带观察页**的独立工程:`bench.mjs`(命令行)、`serve.mjs`(本地服务)、`report.mjs`(汇总)、`observer/`(观察页)、`src/`(引擎,按 import 自动追踪,不会漏)、`levels.json`、`AGENT.md`、`README.md`。约 1.5 MB,**只要 Node 20+,不联网、不装依赖、不需要原仓库**。
|
||||
|
||||
对方那边三步:`node serve.mjs` → 对 agent 说「读 `AGENT.md`,按它说的玩第 1 关」→ 打开 <http://localhost:4173/observer/index.html> 看着。包里的 `README.md` 就是写给这个人看的。
|
||||
|
||||
构建时会在生成的包里真跑一遍 list → start → act → rate → report 并检查观察页的依赖齐全,所以不会发出一个跑不起来的包。每一处路径改写都有断言:仓库里改了名字,构建会直接报错而不是悄悄发坏包。
|
||||
|
||||
玩完让对方把 `runs/` 发回来:
|
||||
|
||||
```bash
|
||||
node scripts/bench-import.mjs --from=<路径>/runs --tag=grok
|
||||
node scripts/bench-report.mjs --by=model
|
||||
```
|
||||
|
||||
`--tag` 会给局名加前缀,不同来源不会撞名。导入后在观察页照常回放。
|
||||
|
||||
(`scripts/bench-pack.mjs` 是它的前身——只有 7 个文件、没有观察页。已被 `bench-kit` 完全取代,可以删掉。)
|
||||
|
||||
### E. 直连 API(有 key 时最快)
|
||||
|
||||
不需要 shell、不需要 tool-use,模型每轮输出一行 JSON 或一个数组。预设了四个端点:
|
||||
|
||||
```bash
|
||||
BENCH_PROVIDER=anthropic ANTHROPIC_API_KEY=… node scripts/bench-batch.mjs --levels=1-18 --personas=tapper,reader --concurrency=6
|
||||
BENCH_PROVIDER=deepseek DEEPSEEK_API_KEY=… node scripts/bench-batch.mjs --levels=1-18 --model=deepseek-flash
|
||||
BENCH_PROVIDER=xai XAI_API_KEY=… node scripts/bench-batch.mjs --levels=1-18 --model=grok-4.6
|
||||
BENCH_PROVIDER=openai OPENAI_API_KEY=… node scripts/bench-batch.mjs --levels=1-18 --model=gpt-4o-mini
|
||||
BENCH_PROVIDER=openai BENCH_BASE_URL=http://localhost:11434/v1 node scripts/bench-batch.mjs --levels=1-18 --model=qwen2.5:7b # Ollama / vLLM
|
||||
node scripts/bench-agent.mjs --run=x --level=1 --dry # 只看提示,不联网
|
||||
```
|
||||
|
||||
| 预设 | base URL | 环境变量 | 默认模型 |
|
||||
|---|---|---|---|
|
||||
| `anthropic` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | `claude-sonnet-5` |
|
||||
| `deepseek` | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` | `deepseek-flash` |
|
||||
| `xai` | `https://api.x.ai/v1` | `XAI_API_KEY` | `grok-4.6` |
|
||||
| `openai` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | `gpt-4o-mini` |
|
||||
|
||||
模型标识符会变,用 `--model` 覆盖(例如 DeepSeek 的 `deepseek-chat` / `deepseek-reasoner` 已于 2026-07-24 下线,现在是 `deepseek-flash` / `deepseek-v4-pro`)。请求体只用 `model` / `messages` / `max_tokens` / `temperature`——DeepSeek 不接受 `frequency_penalty` 和 `presence_penalty`。
|
||||
|
||||
这条路径的**网络部分本仓库没有 key,未实测**;循环、解析、连点、provider 路由、索引重建都用规则机器人跑通了。
|
||||
|
||||
## 速度:连点
|
||||
|
||||
一步一个 LLM 往返是最大的开销(一局 15 步约 2.5 分钟,人玩这关不到 1 分钟)。解决办法和人一样——**看清一次盘面就连着点几枚**:
|
||||
|
||||
```bash
|
||||
node scripts/bench.mjs act --run=x tap t4, tap t5, tap t8, rotate right --note="三枚边上的,清完就转"
|
||||
```
|
||||
|
||||
链在第一个没成功的动作和任何一次旋转之后停下并返回新盘面,所以它不会"闭眼连点"到错的地方。API 循环里对应输出一个 JSON 数组。指标里 `decisions`(看盘次数)和 `steps`(动作数)分开记:同样 15 步,连点大约 5–6 次决策。
|
||||
|
||||
## 怎么看
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- 回放:`http://localhost:4173/labs/bench/index.html?run=<局名>`——下拉选局,点右侧实况里的任意一步即可回看那一刻的棋盘,「↩ 回到实时」跟回 Agent。右侧竖排是计时板与 Agent 每一步的理由,指标和评分在页面下方。棋盘上方的横幅显示当前是谁在玩(`--model` / `--agent`)。
|
||||
- 直播:勾选「直播」或 URL 加 `&live=1`,页面每秒读一次文件,Agent 每走一步这里就动一步。
|
||||
- 汇总:`node scripts/bench-report.mjs`(写 `bench/report.json`)。带标记的关(有放弃 / 有人觉得无解 / 盲转 ≥2)先看。
|
||||
|
||||
## 指标(事后算,Agent 看不到)
|
||||
|
||||
| 指标 | 含义 | 对应 §08 |
|
||||
|---|---|---|
|
||||
| 单局耗时 / 每次决策 | 墙钟时间,含 agent 自己的进程启动;`decisions` 与 `steps` 分开记 | — |
|
||||
| 动作 ÷ 棋子 | 效率;猴子玩家全库 1.67 | — |
|
||||
| 最长连续空点 | 点了没动的最长连续次数;某关中位 ≥3 = 可读性问题 | 连续空点 |
|
||||
| 盲转 / 转错方向 | 一次旋转什么都没打开 / 另一边本来能打开 | 卡住停留、旋转效率 |
|
||||
| 进入卡住状态 | 动作后无棋可走的次数 | — |
|
||||
| 多转 | 清盘用的旋转数 − 最少旋转数 | 旋转效率 |
|
||||
| 放弃 + stuck 评分 | "体感无解"的直接信号 | 逐关会话终止率 |
|
||||
|
||||
## 边界
|
||||
|
||||
- Agent 不是人:推理贵、感知弱。用它做**相对**排序和卡点检测;不要把 clear rate 当真人通关率。
|
||||
- 观察是结构化文本,不是像素。它测不出"看不清"。要测可读性,下一步是纯截图模式(让模型看图点坐标),本仓库尚未实现。
|
||||
- 没有动画时长、没有连点队列:一步一步来,等价于一个不急的玩家。
|
||||
- 校准:同一批关让 5–10 个真人玩一次,把真人的空点、放弃、评分与 Agent 汇总对照,找出 Agent 系统性偏差,再用 Agent 去筛更多关。
|
||||
@@ -0,0 +1,331 @@
|
||||
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);
|
||||
@@ -0,0 +1,104 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>关卡实验台 · Agent 观察</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<h1 class="sr-only">关卡实验台 · Agent 观察</h1>
|
||||
<a class="back" href="../../">← 返回主线</a>
|
||||
<div class="picker">
|
||||
<select id="run" aria-label="选择一局"></select>
|
||||
<button id="reload" type="button" class="small">刷新列表</button>
|
||||
<label class="toggle"><input id="live" type="checkbox" checked> 跟随 Agent</label>
|
||||
</div>
|
||||
<span class="badge">AGENT BENCH</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- 棋盘占主位,实时信息在右侧竖排:看盘的人先看棋盘,再瞟一眼计时。 -->
|
||||
<div class="stage">
|
||||
<section class="board-col">
|
||||
<div class="who">
|
||||
<div class="who-id">
|
||||
<b id="who-model">—</b>
|
||||
<span id="who-agent" class="who-agent"></span>
|
||||
</div>
|
||||
<span id="who-level" class="who-level"></span>
|
||||
</div>
|
||||
<div class="board-wrap"><canvas id="board" aria-label="Agent 正在操作的棋盘"></canvas></div>
|
||||
<!-- 只在读不到存档时才出现,正常观看时不占位置 -->
|
||||
<p class="notice" id="notice" role="status" hidden></p>
|
||||
</section>
|
||||
|
||||
<aside class="rail">
|
||||
<section class="scoreboard" aria-label="实时计时与进度">
|
||||
<div class="clock primary">
|
||||
<span class="k">总用时</span>
|
||||
<b id="clock" class="tick">0:00.000</b>
|
||||
</div>
|
||||
<div class="clock think">
|
||||
<span class="k" id="think-label">本步思考</span>
|
||||
<b id="think" class="tick">0.0s</b>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="cell">
|
||||
<span class="k">步数</span>
|
||||
<b id="sb-steps">0</b><small id="sb-budget">/ 80</small>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<span class="k">决策</span>
|
||||
<b id="sb-decisions">0</b><small id="sb-per">—</small>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<span class="k">已离场</span>
|
||||
<b id="sb-cleared">0</b><small id="sb-total">/ 0</small>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<span class="k">空点 / 旋转</span>
|
||||
<b id="sb-dead">0</b><small id="sb-rot">· 0 转</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar"><i id="sb-bar"></i></div>
|
||||
<div id="sb-status" class="pill">—</div>
|
||||
</section>
|
||||
|
||||
<section class="feed-col">
|
||||
<div class="feed-head">
|
||||
<h2>实况</h2>
|
||||
<span id="feed-state" class="feed-state"></span>
|
||||
<button id="end" type="button" class="small back-live" hidden>↩ 回到实时</button>
|
||||
</div>
|
||||
<!-- One tab stop, then ↑↓ walks the replay — the keyboard equivalent of
|
||||
the progress bar this list replaced. -->
|
||||
<ol id="steps" class="steps" tabindex="0" role="log"
|
||||
aria-label="每一步的动作与理由,方向键可回看任意一步"></ol>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="details">
|
||||
<div class="panel">
|
||||
<h2>耗时明细</h2>
|
||||
<dl id="timing" class="kv"></dl>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>指标</h2>
|
||||
<dl id="metrics" class="kv"></dl>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>Agent 评分</h2>
|
||||
<dl id="rating" class="kv"></dl>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>数据来自 <code>bench/runs/*.json</code>。开一局:<code>node scripts/bench.mjs start --model=你是谁 --level=1</code>;Agent 读的说明见 <code>labs/bench/AGENT.md</code>。点右侧任意一步可回看那一刻的棋盘。</p>
|
||||
</footer>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,169 @@
|
||||
:root{
|
||||
/* ink3 is the smallest type on the page; it has to clear 4.5:1 on white. */
|
||||
--ink:#1a2430; --ink2:#516678; --ink3:#647385; --ink4:#5a6974;
|
||||
/* One source of truth for the two-column width, so the panels below the
|
||||
stage line up with it instead of running the full window. */
|
||||
--rail:clamp(270px,25vw,340px);
|
||||
--boardmax:calc((100svh - 205px) * .92);
|
||||
--stage-w:calc(var(--boardmax) + var(--rail) + 16px);
|
||||
--page:#eceff2; --card:#fff; --line:#d6dee5; --line2:#c2ccd6;
|
||||
/* --live fills bars and the dark board; --live-ink is the readable text version. */
|
||||
--accent:#3f6d95; --live:#2ea36a; --live-ink:#1f7d50; --warn:#c2762a; --bad:#b8412b;
|
||||
--board-bg:#11181f; --board-ink:#e8eef4; --board-dim:#8798a8;
|
||||
font-family:system-ui,-apple-system,"PingFang SC",sans-serif;color:var(--ink);background:var(--page);font-synthesis:none;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;padding-bottom:28px}
|
||||
h1,h2{margin:0}
|
||||
code{font-family:ui-monospace,Menlo,monospace;font-size:.92em;background:#e2e8ee;padding:1px 4px;border-radius:3px}
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
.topbar{display:flex;align-items:center;gap:14px;padding:10px 20px;background:var(--card);border-bottom:1px solid var(--line);flex-wrap:wrap}
|
||||
.back{color:var(--ink2);text-decoration:none;font-size:13px;white-space:nowrap}
|
||||
.badge{font-size:10px;letter-spacing:.14em;color:var(--ink3);border:1px solid var(--line2);border-radius:20px;padding:5px 11px;margin-left:auto;white-space:nowrap}
|
||||
.picker{display:flex;align-items:center;gap:9px;flex:1;min-width:280px;font-size:12px;color:var(--ink2)}
|
||||
.picker select{font:13px system-ui;padding:6px 9px;border:1px solid var(--line2);border-radius:8px;background:#fff;color:var(--ink);flex:1;min-width:0;max-width:560px}
|
||||
.toggle{display:inline-flex;align-items:center;gap:6px;cursor:pointer;white-space:nowrap}
|
||||
input[type=checkbox]{accent-color:var(--accent)}
|
||||
button{font:14px system-ui;color:var(--ink2);background:#f7fafc;border:1px solid var(--line2);border-radius:10px;padding:7px 12px;cursor:pointer}
|
||||
button:hover{background:#fff;color:var(--ink)}
|
||||
button:disabled{opacity:.4;cursor:default}
|
||||
button.small{font-size:12px;padding:5px 10px;border-radius:8px}
|
||||
button:focus-visible,a:focus-visible,select:focus-visible,input:focus-visible,.steps:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
||||
/* Reachable by screen readers, invisible on screen. */
|
||||
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
|
||||
|
||||
/* ---------- stage: board left, live rail right ---------- */
|
||||
/* The board is a portrait hexagon, so its card is height-bound: cap the board
|
||||
column near the height it can actually use and centre the pair, instead of
|
||||
stretching a card whose middle 40% would be empty. */
|
||||
.stage{display:grid;grid-template-columns:minmax(0,var(--boardmax)) var(--rail);
|
||||
gap:16px;padding:16px 20px 0;align-items:stretch;justify-content:center}
|
||||
.board-col{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;display:flex;flex-direction:column;min-width:0}
|
||||
/* The canvas is taken out of flow on purpose. Left in flow, `height:100%`
|
||||
degrades to auto during intrinsic sizing and the box falls back to the
|
||||
canvas *attribute* ratio — which resize() writes from this very box. That
|
||||
loop let a detour through a short window permanently inflate the card. */
|
||||
.board-wrap{flex:1;width:100%;position:relative;min-height:clamp(340px,calc(100svh - 235px),760px)}
|
||||
canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
|
||||
/* Only surfaces when a run's JSON can't be read — otherwise it takes no space. */
|
||||
.notice{margin:10px 0 0;text-align:center;font-size:13px;line-height:1.6;color:var(--bad)}
|
||||
|
||||
/* 谁在玩:棋盘正上方的身份横幅 */
|
||||
.who{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap;
|
||||
padding:0 2px 10px;margin-bottom:10px;border-bottom:1px solid var(--line)}
|
||||
.who-id{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;min-width:0}
|
||||
.who-id b{font-size:19px;font-weight:700;letter-spacing:-.01em}
|
||||
/* Agents self-report free text, so assume the worst: a slug or user-agent
|
||||
string with no spaces has to break rather than push the page sideways. */
|
||||
.who-agent{font-size:13px;color:var(--accent);font-weight:600;min-width:0;overflow-wrap:anywhere}
|
||||
/* Hang the separator off the model name. As ::before on the agent it wrapped
|
||||
to the start of the next line and read like a bullet. */
|
||||
.who-id:has(.who-agent:not(:empty)) b::after{content:'·';margin-left:10px;color:var(--ink3);font-weight:400}
|
||||
.who-level{font-size:11.5px;color:var(--ink3);font-variant-numeric:tabular-nums}
|
||||
|
||||
.rail{display:flex;flex-direction:column;gap:16px;min-width:0;min-height:0}
|
||||
|
||||
/* ---------- scoreboard: a vertical panel beside the board ---------- */
|
||||
.scoreboard{background:var(--board-bg);color:var(--board-ink);border-radius:14px;padding:16px;
|
||||
display:flex;flex-direction:column;gap:13px;flex:0 0 auto;transition:opacity .2s}
|
||||
/* Rewound: the clock and the totals belong to the whole run, so they no longer
|
||||
describe the board sitting right next to them. Say so instead of lying. */
|
||||
.scoreboard.rewound{opacity:.55}
|
||||
@media(prefers-reduced-motion:reduce){.scoreboard{transition:none}}
|
||||
.scoreboard .clock{display:flex;flex-direction:column;gap:4px;min-width:0}
|
||||
.scoreboard .k{font-size:10px;letter-spacing:.12em;color:var(--board-dim);text-transform:uppercase;white-space:nowrap}
|
||||
.scoreboard b{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums;font-weight:600;line-height:1;font-size:19px}
|
||||
.scoreboard small{font-size:11px;color:var(--board-dim);margin-left:4px;font-family:ui-monospace,Menlo,monospace}
|
||||
.clock.primary b{font-size:clamp(32px,3.1vw,42px);letter-spacing:-.02em}
|
||||
.clock.think b{font-size:23px;color:#f0c674}
|
||||
.tick.hot{color:#ffcf6b}
|
||||
/* Each readout is a wrapping row: the label owns a line, the number keeps its
|
||||
suffix beside it. Stacking all three (a flex column blockifies `inline`)
|
||||
cost a third line per cell and made the panel needlessly tall. */
|
||||
.scoreboard .stats{display:grid;grid-template-columns:1fr 1fr;gap:12px 12px;border-top:1px solid #27323d;padding-top:13px}
|
||||
.scoreboard .cell{display:flex;flex-wrap:wrap;align-items:baseline;min-width:0}
|
||||
.scoreboard .cell .k{flex:0 0 100%;margin-bottom:4px}
|
||||
.scoreboard .bar{height:5px;background:#27323d;border-radius:3px;overflow:hidden}
|
||||
.scoreboard .bar i{display:block;height:100%;background:var(--live);width:0;transition:width .3s}
|
||||
.pill{align-self:flex-start;font-size:12px;font-weight:600;letter-spacing:.04em;padding:7px 14px;border-radius:20px;white-space:nowrap;background:#25303b;color:var(--board-dim)}
|
||||
.pill.rewound{background:#2b3742;color:#c7d4de}
|
||||
.pill.playing{background:rgba(46,163,106,.18);color:#5ed69b}
|
||||
.pill.playing::before{content:'●';margin-right:6px;animation:blink 1.4s infinite}
|
||||
.pill.cleared{background:rgba(46,163,106,.25);color:#7fe6b4}
|
||||
.pill.gaveup{background:rgba(184,65,43,.22);color:#ff9b85}
|
||||
.pill.budget{background:rgba(194,118,42,.22);color:#f3c98a}
|
||||
@keyframes blink{50%{opacity:.25}}
|
||||
|
||||
/* ---------- live feed ---------- */
|
||||
.feed-col{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;display:flex;flex-direction:column;flex:1 1 auto;min-height:200px}
|
||||
/* The button and the live state are siblings of the heading, not inside it —
|
||||
nested, they became part of the heading's accessible name. */
|
||||
.feed-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.feed-col h2{font-size:11px;letter-spacing:.16em;color:var(--ink3);text-transform:uppercase}
|
||||
.feed-state{font-size:10.5px;letter-spacing:.02em;color:var(--live-ink);font-weight:600}
|
||||
.back-live{margin-left:auto;color:var(--accent);border-color:var(--accent)}
|
||||
.steps{margin:0;padding:0;list-style:none;overflow-y:auto;flex:1 1 0;min-height:0;font-size:12.5px;counter-reset:step}
|
||||
.steps li{padding:8px 10px 8px 32px;border-bottom:1px solid #eef2f5;position:relative;cursor:pointer;line-height:1.55;counter-increment:step}
|
||||
.steps li::before{content:counter(step);position:absolute;left:8px;top:8px;font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--ink3)}
|
||||
.steps li:last-child{border-bottom:none}
|
||||
.steps li.lead{border-top:1px solid var(--line);margin-top:2px}
|
||||
.steps li:first-child.lead{border-top:none;margin-top:0}
|
||||
.steps li b{font-weight:600}
|
||||
.steps li i{display:block;color:var(--ink2);font-style:normal;font-size:11.5px;margin-top:2px}
|
||||
.steps li em{float:right;margin-left:8px;font-style:normal;font-size:10.5px;color:var(--accent);background:#eaf1f7;border-radius:9px;padding:1px 7px;font-variant-numeric:tabular-nums;font-weight:600}
|
||||
.steps li.current{background:#eaf1f7;box-shadow:inset 3px 0 0 var(--accent)}
|
||||
.steps li:hover{background:#f2f6f9}
|
||||
|
||||
/* ---------- details below ---------- */
|
||||
.details{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px;padding:16px 20px 0;
|
||||
width:100%;max-width:calc(var(--stage-w) + 40px);margin-inline:auto}
|
||||
.panel{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px}
|
||||
.panel h2{font-size:11px;letter-spacing:.16em;color:var(--ink3);text-transform:uppercase;margin-bottom:10px}
|
||||
.kv{margin:0;font-size:12.5px}
|
||||
.kv>div{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-bottom:1px dashed #e4eaef}
|
||||
.kv>div:last-child{border-bottom:none}
|
||||
.kv dt{color:var(--ink3)}
|
||||
.kv dd{margin:0;font-variant-numeric:tabular-nums;text-align:right;color:var(--ink);font-weight:600}
|
||||
.kv .giveup dd{color:var(--bad);text-align:left;font-weight:400}
|
||||
|
||||
footer{text-align:center;margin:20px auto 0;max-width:760px;padding:0 20px;font-size:11px;color:var(--ink4);line-height:1.7}
|
||||
|
||||
/* The rail needs ~260px to stay readable; below that it goes under the board,
|
||||
where the clock turns into a wide two-up row instead of a tall column. */
|
||||
@media(max-width:820px){
|
||||
.stage{grid-template-columns:1fr;align-items:start}
|
||||
/* Stacked, the card is full width, so the board should be tall enough to
|
||||
use it — 62vw left a 603px canvas holding a 330px board. */
|
||||
.board-wrap{min-height:clamp(280px,min(96vw,64svh),680px)}
|
||||
.scoreboard{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:12px 18px}
|
||||
.scoreboard .clock.primary{grid-column:1}
|
||||
.scoreboard .clock.think{grid-column:2}
|
||||
.clock.primary b{font-size:38px}
|
||||
.scoreboard .stats{grid-column:1 / -1;grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
.scoreboard .bar,.pill{grid-column:1 / -1}
|
||||
/* Stacked, the rail's height comes from its content, so `flex:1 1 0` resolved
|
||||
to zero and the list vanished — taking the only way to replay a step with
|
||||
it. Give the list its own height instead of a share of a leftover. */
|
||||
.feed-col{min-height:0}
|
||||
.steps{flex:0 0 auto;max-height:min(340px,52svh)}
|
||||
}
|
||||
@media(max-width:520px){
|
||||
.topbar{padding:8px 12px;gap:8px}
|
||||
.picker{min-width:100%;order:3}
|
||||
.picker select{max-width:none}
|
||||
.stage,.details{padding-left:12px;padding-right:12px}
|
||||
.board-col,.feed-col,.panel{padding:12px}
|
||||
.scoreboard{padding:14px;gap:11px 14px}
|
||||
.clock.primary b{font-size:34px}
|
||||
.clock.think b{font-size:20px}
|
||||
.scoreboard .stats{grid-template-columns:1fr 1fr}
|
||||
.who-id b{font-size:17px}
|
||||
}
|
||||
/* Below ~400px the two clocks no longer fit on one row: 「本步思考」 gets about
|
||||
94px and its mono value needs 96. Stack them rather than clip the digits. */
|
||||
@media(max-width:400px){
|
||||
.scoreboard .clock.primary,.scoreboard .clock.think{grid-column:1 / -1}
|
||||
.clock.think b{font-size:22px}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce){.pill.playing::before{animation:none}.scoreboard .bar i{transition:none}}
|
||||
Reference in New Issue
Block a user