272 lines
13 KiB
JavaScript
272 lines
13 KiB
JavaScript
// Headless verifier for the Tetris Attack engine.
|
|
// node tools/verifyTetrisAttack.js
|
|
// Exits non-zero if any check fails.
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
import {
|
|
COLS, ROWS, TUNING, mulberry32, newGame, step, trySwap, moveCursor,
|
|
findMatchGroups, findClearingSwap, isSettled, remainingTargets,
|
|
loadPuzzle, solvePuzzle, resolveFully, comboBonus, chainBonus,
|
|
} from '../src/games/tetrisattack/TetrisAttackLogic.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
let failures = 0;
|
|
let checks = 0;
|
|
function check(name, cond, detail = '') {
|
|
checks++;
|
|
if (!cond) { failures++; console.log(` ✗ ${name}${detail ? ' — ' + detail : ''}`); }
|
|
}
|
|
function section(t) { console.log(`\n${t}`); }
|
|
|
|
// Build a settled state from a bottom-first grid (for fixtures). Always places
|
|
// the explicit grid (via puzzle load) then retags the mode so endless/stageclear
|
|
// fixtures use the hand-authored board rather than a random fill.
|
|
function fromGrid(grid, mode = 'puzzle') {
|
|
const s = newGame({ mode: 'puzzle', puzzle: { grid, maxMoves: 20 } });
|
|
s.mode = mode;
|
|
if (mode !== 'puzzle') s.riseRate = 0; // keep fixtures still unless a test opts in
|
|
return s;
|
|
}
|
|
// Step until settled, recording the peak chain and all emitted event types.
|
|
function resolveRecord(state, rng = () => 0.5, cap = 3000) {
|
|
let peakChain = 0;
|
|
const types = new Set();
|
|
let i = 0;
|
|
while (i < cap) {
|
|
const events = step(state, rng);
|
|
for (const e of events) types.add(e.type);
|
|
peakChain = Math.max(peakChain, state.chain);
|
|
i++;
|
|
if (isSettled(state) && state.chain === 0) break;
|
|
}
|
|
return { peakChain, types, ticks: i };
|
|
}
|
|
|
|
// ── 1. Match detection ──────────────────────────────────────────────────────
|
|
section('1. Match detection');
|
|
{
|
|
const s = fromGrid(['rrr...']);
|
|
const g = findMatchGroups(s.board);
|
|
check('horizontal 3-run found', g.length === 1 && g[0].size === 3);
|
|
|
|
const s2 = fromGrid(['g.....', 'g.....', 'g.....']);
|
|
const g2 = findMatchGroups(s2.board);
|
|
check('vertical 3-run found', g2.length === 1 && g2[0].size === 3);
|
|
|
|
// L-shape merges into one group of 5
|
|
const s3 = fromGrid(['b.....', 'b.....', 'bbb...']);
|
|
const g3 = findMatchGroups(s3.board);
|
|
check('L-shape is one group of 5', g3.length === 1 && g3[0].size === 5, JSON.stringify(g3.map(x => x.size)));
|
|
|
|
const s4 = fromGrid(['ryg...']);
|
|
check('no false match', findMatchGroups(s4.board).length === 0);
|
|
}
|
|
|
|
// ── 2. Basic clear + scoring ────────────────────────────────────────────────
|
|
section('2. Clear & scoring');
|
|
{
|
|
const s = fromGrid(['rrr...']);
|
|
const before = s.score;
|
|
const rec = resolveRecord(s);
|
|
check('clear removes matched panels', s.board.every((row) => row.every((p) => p === null || p.color !== 'red')));
|
|
check('clear event fired', rec.types.has('clear'));
|
|
check('pop event fired', rec.types.has('pop'));
|
|
check('score increased by base*3', s.score - before === 3 * TUNING.BASE_PANEL, `got ${s.score - before}`);
|
|
check('comboBonus(3)=0, (4)=20', comboBonus(3) === 0 && comboBonus(4) === 20);
|
|
check('chainBonus(1)=0, (2)=50', chainBonus(1) === 0 && chainBonus(2) === 50);
|
|
}
|
|
|
|
// ── 3. Chain propagation ────────────────────────────────────────────────────
|
|
section('3. Chain propagation');
|
|
{
|
|
// Bottom RRR clears; the g above col2 falls and completes GGG (cols 2,3,4).
|
|
const s = fromGrid(['..g...', 'rrrgg.']);
|
|
const rec = resolveRecord(s);
|
|
check('chain reaches level 2', rec.peakChain >= 2, `peak ${rec.peakChain}`);
|
|
check('chainEnd fired', rec.types.has('chainEnd'));
|
|
check('board fully cleared by chain', s.board.every((row) => row.every((p) => p === null)));
|
|
}
|
|
|
|
// ── 4. Gravity: no floating panels ──────────────────────────────────────────
|
|
section('4. Gravity');
|
|
{
|
|
// A panel with empty space beneath must fall to the floor.
|
|
const s = fromGrid(['r.....', '......', '......']);
|
|
// r is at top of a 3-tall region; resolve (no match) → it should rest on floor
|
|
resolveRecord(s, () => 0.5, 200);
|
|
const bottomHasR = s.board[ROWS - 1][0]?.color === 'red';
|
|
check('lone panel falls to floor', bottomHasR, JSON.stringify(s.board.map(r => r[0]?.color ?? '.')));
|
|
// invariant: no idle panel has an empty cell directly beneath it
|
|
let floating = 0;
|
|
for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS - 1; r++) {
|
|
if (s.board[r][c] && !s.board[r + 1][c]) floating++;
|
|
}
|
|
check('no floating panels after settle', floating === 0, `${floating} floating`);
|
|
}
|
|
|
|
// ── 5. Cursor & swap ────────────────────────────────────────────────────────
|
|
section('5. Cursor & swap');
|
|
{
|
|
const s = fromGrid(['ryr...', 'yyr...', 'rryyy.'], 'endless');
|
|
// move cursor bounds
|
|
s.cursor = { row: 0, col: 0 };
|
|
moveCursor(s, 'left');
|
|
check('cursor col clamps at 0', s.cursor.col === 0);
|
|
for (let i = 0; i < 10; i++) moveCursor(s, 'right');
|
|
check('cursor col clamps at COLS-2', s.cursor.col === COLS - 2);
|
|
|
|
// a swap between two idle panels succeeds and exchanges them
|
|
const s2 = fromGrid(['rg....'], 'endless');
|
|
s2.cursor = { row: ROWS - 1, col: 0 };
|
|
const a = s2.board[ROWS - 1][0].id;
|
|
const ev = trySwap(s2);
|
|
check('swap returns event', ev && ev.type === 'swap');
|
|
check('swap exchanged cells', s2.board[ROWS - 1][1].id === a);
|
|
|
|
// cannot swap a clearing panel
|
|
const s3 = fromGrid(['rrr...'], 'endless');
|
|
step(s3, () => 0.5); // starts the clear
|
|
s3.cursor = { row: ROWS - 1, col: 0 };
|
|
check('cannot swap mid-clear', trySwap(s3) === null);
|
|
}
|
|
|
|
// ── 6. Rise & emerge ────────────────────────────────────────────────────────
|
|
section('6. Rise & row emergence');
|
|
{
|
|
const s = newGame({ mode: 'endless', rng: mulberry32(7) });
|
|
s.riseRate = 0.34; // fast for the test
|
|
const raised0 = s.rowsRaised;
|
|
const bottomBefore = s.incoming.slice();
|
|
let sawShift = false;
|
|
for (let i = 0; i < 20; i++) {
|
|
const ev = step(s, mulberry32(99 + i));
|
|
if (ev.some((e) => e.type === 'rowShift')) sawShift = true;
|
|
}
|
|
check('rows emerge as the stack rises', s.rowsRaised > raised0 && sawShift);
|
|
check('riseOffset stays in [0,1)', s.riseOffset >= 0 && s.riseOffset < 1, `${s.riseOffset}`);
|
|
}
|
|
|
|
// ── 7. Top-out / game over ──────────────────────────────────────────────────
|
|
section('7. Top-out');
|
|
{
|
|
const s = newGame({ mode: 'endless', rng: mulberry32(3) });
|
|
// fill the entire board so row 0 is occupied and nothing can clear
|
|
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
|
s.board[r][c] = { color: 'red', id: 10000 + r * COLS + c, state: 'idle', chain: false };
|
|
}
|
|
// give it distinct colors to avoid instant clears masking the top-out
|
|
const cols = ['red', 'yellow', 'green', 'cyan', 'purple', 'blue'];
|
|
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) s.board[r][c].color = cols[(r + c) % 6];
|
|
let over = false;
|
|
for (let i = 0; i < TUNING.PANIC_GRACE + 50 && !over; i++) {
|
|
const ev = step(s, mulberry32(1 + i));
|
|
if (ev.some((e) => e.type === 'gameOver')) over = true;
|
|
}
|
|
check('full board tops out', over && s.over);
|
|
}
|
|
|
|
// ── 8. Stage Clear win condition ────────────────────────────────────────────
|
|
section('8. Stage Clear');
|
|
{
|
|
// one target row of 3 that clears immediately → win
|
|
const s = newGame({ mode: 'stageclear', rng: mulberry32(5), stage: { speedLevel: 1, startRows: 0 } });
|
|
// hand-place a single clearing target group
|
|
for (let c = 0; c < 3; c++) s.board[ROWS - 1][c] = { color: 'green', id: c + 1, state: 'idle', chain: false, target: true };
|
|
s.targetsTotal = 3;
|
|
let won = false;
|
|
for (let i = 0; i < 200 && !won; i++) {
|
|
const ev = step(s, mulberry32(50 + i));
|
|
if (ev.some((e) => e.type === 'win')) won = true;
|
|
}
|
|
check('clearing all targets wins the stage', won && s.won);
|
|
check('no targets remain on win', remainingTargets(s) === 0);
|
|
}
|
|
|
|
// ── 9. Puzzle bank ──────────────────────────────────────────────────────────
|
|
section('9. Puzzle bank');
|
|
{
|
|
const bankPath = join(__dirname, '..', 'data', 'tetrisattack-puzzles.json');
|
|
const bank = JSON.parse(readFileSync(bankPath, 'utf8'));
|
|
check('bank has puzzles', bank.puzzles.length >= 12, `${bank.puzzles.length}`);
|
|
let solvable = 0;
|
|
let optimal = 0;
|
|
for (const p of bank.puzzles) {
|
|
const sol = solvePuzzle({ grid: p.grid, maxMoves: p.maxMoves }, () => 0.5);
|
|
if (sol) {
|
|
solvable++;
|
|
if (sol.length <= p.maxMoves) optimal++;
|
|
// apply the solution and confirm the board empties
|
|
const s = newGame({ mode: 'puzzle', puzzle: { grid: p.grid, maxMoves: p.maxMoves } });
|
|
for (const mv of sol) { s.cursor = { row: mv.row, col: mv.col }; trySwap(s); resolveFully(s, () => 0.5); }
|
|
const empty = s.board.every((row) => row.every((c) => c === null));
|
|
if (!empty) { failures++; checks++; console.log(` ✗ puzzle ${p.id} solution did not empty board`); }
|
|
else checks++;
|
|
}
|
|
}
|
|
check('every puzzle is solvable', solvable === bank.puzzles.length, `${solvable}/${bank.puzzles.length}`);
|
|
check('every puzzle solvable within maxMoves', optimal === bank.puzzles.length, `${optimal}/${bank.puzzles.length}`);
|
|
}
|
|
|
|
// ── 10. Determinism ─────────────────────────────────────────────────────────
|
|
section('10. Determinism');
|
|
{
|
|
const boardHash = (s) => s.board.map((row) => row.map((p) => (p ? p.color[0] : '.')).join('')).join('|');
|
|
const run = (seed) => {
|
|
const rng = mulberry32(seed);
|
|
const s = newGame({ mode: 'endless', rng });
|
|
const trace = [];
|
|
for (let i = 0; i < 400; i++) {
|
|
if (i % 5 === 0) { const mv = findClearingSwap(s); if (mv) { s.cursor = { row: mv.row, col: mv.col }; trySwap(s); } }
|
|
step(s, rng);
|
|
trace.push(`${s.score}:${boardHash(s)}`);
|
|
}
|
|
return trace.join(',');
|
|
};
|
|
check('seeded runs are identical', run(12345) === run(12345));
|
|
check('different seeds differ', run(12345) !== run(999));
|
|
}
|
|
|
|
// ── 11. Monte-carlo self-play (invariants) ──────────────────────────────────
|
|
section('11. Self-play invariants');
|
|
{
|
|
let games = 0, totalTicks = 0, gameOvers = 0, maxScore = 0, invariantBreaks = 0;
|
|
for (let g = 0; g < 40; g++) {
|
|
const seed = 1000 + g;
|
|
const rng = mulberry32(seed);
|
|
const s = newGame({ mode: 'endless', rng });
|
|
let lastScore = 0;
|
|
for (let t = 0; t < 1500 && !s.over; t++) {
|
|
// occasionally act: make a clearing swap when one exists
|
|
if (t % 5 === 0) {
|
|
const mv = findClearingSwap(s);
|
|
if (mv) { s.cursor = { row: mv.row, col: mv.col }; trySwap(s); }
|
|
}
|
|
step(s, rng);
|
|
totalTicks++;
|
|
// invariant: score never decreases
|
|
if (s.score < lastScore) invariantBreaks++;
|
|
lastScore = s.score;
|
|
// invariant: cursor in bounds
|
|
if (s.cursor.row < 0 || s.cursor.row >= ROWS || s.cursor.col < 0 || s.cursor.col > COLS - 2) invariantBreaks++;
|
|
// invariant: when fully settled, no floating idle panels
|
|
if (isSettled(s)) {
|
|
for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS - 1; r++) {
|
|
if (s.board[r][c] && s.board[r][c].state === 'idle' && !s.board[r + 1][c]) invariantBreaks++;
|
|
}
|
|
}
|
|
}
|
|
games++;
|
|
if (s.over) gameOvers++;
|
|
maxScore = Math.max(maxScore, s.score);
|
|
}
|
|
check('self-play produced no invariant breaks', invariantBreaks === 0, `${invariantBreaks} breaks`);
|
|
check('self-play scored points', maxScore > 0, `max ${maxScore}`);
|
|
console.log(` (ran ${games} games, ${totalTicks} ticks, ${gameOvers} top-outs, peak score ${maxScore})`);
|
|
}
|
|
|
|
// ── Summary ─────────────────────────────────────────────────────────────────
|
|
console.log(`\n${failures === 0 ? '✓ ALL PASSED' : '✗ FAILURES'} — ${checks - failures}/${checks} checks passed`);
|
|
process.exit(failures ? 1 : 0);
|