fertig-classic-games/server/scripts/verifyBejeweled.js

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