fertig-classic-games/tools/verifyTetrisAttack.js

412 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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