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>
154 lines
8.2 KiB
JavaScript
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);
|
|
});
|