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