// Headless verification for Block Fighter. // node server/scripts/verifyBlockFighter.js [--quick] // Exits non-zero on any failure. // // 1. Fixture tests: exact engine behavior on hand-built boards. // 2. AI-vs-AI self-play with invariant checks after every lock. // 3. Skill differentiation matrix (higher skill should win more). // 4. Level bank lint (public/data/blockfighter.json vs opponents.json). import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { WIDTH, HEIGHT, SPAWN_COL, KIND, COUNTER_START, SPEED_GRAVITY_MS, createMatch, spawnPiece, stepDown, hardDrop, moveLeft, moveRight, rotateCW, rotateCCW, } from '../../public/src/games/blockfighter/BlockFighterLogic.js'; import { createAI, planPlacement, nextAction } from '../../public/src/games/blockfighter/BlockFighterAI.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const QUICK = process.argv.includes('--quick'); let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); } else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } } // ── Fixture helpers ────────────────────────────────────────────────────────── const gem = (color) => ({ color, kind: KIND.GEM }); const crash = (color) => ({ color, kind: KIND.CRASH }); const counter = (color, count = COUNTER_START) => ({ color, kind: KIND.COUNTER, count }); function freshMatch() { return createMatch({ seed: 42 }); } function put(player, r, c, cell) { player.board[r][c] = cell; } // Give the player a specific piece and hard-drop it. function dropPiece(match, pIdx, a, b, col, orient = 0) { const player = match.players[pIdx]; player.piece = { a, b, row: 1, col, orient }; return hardDrop(match, pIdx); } function cellAt(player, r, c) { return player.board[r][c]; } function countCells(player, pred) { let n = 0; for (let r = 0; r < HEIGHT; r++) for (let c = 0; c < WIDTH; c++) { if (player.board[r][c] && pred(player.board[r][c])) n++; } return n; } const BOTTOM = HEIGHT - 1; // ── 1. Fixtures ────────────────────────────────────────────────────────────── console.log('Fixtures:'); { // Crash gem clears its connected same-color group. const m = freshMatch(); const p = m.players[0]; put(p, BOTTOM, 0, gem(0)); put(p, BOTTOM - 1, 0, gem(0)); dropPiece(m, 0, crash(0), gem(1), 0); check('crash clears connected group', countCells(p, (c) => c.color === 0) === 0); check('non-matching half survives', countCells(p, (c) => c.color === 1) === 1); check('cleared cells settle', cellAt(p, BOTTOM, 0)?.color === 1); } { // Lone crash gem stays put. const m = freshMatch(); const p = m.players[0]; dropPiece(m, 0, crash(0), gem(1), 0); check('lone crash gem stays', countCells(p, (c) => c.kind === KIND.CRASH) === 1); } { // 2x2 fusion into a power gem; then column extension to 2x3. const m = freshMatch(); const p = m.players[0]; dropPiece(m, 0, gem(2), gem(2), 0); dropPiece(m, 0, gem(2), gem(2), 1); check('2x2 fuses into power gem', p.powerGems.size === 1); const g0 = [...p.powerGems.values()][0]; check('power gem is 2x2', g0 && g0.w === 2 && g0.h === 2); dropPiece(m, 0, gem(2), gem(2), 2); const g1 = [...p.powerGems.values()][0]; check('power gem extends to 3 wide', p.powerGems.size === 1 && g1.w === 3 && g1.h === 2, JSON.stringify([...p.powerGems.values()])); } { // Rigid power-gem gravity: gem bridges a hole and falls as a unit. const m = freshMatch(); const p = m.players[0]; // pillar in col 0 only; power gem sits on rows 10-11 across cols 0-1, hole below col 1 put(p, BOTTOM, 0, gem(3)); put(p, BOTTOM - 1, 0, gem(3)); const pg = { id: 99, color: 1, x: 0, y: BOTTOM - 3, w: 2, h: 2 }; p.powerGems.set(pg.id, pg); for (let r = pg.y; r < pg.y + pg.h; r++) for (let c = pg.x; c < pg.x + pg.w; c++) { put(p, r, c, { color: 1, kind: KIND.GEM, powerId: 99 }); } // drop something far away to trigger a resolution pass dropPiece(m, 0, gem(0), gem(2), 5); const g = p.powerGems.get(99); check('power gem rests on support, bridging the hole', g && g.y === BOTTOM - 3 && !cellAt(p, BOTTOM, 1), JSON.stringify(g)); // remove the pillar support and resolve again: gem should drop as a unit put(p, BOTTOM, 0, null); put(p, BOTTOM - 1, 0, null); dropPiece(m, 0, gem(0), gem(2), 5); const g2 = p.powerGems.get(99); check('power gem falls rigidly when support clears', g2 && g2.y === BOTTOM - 1, JSON.stringify(g2)); } { // Counter gems: tick per lock, mature into normal gems at 0. const m = freshMatch(); const p = m.players[0]; put(p, BOTTOM, 0, counter(2, 2)); dropPiece(m, 0, gem(0), gem(1), 5); check('counter ticks down on lock', cellAt(p, BOTTOM, 0)?.count === 1); dropPiece(m, 0, gem(0), gem(1), 4); check('counter matures into gem', cellAt(p, BOTTOM, 0)?.kind === KIND.GEM); } { // Counter destroyed when an adjacent same-color group clears. const m = freshMatch(); const p = m.players[0]; put(p, BOTTOM, 0, gem(0)); put(p, BOTTOM, 1, counter(0)); dropPiece(m, 0, crash(0), gem(1), 0); check('adjacent same-color counter cleared', countCells(p, (c) => c.kind === KIND.COUNTER) === 0); } { // Diamond wipes the color beneath it; vanishes on bare floor. const m = freshMatch(); const p = m.players[0]; put(p, BOTTOM, 0, gem(3)); put(p, BOTTOM, 3, gem(3)); put(p, BOTTOM, 5, counter(3)); put(p, BOTTOM, 1, gem(2)); dropPiece(m, 0, { color: null, kind: KIND.DIAMOND }, gem(2), 0, 0); // diamond (bottom half) lands on yellow check('diamond wipes all of that color (incl. counters)', countCells(p, (c) => c.color === 3) === 0); check('other colors survive diamond', countCells(p, (c) => c.color === 2) === 2); check('diamond itself is gone', countCells(p, (c) => c.kind === KIND.DIAMOND) === 0); const m2 = freshMatch(); dropPiece(m2, 0, { color: null, kind: KIND.DIAMOND }, gem(2), 0, 2); check('diamond on bare floor vanishes', countCells(m2.players[0], (c) => c.kind === KIND.DIAMOND) === 0); } { // Attack is offset by own pending garbage before reaching the opponent. const m = freshMatch(); const p = m.players[0]; p.pendingGarbage = 100; put(p, BOTTOM, 0, gem(0)); put(p, BOTTOM - 1, 0, gem(0)); put(p, BOTTOM, 1, gem(0)); put(p, BOTTOM - 1, 1, gem(0)); dropPiece(m, 0, crash(0), gem(1), 2, 3); // crash next to the 2x2... orient 3: b left check('clear happened for offset test', p.lastResolve.cleared >= 5, `cleared=${p.lastResolve.cleared}`); check('attack offsets own pending garbage', p.pendingGarbage < 100 && m.players[1].pendingGarbage === 0, `pending=${p.pendingGarbage}, opp=${m.players[1].pendingGarbage}`); } { // Unoffset attack lands on the opponent; garbage drops as counters. const m = freshMatch(); const p = m.players[0]; for (let r = 0; r < 4; r++) for (let c = 0; c < 2; c++) put(p, BOTTOM - r, c, gem(1)); dropPiece(m, 0, crash(1), gem(0), 2, 3); const sent = m.players[1].pendingGarbage; check('attack reaches opponent', sent > 0, `sent=${sent}`); spawnPiece(m, 1); const counters = countCells(m.players[1], (c) => c.kind === KIND.COUNTER); check('garbage drops as counter gems on spawn', counters === Math.min(sent, 24), `counters=${counters} sent=${sent}`); check('counter colors follow the drop pattern', m.players[1].board.flat().filter(Boolean).every((c) => c.kind !== KIND.COUNTER || c.color != null)); } { // Chain: red crash dropped in col 1 clears the reds; the green crash above // them falls beside the green gem and triggers a second clear. const m = freshMatch(); const p = m.players[0]; put(p, BOTTOM, 0, gem(0)); put(p, BOTTOM - 1, 0, gem(0)); put(p, BOTTOM - 2, 0, crash(1)); put(p, BOTTOM, 1, gem(1)); dropPiece(m, 0, crash(0), gem(2), 1); // lands beside the red pair check('chain of 2 detected', p.lastResolve.chain === 2, `chain=${p.lastResolve.chain}`); check('chain cleared everything green', countCells(p, (c) => c.color === 1) === 0); } { // Spawn-blocked loss in the spawn column. const m = freshMatch(); const p = m.players[0]; for (let r = 0; r < HEIGHT; r++) put(p, r, SPAWN_COL, gem(r % 4)); spawnPiece(m, 0); check('blocked spawn loses the match', p.lost && m.over && m.winner === 1); } // ── 2 & 3. Self-play with invariants + skill matrix ───────────────────────── function checkInvariants(match, tag) { for (const p of match.players) { for (let r = 0; r < HEIGHT - 1; r++) { for (let c = 0; c < WIDTH; c++) { const cell = p.board[r][c]; if (cell && !p.board[r + 1][c] && cell.powerId == null) { throw new Error(`${tag}: floating cell at ${r},${c}`); } } } for (const g of p.powerGems.values()) { if (g.w < 2 || g.h < 2) throw new Error(`${tag}: degenerate power gem ${JSON.stringify(g)}`); for (let r = g.y; r < g.y + g.h; r++) for (let c = g.x; c < g.x + g.w; c++) { const cell = p.board[r]?.[c]; if (!cell || cell.powerId !== g.id || cell.color !== g.color) { throw new Error(`${tag}: power gem cell mismatch at ${r},${c}: ${JSON.stringify(g)}`); } } } for (let r = 0; r < HEIGHT; r++) for (let c = 0; c < WIDTH; c++) { const cell = p.board[r][c]; if (cell?.powerId != null && !p.powerGems.has(cell.powerId)) { throw new Error(`${tag}: orphaned powerId at ${r},${c}`); } } if (p.pendingGarbage < 0) throw new Error(`${tag}: negative pendingGarbage`); } } const TICK_MS = 25; function playMatch(skillA, skillB, seed, speed = 3, pieceCap = 1200) { const match = createMatch({ seed }); const ais = [ createAI({ skill: skillA, speed, seed: seed * 2 + 1 }), createAI({ skill: skillB, speed, seed: seed * 3 + 7 }), ]; const gravityMs = SPEED_GRAVITY_MS[speed]; const gravTimer = [0, 0]; let now = 0; let pieces = 0; for (const i of [0, 1]) { spawnPiece(match, i); if (match.players[i].piece) planPlacement(ais[i], match, i); } while (!match.over && pieces < pieceCap) { now += TICK_MS; for (const i of [0, 1]) { if (match.over) break; const p = match.players[i]; if (!p.piece) continue; let locked = false; const act = nextAction(ais[i], match, i, now); if (act === 'left') moveLeft(match, i); else if (act === 'right') moveRight(match, i); else if (act === 'rotateCW') rotateCW(match, i); else if (act === 'rotateCCW') rotateCCW(match, i); else if (act === 'softDrop') locked = stepDown(match, i).locked; else if (act === 'hardDrop') { hardDrop(match, i); locked = true; } gravTimer[i] += TICK_MS; if (!locked && p.piece && gravTimer[i] >= gravityMs) { gravTimer[i] = 0; locked = stepDown(match, i).locked; } if (locked) { pieces += 1; checkInvariants(match, `match(seed=${seed},${skillA}v${skillB})`); if (!match.over) { spawnPiece(match, i); if (match.players[i].piece) planPlacement(ais[i], match, i); } } } } return { winner: match.over ? match.winner : null, pieces }; } console.log('\nSelf-play invariants:'); { const games = QUICK ? 10 : 40; let decided = 0, totalPieces = 0; for (let s = 1; s <= games; s++) { const { winner, pieces } = playMatch(5, 5, s * 101); if (winner !== null) decided += 1; totalPieces += pieces; } check(`self-play runs clean (${games} games)`, true); check('most games reach a decision', decided >= games * 0.8, `${decided}/${games}`); console.log(` avg pieces/game: ${(totalPieces / games).toFixed(1)}`); } console.log('\nSkill differentiation:'); { const games = QUICK ? 8 : 24; const pairs = [[2, 8], [3, 6], [5, 6], [1, 10]]; for (const [lo, hi] of pairs) { let hiWins = 0, decided = 0; for (let s = 1; s <= games; s++) { // alternate sides to cancel any side bias const flip = s % 2 === 1; const { winner } = playMatch(flip ? lo : hi, flip ? hi : lo, s * 977 + lo * 13 + hi); if (winner === null) continue; decided += 1; if ((flip && winner === 1) || (!flip && winner === 0)) hiWins += 1; } const rate = decided ? hiWins / decided : 0; const need = hi - lo >= 5 ? 0.7 : 0.5; check(`skill ${hi} beats ${lo} (${(rate * 100).toFixed(0)}% of ${decided})`, rate >= need); } } // ── 4. Level bank lint ─────────────────────────────────────────────────────── console.log('\nLevel bank:'); { const bank = JSON.parse(readFileSync(join(__dirname, '../../public/data/blockfighter.json'), 'utf8')); const roster = JSON.parse(readFileSync(join(__dirname, '../../public/data/opponents.json'), 'utf8')); const ids = new Set((roster.opponents ?? roster).map((o) => o.id)); const levels = bank.levels ?? []; check('bank has levels', levels.length > 0); let ok = true; levels.forEach((lv, i) => { if (lv.level !== i + 1) ok = false; if (!(lv.skill >= 1 && lv.skill <= 10)) ok = false; if (!(lv.speed >= 1 && lv.speed <= 5)) ok = false; if (lv.dropPattern && !(Array.isArray(lv.dropPattern) && lv.dropPattern.length === 4 && lv.dropPattern.every((row) => /^[RGBY]{6}$/.test(row)))) ok = false; if (!ids.has(lv.opponentId)) console.warn(` warn: level ${lv.level} opponent '${lv.opponentId}' not in roster`); }); check('levels contiguous with valid skill/speed/dropPattern', ok); } console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.'); process.exit(failures ? 1 : 0);