fertig-classic-games/tools/verifyTetrisAttack.js

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