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); });