// 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, setRaise, advanceStage, findMatchGroups, findClearingSwap, isSettled, panelsAboveClearLine, 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 boundary line ──────────────────────────────────────────── section('8. Stage Clear'); { // The line appears once `clearLineRows` rows have risen, lands between the // row that just emerged and the stack above it, then rides that boundary up. const s = newGame({ mode: 'stageclear', rng: mulberry32(5), stage: { speedLevel: 1, startRows: 3, clearLineRows: 2 } }); setRaise(s, true); // rush the rise so the line arrives within the tick budget check('no line on a fresh stage', s.clearLine === null); let spawns = 0; let lineAtSpawn = null; let rowsAtSpawn = null; let shiftsAfterSpawn = 0; let glued = true; let wonBeforeLine = false; for (let i = 0; i < 4000; i++) { const before = s.clearLine; const events = step(s, mulberry32(100 + i)); for (const e of events) { if (e.type === 'clearLineAppear') { spawns++; lineAtSpawn = s.clearLine; rowsAtSpawn = s.rowsRaised; } if (e.type === 'win' && spawns === 0) wonBeforeLine = true; if (e.type === 'rowShift' && before !== null) { shiftsAfterSpawn++; if (s.clearLine !== before - 1) glued = false; } } if (s.over || s.won) break; } check('the line appears exactly once', spawns === 1, `${spawns}`); check('the line appears after clearLineRows rows', rowsAtSpawn === 2, `${rowsAtSpawn}`); check('the line lands under the whole stack', lineAtSpawn === ROWS - 1, `${lineAtSpawn}`); check('no stage win before the line exists', !wonBeforeLine); check('the line rises with the stack', glued && shiftsAfterSpawn > 0, `${shiftsAfterSpawn} shifts`); } { // Clearing everything above the line wins — panels below it are irrelevant. const s = fromGrid(['ggg...', 'rybrgb', 'brygbr', 'ybgrby'], 'stageclear'); s.clearLine = ROWS - 3; check('objective counts only panels above the line', panelsAboveClearLine(s) === 3, `${panelsAboveClearLine(s)}`); let won = false; for (let i = 0; i < 200 && !won; i++) { if (step(s, mulberry32(50 + i)).some((e) => e.type === 'win')) won = true; } check('clearing above the line wins the stage', won && s.won); check('nothing remains above the line on win', panelsAboveClearLine(s) === 0); let below = 0; for (let r = s.clearLine; r < ROWS; r++) for (const p of s.board[r]) if (p) below++; check('panels below the line survive the win', below === 18, `${below}`); } { // Same board with no line yet: the stage can never be won. const s = fromGrid(['ggg...', 'rybrgb'], 'stageclear'); let won = false; for (let i = 0; i < 300 && !won; i++) { if (step(s, mulberry32(9 + i)).some((e) => e.type === 'win')) won = true; } check('an emptied board without a line does not win', !won && !s.won); } // ── 8b. Stage rounds (5 stages per character, same board) ─────────────────── section('8b. Stage rounds'); { const stageOpts = { speedLevel: 2, startRows: 4, clearLineRows: 2, stageNumber: 1, speedStep: 0.2 }; const s = newGame({ mode: 'stageclear', rng: mulberry32(11), stage: stageOpts }); check('a round opens on stage 1', s.stage === 1 && s.speed === 2); // stage 4 of the same round starts faster than stage 1 but keeps the round level const later = newGame({ mode: 'stageclear', rng: mulberry32(11), stage: { ...stageOpts, stageNumber: 4 } }); check('a later stage starts faster', later.riseRate > s.riseRate && later.stage === 4); check('the round level is unchanged by the stage', later.level === s.level, `${later.level}`); // run until the line appears, then hand it a cleared objective setRaise(s, true); for (let i = 0; i < 4000 && s.clearLine === null; i++) step(s, mulberry32(200 + i)); check('stage 1 got its line', s.clearLine !== null); for (let r = 0; r < s.clearLine; r++) for (let c = 0; c < COLS; c++) s.board[r][c] = null; let won = false; for (let i = 0; i < 60 && !won; i++) { if (step(s, mulberry32(300 + i)).some((e) => e.type === 'win')) won = true; } check('emptying above the line ends stage 1', won && s.won); const idsBefore = s.board.map((row) => row.map((p) => p?.id ?? 0).join(',')).join('|'); const rateBefore = s.riseRate; const scoreBefore = s.score; const rowsAtAdvance = s.rowsRaised; advanceStage(s, { speedStep: 0.2 }); check('advancing resumes play on stage 2', !s.won && s.stage === 2); check('advancing keeps the whole stack', s.board.map((row) => row.map((p) => p?.id ?? 0).join(',')).join('|') === idsBefore); check('advancing keeps the score', s.score === scoreBefore); check('advancing clears the old line', s.clearLine === null); check('advancing speeds up the rise', s.riseRate > rateBefore && Math.abs(s.speed - 2.2) < 1e-9, `speed ${s.speed}`); check('advancing re-arms the line', s.clearLineAt === rowsAtAdvance + 2, `${s.clearLineAt}`); // and the next line really does show up, clearLineRows further on let respawned = false; let rowsAtRespawn = null; for (let i = 0; i < 4000 && !respawned && !s.over; i++) { if (step(s, mulberry32(500 + i)).some((e) => e.type === 'clearLineAppear')) { respawned = true; rowsAtRespawn = s.rowsRaised; } } check('stage 2 gets a fresh line', respawned && s.clearLine === ROWS - 1); check('the fresh line waits clearLineRows rows', rowsAtRespawn === rowsAtAdvance + 2, `${rowsAtRespawn} vs ${rowsAtAdvance}`); } // ── 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})`); } // ── 12. Stage Clear soak (every shipped round must be beatable) ───────────── section('12. Stage Clear soak'); { const cfg = JSON.parse(readFileSync(join(__dirname, '..', 'data', 'tetrisattack.json'), 'utf8')); const rounds = cfg.stageClear?.rounds ?? []; const perRound = cfg.stageClear?.stagesPerRound; const speedStep = cfg.stageClear?.stageSpeedStep; // dialog is authored as arrays so each friend can say something different const linesOk = (v) => Array.isArray(v) && v.length > 0 && v.every((l) => typeof l === 'string' && l.trim()); check('every round is fully configured', rounds.every((r) => ( Number.isInteger(r.clearLineRows) && Number.isInteger(r.startRows) && Number.isInteger(r.speedLevel) && r.characterId && linesOk(r.introLines) && linesOk(r.winLines) && linesOk(r.loseLines)))); check('the ladder is 6 rounds × 5 stages', rounds.length === 6 && perRound === 5, `${rounds.length}×${perRound}`); // the per-stage ramp must not overtake the next character's opening stage const lastStageSpeed = (r) => r.speedLevel + (perRound - 1) * speedStep; check('the difficulty ramp stays monotonic', rounds.every((r, i) => i === rounds.length - 1 || lastStageSpeed(r) <= rounds[i + 1].speedLevel), `step ${speedStep}`); let lineless = 0; const won = []; for (const round of rounds) { let wins = 0; for (let g = 0; g < 3; g++) { const rng = mulberry32(7000 + g); const s = newGame({ mode: 'stageclear', stage: { speedLevel: round.speedLevel, startRows: round.startRows, clearLineRows: round.clearLineRows }, rng, }); for (let t = 0; t < 20000 && !s.over && !s.won; t++) { if (t % 5 === 0) { const mv = findClearingSwap(s); if (mv) { s.cursor = { row: mv.row, col: mv.col }; trySwap(s); } } step(s, rng); } if (s.won) wins++; if (s.clearLine === null) lineless++; } won.push(`${round.name} ${wins}/3`); } // The line must always show up before the stack tops out — otherwise the // round has no reachable objective at all. (Winning is a different bar: the // greedy 1-ply player only beats the first two rounds, which was equally true // of the old clear-the-whole-starting-stack rule.) check('every soak game saw its CLEAR line appear', lineless === 0, `${lineless} without a line`); check('the opening rounds are beatable by a greedy player', won.slice(0, 2).every((w) => !w.endsWith('0/3')), won.slice(0, 2).join(', ')); console.log(` (greedy wins — ${won.join(', ')})`); } // ── Summary ───────────────────────────────────────────────────────────────── console.log(`\n${failures === 0 ? '✓ ALL PASSED' : '✗ FAILURES'} — ${checks - failures}/${checks} checks passed`); process.exit(failures ? 1 : 0);