// Headless verification for Bejeweled Blitz. // node server/scripts/verifyBejeweled.js // Exits non-zero on any failure. // // 1. Fixture tests: special-gem creation and detonation on hand-built boards. // 2. Monte-carlo self-play: thousands of random moves with invariants checked // after every resolution (board full, no resting matches, phases coherent). // 3. Last Hurrah and shuffle sanity. import { COLS, ROWS, SPECIAL, GEM_COLORS, newGame, applyMove, lastHurrah, findMove, findRuns, shuffleBoard, randomBoard, } from '../../public/src/games/bejeweled/BejeweledLogic.js'; let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); return; } failures++; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); } function mulberry32(seed) { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // Build a board from 8 strings of 8 colour initials (r/o/y/g/b/p/w). // Uppercase suffix markers are handled by the caller via overrides. const INITIAL = { r: 'red', o: 'orange', y: 'yellow', g: 'green', b: 'blue', p: 'purple', w: 'white' }; function boardFromStrings(rows, overrides = {}) { const board = []; for (let r = 0; r < ROWS; r++) { board[r] = []; for (let c = 0; c < COLS; c++) { const color = INITIAL[rows[r][c]]; board[r][c] = { color, special: SPECIAL.NONE }; } } for (const [k, v] of Object.entries(overrides)) { const [c, r] = k.split(',').map(Number); board[r][c] = { ...board[r][c], ...v }; } return board; } function boardFull(board) { for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { const cell = board[r][c]; if (!cell) return false; if (cell.special !== SPECIAL.HYPER && !GEM_COLORS.includes(cell.color)) return false; } return true; } console.log('Fixture: 3-match clears, board refills'); { // Swapping (0,1)↔(0,0)? Build a guaranteed vertical 3-match: column 0 has // red at rows 1,2 and a red arrives at row 0 via swap from (1,0). const rows = [ 'rgybgypg', 'grybyopw', 'gboprwyb', 'ywgwobry', 'obrygwpo', 'wpogrbwy', 'rygbpoyr', 'bowyrgbw', ]; const board = boardFromStrings(rows); const state = { board, multiplier: 1, noMoves: false }; // (1,0) is 'g'; swap with (0,0)='r'? col0 rows1,2 are g,g → moving g to (0,0) makes col0 g,g,g. const phases = applyMove(state, { c: 1, r: 0 }, { c: 0, r: 0 }, mulberry32(7)); check('legal swap returns phases', Array.isArray(phases) && phases.length >= 1); check('phase 1 cleared 3+ gems', phases && phases[0].cleared.length >= 3); check('phase points positive', phases && phases[0].points > 0); check('board still full after resolution', boardFull(state.board)); check('no resting matches', findRuns(state.board).length === 0); } console.log('Fixture: illegal swap rejected, board untouched'); { const state = newGame(mulberry32(3)); const snapshot = JSON.stringify(state.board); // Find a swap that yields no match. let rejected = false; outer: for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS - 1; c++) { const res = applyMove(state, { c, r }, { c: c + 1, r }, mulberry32(4)); if (res === null) { rejected = true; break outer; } // applyMove mutated the board — restore for the next probe. state.board = JSON.parse(snapshot); } check('some non-matching swap was rejected', rejected); check('rejected swap left board unchanged', JSON.stringify(state.board) === snapshot); check('non-adjacent swap rejected', applyMove(state, { c: 0, r: 0 }, { c: 2, r: 0 }) === null); } console.log('Fixture: match 4 spawns a Flame gem'); { const rows = [ 'gybgypgo', 'rrwryopw', 'gboprwyb', 'ywgwobry', 'obrygwpo', 'wpogrbwy', 'rygbpoyr', 'bowyrgbw', ]; // Row 1: r r w r — swapping (2,1)'w' with (2,0)'b'? need the 'w' replaced by r. // Instead swap (2,1)↔(2,2): (2,2)='o'… simpler: put r at (2,0) and swap down. const board = boardFromStrings(rows, { '2,0': { color: 'red' } }); const state = { board, multiplier: 1, noMoves: false }; const phases = applyMove(state, { c: 2, r: 0 }, { c: 2, r: 1 }, mulberry32(9)); check('4-match resolved', phases !== null); const spawns = phases ? phases.flatMap((p) => p.spawns) : []; check('flame gem spawned', spawns.some((s) => s.special === SPECIAL.FLAME && s.color === 'red'), JSON.stringify(spawns)); } console.log('Fixture: match 5 spawns a Hypercube; hyper swap zaps a colour'); { const rows = [ 'gybgypgo', 'rrwrropw', 'gboprwyb', 'ywgwobry', 'obrygwpo', 'wpogrbwy', 'rygbpoyr', 'bowyrgbw', ]; // Row 1 becomes r r r r r after dropping a red into (2,1) from (2,0). const board = boardFromStrings(rows, { '2,0': { color: 'red' } }); const state = { board, multiplier: 1, noMoves: false }; const phases = applyMove(state, { c: 2, r: 0 }, { c: 2, r: 1 }, mulberry32(11)); const spawns = phases ? phases.flatMap((p) => p.spawns) : []; const hyperSpawn = spawns.find((s) => s.special === SPECIAL.HYPER); check('hypercube spawned from 5-match', !!hyperSpawn, JSON.stringify(spawns)); // Now swap the hypercube with a neighbour and confirm a colour sweep. if (hyperSpawn) { // The hyper may have fallen; find it. let pos = null; for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { if (state.board[r][c]?.special === SPECIAL.HYPER) pos = { c, r }; } check('hypercube present on board', !!pos); if (pos) { const nb = pos.c > 0 ? { c: pos.c - 1, r: pos.r } : { c: pos.c + 1, r: pos.r }; const target = state.board[nb.r][nb.c].color; const before = JSON.stringify(state.board); const hp = applyMove(state, pos, nb, mulberry32(13)); check('hyper swap always legal', hp !== null); if (hp) { const ev = hp.flatMap((p) => p.events).find((e) => e.type === 'hyper'); check('hyper event fired with swapped colour', !!ev && ev.color === target, `event=${JSON.stringify(ev)} target=${target}`); check('board full after hyper sweep', boardFull(state.board)); } else { state.board = JSON.parse(before); } } } } console.log('Fixture: L-match spawns a Star; star detonation clears row+col'); { const rows = [ 'rwbgypgo', 'rgwbropw', 'gboprwyb', 'ywgwobry', 'obrygwpo', 'wpogrbwy', 'rygbpoyr', 'bowyrgbw', ]; // Column 0: r r g … and row 0: r w b — swap (1,0)'g' row0? Build L: // put red at (1,1) wait — simpler: col0 rows0,1 red; row2 'g b o' — make // row 2 start r ? Use overrides: row2 col1,col2 red → swapping (0,2)'g' // with (1,2)? Instead: cells (0,0),(0,1) red vertical; (1,2),(2,2) red // horizontal… Build explicitly: const board = boardFromStrings(rows, { '0,0': { color: 'red' }, '0,1': { color: 'red' }, // col 0, rows 0,1 '1,2': { color: 'red' }, '2,2': { color: 'red' }, // row 2, cols 1,2 '0,3': { color: 'green' }, // below the L corner '0,2': { color: 'blue' }, '1,3': { color: 'yellow' }, }); // Swap (1,3)? The corner (0,2) needs red: swap (0,2)'blue' with… place red at (1,2)? taken. // Give (0,3) red and swap it up into (0,2): col0 r,r,[r] + row2 [r],r,r → L of 5. board[3][0] = { color: 'red', special: SPECIAL.NONE }; board[2][0] = { color: 'blue', special: SPECIAL.NONE }; const state = { board, multiplier: 1, noMoves: false }; const phases = applyMove(state, { c: 0, r: 3 }, { c: 0, r: 2 }, mulberry32(17)); check('L-swap resolved', phases !== null); const spawns = phases ? phases.flatMap((p) => p.spawns) : []; check('star gem spawned from L', spawns.some((s) => s.special === SPECIAL.STAR), JSON.stringify(spawns)); } console.log('Monte-carlo self-play'); { const rng = mulberry32(42); let totalMoves = 0; let totalPhases = 0; let specialsSeen = 0; let multsSeen = 0; let maxCascade = 0; let invariantsOk = true; let pointsOk = true; for (let game = 0; game < 60; game++) { const state = newGame(rng); for (let move = 0; move < 80; move++) { const mv = findMove(state.board); if (!mv) { shuffleBoard(state, rng); continue; } const phases = applyMove(state, mv.a, mv.b, rng); if (!phases) { invariantsOk = false; console.error(' findMove suggested an illegal move', mv); break; } totalMoves++; totalPhases += phases.length; for (const p of phases) { maxCascade = Math.max(maxCascade, p.cascade); if (p.points <= 0) pointsOk = false; specialsSeen += p.spawns.length; multsSeen += p.events.filter((e) => e.type === 'mult').length; // falls/refills coherence: every refill lands on a distinct cell. const seen = new Set(); for (const f of p.refills) { const k = `${f.c},${f.r}`; if (seen.has(k)) { invariantsOk = false; console.error(' duplicate refill cell', k); } seen.add(k); } } if (!boardFull(state.board)) { invariantsOk = false; console.error(' board has holes after move'); break; } if (findRuns(state.board).length) { invariantsOk = false; console.error(' resting matches after move'); break; } if (state.multiplier < 1 || state.multiplier > 8) { invariantsOk = false; console.error(' multiplier out of range'); break; } } if (!invariantsOk) break; } check('played 4000+ moves', totalMoves >= 4000, `moves=${totalMoves}`); check('all invariants held', invariantsOk); check('all phases scored points', pointsOk); check('cascades occurred', totalPhases > totalMoves, `phases=${totalPhases}`); check('special gems spawned', specialsSeen > 0, `specials=${specialsSeen}`); check('multiplier gems appeared', multsSeen > 0, `mults=${multsSeen}`); console.log(` info moves=${totalMoves} phases=${totalPhases} specials=${specialsSeen} mults=${multsSeen} maxCascade=${maxCascade}`); } console.log('Last Hurrah & shuffle'); { const rng = mulberry32(99); const state = newGame(rng); // Seed some specials by hand. state.board[7][0].special = SPECIAL.FLAME; state.board[7][3].special = SPECIAL.STAR; state.board[7][6] = { color: null, special: SPECIAL.HYPER }; state.board[6][2].special = SPECIAL.MULT; const phases = lastHurrah(state, rng); check('last hurrah produced phases', phases.length >= 1); check('last hurrah detonated events', phases.flatMap((p) => p.events).length >= 3); let specialsLeft = 0; for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { if (state.board[r][c].special !== SPECIAL.NONE) specialsLeft++; } check('no specials remain after last hurrah', specialsLeft === 0, `left=${specialsLeft}`); check('board full after last hurrah', boardFull(state.board)); const s2 = { board: randomBoard(rng), multiplier: 1, noMoves: false }; shuffleBoard(s2, rng); check('shuffle leaves no resting matches', findRuns(s2.board).length === 0); check('shuffle leaves a legal move', !!findMove(s2.board)); } console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.'); process.exit(failures ? 1 : 0);