Files
cabinwandClaude Opus 5 c760c23fd1 feat(bench): agent bench session core and CLI
Let any agent with a shell play a level the way a person does: look at the
board, decide what to tap or which way to turn, read the feedback, decide
again. Every step carries the agent's own reason, so a human can watch and
judge the level from how it was played.

observe() returns what the eye sees — each piece's drawn route, which cells it
crosses, who is sitting on it, and who it brushes past — never "which pieces
can leave". act() goes through the real Game rules; metrics() scores the run
afterwards with the solver.

Pieces are wide, so a route with nobody standing on it can still be blocked by
a neighbour the curve hugs. That is invisible in a cell listing, so every
obstacle is reported, on-route and grazing alike: reporting only the leading
one hid the rest, and in 113 of 581 such cases clearing the blocker sprang a
collision the agent had never been shown.

AGENT.md is the single file an agent reads — rules, board reading, commands,
rating scale, worked example.

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

154 lines
8.2 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { hexagon } from '../src/core.js';
import { createRun, observe, act, actMany, rate, metrics, replay, rulesText, decisionTimeline } from '../src/bench.js';
// Straight rails on a 19-cell board: t1 sits on t0's route, so t0 is blocked
// until t1 leaves. Turning the board swaps which rails the pieces sit on.
function fixture() {
return {
version: 1, id: 'bench-fixture', name: 'fixture', cells: hexagon(2), rotationEnabled: true, moveLimit: 2,
tiles: [{ id: 't0', q: 0, r: 0, dir: 0, color: 0 }, { id: 't1', q: 0, r: -1, dir: 0, color: 1 }],
};
}
test('observation exposes board and pieces but never which pieces can leave', () => {
const run = createRun({ level: fixture(), run: 'obs', budget: 10 });
const json = observe(run, { format: 'json' });
assert.equal(json.remaining, 2);
assert.equal(json.cells.length, 19);
assert.deepEqual(Object.keys(json.pieces[0]).sort(), ['arrow', 'color', 'dir', 'id', 'q', 'r', 'route']);
// Routes are the drawn line a person sees: geometric, with pieces sitting on it marked.
const t0 = json.pieces.find(p => p.id === 't0'), t1 = json.pieces.find(p => p.id === 't1');
assert.equal(t0.route.end, 'exit');
assert.deepEqual(t0.route.onRoute, ['t1'], 't1 sits on t0\'s line');
assert.deepEqual(t1.route.onRoute, []);
assert.deepEqual(t0.route.grazes, [], 'a blocker on the line is not a graze');
assert.deepEqual(t1.route.grazes, [], 'nothing brushes a clear straight line');
assert.ok(!JSON.stringify(json).includes('available'));
assert.ok(!JSON.stringify(json).includes('solvable'));
const text = observe(run);
assert.match(text, /剩余 2\/2 枚/);
assert.match(text, /t0 @ 0,0 ↑:0,-1\[t1\] → 0,-2 → 出界(2 格 · 0 弯)· 路上有 t1/);
assert.match(text, /t1 @ 0,-1 ↑:0,-2 → 出界(1 格 · 0 弯)· 路上没有棋子/);
assert.doesNotMatch(text, /端口配对/, 'the rail table is opt-in');
assert.match(observe(run, { format: 'full' }), /端口配对/);
assert.match(rulesText(false), /怎么玩/);
assert.doesNotMatch(rulesText(false), /peek/);
assert.match(rulesText(true), /peek/);
});
test('actions go through the real game: blocked, exit with unlock, cleared', () => {
const run = createRun({ level: fixture(), run: 'play', budget: 10 });
let out = act(run, { type: 'tap', id: 't0', note: '先试最里面的' });
assert.equal(out.step.result.status, 'blocked');
assert.equal(out.step.result.blocker, 't1');
assert.match(out.feedback, /被 t1 挡住/);
out = act(run, { type: 'tap', id: 't1' });
assert.equal(out.step.result.status, 'exit');
assert.equal(out.step.result.opened, 1, 'removing the gate opens t0');
out = act(run, { type: 'tap', id: 't0' });
assert.equal(out.step.result.status, 'exit');
assert.equal(run.status, 'cleared');
assert.equal(replay(run).won, true);
assert.match(act(run, { type: 'tap', id: 't0' }).feedback, /已结束/);
const m = metrics(run);
assert.equal(m.taps, 3); assert.equal(m.exits, 2); assert.equal(m.deadTaps, 1);
assert.equal(m.longestDeadStreak, 1); assert.equal(m.stepsToFirstExit, 2); assert.equal(m.cleared, true);
assert.equal(run.steps[0].note, '先试最里面的');
});
test('rotate reports how many pieces opened, undo restores, peek is gated', () => {
const run = createRun({ level: fixture(), run: 'turn', budget: 10 });
const turned = act(run, { type: 'rotate', direction: 1 });
assert.equal(turned.step.result.status, 'rotated');
assert.equal(turned.step.result.orientation, 1);
assert.equal(typeof turned.step.result.opened, 'number');
assert.equal(observe(run, { format: 'json' }).orientation, 1);
assert.equal(act(run, { type: 'undo' }).step.result.status, 'undone');
assert.equal(observe(run, { format: 'json' }).orientation, 0);
assert.match(act(run, { type: 'peek', id: 't0' }).feedback, /未开启 peek/);
assert.match(act(run, { type: 'rotate', direction: 2 }).feedback, /direction/);
const peeking = createRun({ level: fixture(), run: 'peek', peek: true, budget: 10 });
const seen = act(peeking, { type: 'peek', id: 't0' });
assert.equal(seen.step.result.status, 'peek');
assert.equal(seen.step.result.route, 'blocked');
assert.equal(seen.step.result.blocker, 't1');
assert.equal(metrics(peeking).peeks, 1);
});
test('a chained batch stops at the first failed tap and after a turn', () => {
const run = createRun({ level: fixture(), run: 'chain', budget: 20 });
// t0 is blocked by t1: the chain must stop there and not tap t1.
let batch = actMany(run, [{ type: 'tap', id: 't0' }, { type: 'tap', id: 't1' }], { note: '连点试试' });
assert.equal(batch.outcomes.length, 1);
assert.equal(batch.outcomes[0].step.result.status, 'blocked');
assert.match(batch.stopped, /没成功/);
assert.equal(run.steps.length, 1);
assert.equal(run.steps[0].batch, 1);
assert.equal(run.steps[0].note, '连点试试');
// A turn ends the chain even when it succeeds.
batch = actMany(run, [{ type: 'rotate', direction: 1 }, { type: 'tap', id: 't1' }]);
assert.equal(batch.outcomes.length, 1);
assert.match(batch.stopped, /旋转后/);
actMany(run, [{ type: 'undo' }]);
// The good order clears in one decision.
batch = actMany(run, [{ type: 'tap', id: 't1' }, { type: 'tap', id: 't0' }]);
assert.equal(batch.outcomes.length, 2);
assert.equal(batch.stopped, null);
assert.equal(run.status, 'cleared');
const m = metrics(run);
assert.equal(m.steps, 5);
assert.equal(m.decisions, 4, 'four looks at the board: blocked chain, turn, undo, clearing chain');
});
test('the agent self-reports its identity, and it survives into metrics', () => {
const run = createRun({ level: fixture(), run: 'who', model: 'claude-opus-5', agent: 'Claude Code · ultracode', budget: 5 });
assert.equal(run.model, 'claude-opus-5');
assert.equal(run.agent, 'Claude Code · ultracode');
assert.equal(metrics(run).agent, 'Claude Code · ultracode');
// Missing is fine — the banner falls back to the model alone.
assert.equal(createRun({ level: fixture(), run: 'anon' }).agent, '');
// A runaway self-description must not become an unbounded banner.
assert.equal(createRun({ level: fixture(), run: 'long', agent: 'x'.repeat(500) }).agent.length, 120);
});
test('timing groups a batch into one decision and measures the gap before it', () => {
const run = createRun({ level: fixture(), run: 'timing', budget: 20, startedAt: new Date(1000).toISOString() });
actMany(run, [{ type: 'tap', id: 't1' }, { type: 'tap', id: 't0' }]);
// Stamps come from the clock; rewrite them so the assertions are exact.
run.steps[0].at = 3000; run.steps[1].at = 3010;
const timeline = decisionTimeline(run);
assert.equal(timeline.length, 1, 'a chained batch is one decision');
assert.equal(timeline[0].actions, 2);
assert.equal(timeline[0].from, 1);
assert.equal(timeline[0].ms, 2010, 'latency runs from startedAt to the batch commit');
const m = metrics(run);
assert.equal(m.decisions, 1);
assert.equal(m.actionsPerDecision, 2);
assert.equal(m.timing.totalMs, 2010);
assert.equal(m.timing.medianDecisionMs, 2010);
assert.equal(m.timing.thinkMs, null, 'only the direct-API loop records model latency');
// A run recorded before timestamps existed must not crash or invent numbers.
const legacy = createRun({ level: fixture(), run: 'legacy', budget: 20 });
act(legacy, { type: 'tap', id: 't1' });
delete legacy.steps[0].at;
assert.equal(metrics(legacy).timing.totalMs, null);
assert.equal(decisionTimeline(legacy)[0].ms, null);
});
test('budget and give-up end the run; rating is recorded', () => {
const run = createRun({ level: fixture(), run: 'budget', budget: 1 });
act(run, { type: 'tap', id: 't0' });
assert.equal(run.status, 'budget');
assert.match(act(run, { type: 'tap', id: 't1' }).feedback, /已结束/);
const quit = createRun({ level: fixture(), run: 'quit', budget: 5 });
act(quit, { type: 'giveup', reason: '看起来无解' });
assert.equal(quit.status, 'gave-up');
assert.equal(quit.reason, '看起来无解');
assert.equal(metrics(quit).gaveUp, true);
const rating = rate(quit, { clarity: 2, stuck: 5, worst: '第 1 步' });
assert.equal(rating.clarity, 2); assert.equal(rating.fun, null);
assert.equal(metrics(quit).rating.stuck, 5);
});