410 lines
20 KiB
JavaScript
410 lines
20 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, 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;
|
||
check('every round is fully configured', rounds.every((r) => (
|
||
Number.isInteger(r.clearLineRows) && Number.isInteger(r.startRows) && Number.isInteger(r.speedLevel)
|
||
&& r.characterId && r.intro && r.winLine && r.loseLine)));
|
||
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);
|