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

116 lines
5.3 KiB
JavaScript

// 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.');