282 lines
11 KiB
JavaScript
282 lines
11 KiB
JavaScript
// 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);
|