// Headless verification for Mahjong Match. // node server/scripts/verifyMahjongMatch.js // Exits non-zero on any failure. // // 1. Face set: 144 tiles, 72 pairs, label assets exist on disk. // 2. Layouts: expected tile counts, no overlaps, every raised tile supported. // 3. Deals: every generated board is clearable by replaying free matching // pairs greedily with reshuffles (and spot-checked exhaustively). // 4. Random self-play: full games with random move choice + shuffle-on-stuck. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { FACES, LAYOUTS, LAYOUT_ORDER, buildPairPool, validateLayout, newGame, isFree, canMatch, removePair, findMoves, reshuffleRemaining, } from '../../public/src/games/mahjongmatch/MahjongLogic.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const IMG_DIR = path.join(__dirname, '../../public/assets/images/mahjong'); let failures = 0; function check(ok, msg) { if (!ok) { failures++; console.error(` ✗ ${msg}`); } return ok; } // ── 1. Face set ───────────────────────────────────────────────────────────── console.log('Face set:'); const totalTiles = FACES.reduce((s, f) => s + f.copies, 0); check(totalTiles === 144, `tile count ${totalTiles}, expected 144`); check(FACES.length === 42, `face count ${FACES.length}, expected 42`); const pool = buildPairPool(); check(pool.length === 72, `pair pool ${pool.length}, expected 72`); check(pool.every(([a, b]) => a.group === b.group), 'pair pool contains a mismatched pair'); for (const f of FACES) { if (!f.label) continue; // white dragon is drawn procedurally const file = path.join(IMG_DIR, `${f.label.replace(/^mahjong-/, '')}.png`); check(fs.existsSync(file), `missing label asset for ${f.id}: ${file}`); } console.log(' ok'); // ── 2. Layouts ────────────────────────────────────────────────────────────── console.log('Layouts:'); const EXPECTED_COUNTS = { garden: 72, crossroads: 90, pyramid: 106, butterfly: 140, fortress: 144, turtle: 144 }; for (const key of LAYOUT_ORDER) { const layout = LAYOUTS[key]; const n = layout.positions.length; check(n === EXPECTED_COUNTS[key], `${key}: ${n} tiles, expected ${EXPECTED_COUNTS[key]}`); check(n <= 144, `${key}: more tiles than the set provides`); const errors = validateLayout(layout.positions); for (const e of errors) check(false, `${key}: ${e}`); console.log(` ${layout.name.padEnd(11)} ${n} tiles${errors.length ? '' : ' ok'}`); } // ── 3 & 4. Deals + random self-play ───────────────────────────────────────── // Play with uniformly random move choice; on stuck, reshuffle (cap 30). // Guaranteed-solvable deals can still strand under random play, but a healthy // engine should clear the vast majority and never crash or stall. function playRandomGame(layoutKey) { const g = newGame(layoutKey); let shuffles = 0; let guard = 10000; while (g.state === 'playing' && guard-- > 0) { const moves = findMoves(g); if (moves.length === 0) { if (shuffles >= 30) return { g, cleared: false, shuffles }; shuffles++; const ok = reshuffleRemaining(g); if (!ok && findMoves(g).length === 0) return { g, cleared: false, shuffles, dead: true }; continue; } const [a, b] = moves[Math.floor(Math.random() * moves.length)]; if (!check(isFree(g, a) && isFree(g, b) && canMatch(g, a, b), `${layoutKey}: findMoves returned illegal move`)) return { g, cleared: false, shuffles }; if (!check(removePair(g, a, b), `${layoutKey}: removePair rejected a legal move`)) return { g, cleared: false, shuffles }; } check(guard > 0, `${layoutKey}: game did not terminate`); return { g, cleared: g.state === 'won', shuffles }; } console.log('Self-play (60 games per layout, random moves + shuffle on stuck):'); for (const key of LAYOUT_ORDER) { let cleared = 0, totalShuffles = 0, dead = 0; for (let i = 0; i < 60; i++) { const r = playRandomGame(key); if (r.cleared) cleared++; if (r.dead) dead++; totalShuffles += r.shuffles; if (r.g.state === 'won') { check(r.g.remaining === 0, `${key}: won with ${r.g.remaining} tiles remaining`); check(r.g.alive.every((a) => !a), `${key}: won with alive tiles`); } } check(cleared >= 55, `${key}: only ${cleared}/60 games cleared — generator or engine suspect`); console.log(` ${LAYOUTS[key].name.padEnd(11)} cleared ${cleared}/60, avg shuffles ${(totalShuffles / 60).toFixed(2)}, dead-ends ${dead}`); } // Sanity: a fresh deal must always open with at least one available move. console.log('Opening-move sanity (200 deals per layout):'); for (const key of LAYOUT_ORDER) { let minMoves = Infinity; for (let i = 0; i < 200; i++) { const g = newGame(key); const m = findMoves(g).length; if (m < minMoves) minMoves = m; } check(minMoves >= 1, `${key}: produced a deal with no opening move`); console.log(` ${LAYOUTS[key].name.padEnd(11)} min opening moves ${minMoves}`); } if (failures) { console.error(`\nFAILED: ${failures} check(s).`); process.exit(1); } console.log('\nAll Mahjong Match checks passed.');