// Headless verification for Mahjong (Hong Kong style). // node server/scripts/verifyMahjong.js // Exits non-zero on any failure. // // 1. Tile catalog: 144-tile wall, label assets exist on disk. // 2. Faan evaluator fixtures: known hands score the expected faan rows. // 3. Payments: zero-sum, points ladder monotonic and capped. // 4. AI self-play: full sessions with per-step invariants (tile conservation, // hand sizes, legal claims, minimum faan, zero-sum scores, termination). import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { TILES, buildWall, BASE_POINTS, LIMIT_FAAN, MIN_FAAN, } from '../../public/src/games/mahjong/MahjongData.js'; import { createInitialState, dealHand, discardTile, claimOptionsFor, resolveClaims, selfActions, declareKong, declareWin, startNextHand, isGameOver, getWinners, counts34, decompose, isThirteenOrphans, canWinWith, winningTiles, shanten, evaluateWin, } from '../../public/src/games/mahjong/MahjongLogic.js'; import { chooseDiscard, chooseClaim, chooseSelfAction, } from '../../public/src/games/mahjong/MahjongAI.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; } // kind shorthands for fixtures const b = (n) => n - 1, c = (n) => 9 + n - 1, ch = (n) => 18 + n - 1; const E = 27, S = 28, W = 29, N = 30, R = 31, G = 32, Wh = 33; // ── 1. Tile catalog ────────────────────────────────────────────────────────── console.log('Tile catalog:'); check(TILES.length === 42, `kind count ${TILES.length}, expected 42`); const wall = buildWall(); check(wall.length === 144, `wall ${wall.length} tiles, expected 144`); const wc = new Array(42).fill(0); for (const k of wall) wc[k]++; for (let k = 0; k < 34; k++) check(wc[k] === 4, `kind ${k} has ${wc[k]} copies, expected 4`); for (let k = 34; k < 42; k++) check(wc[k] === 1, `bonus kind ${k} has ${wc[k]} copies, expected 1`); for (const t of TILES) { if (!t.label) continue; // white dragon is drawn procedurally const file = path.join(IMG_DIR, `${t.label.replace(/^mahjong-/, '')}.png`); check(fs.existsSync(file), `missing label asset for ${t.id}: ${file}`); } console.log(' ok'); // ── 2. Faan fixtures ───────────────────────────────────────────────────────── // Build a minimal state around one player's completed hand. For discard wins // `hand` excludes the winning tile; for self-draws it includes it. function fixtureState({ hand, melds = [], bonus = [], seat = 0, dealer = 0, byDiscard, winTile, lastKong = false, wallEmpty = false }) { const players = []; for (let s = 0; s < 4; s++) { players.push({ name: `P${s + 1}`, seat: s, score: 0, skill: 3, isAI: s !== 0, hand: [], melds: [], bonus: [] }); } players[seat] = { ...players[seat], hand: hand.slice(), melds, bonus }; return { players, dealer, roundWind: 0, wallPos: wallEmpty ? 1 : 0, wallEnd: wallEmpty ? 0 : 90, lastDrawWasKongReplacement: lastKong, lastDiscard: byDiscard ? { kind: winTile, from: (seat + 1) % 4 } : null, }; } function checkFixture(name, args, expectFaan, mustHave = [], mustNotHave = []) { const state = fixtureState(args); const res = evaluateWin(state, args.seat ?? 0, args.winTile, !!args.byDiscard); const ids = res.faanList.map((r) => r.id); check(res.faan === expectFaan, `${name}: faan ${res.faan}, expected ${expectFaan} [${ids.join(', ')}]`); for (const id of mustHave) check(ids.includes(id), `${name}: missing faan row '${id}'`); for (const id of mustNotHave) check(!ids.includes(id), `${name}: unexpected faan row '${id}'`); check(res.payments.reduce((a, x) => a + x, 0) === 0, `${name}: payments not zero-sum`); return res; } console.log('Faan fixtures:'); const exposedPung = (k, from = 1) => ({ type: 'pung', kinds: [k, k, k], concealed: false, from }); checkFixture('thirteen orphans', { hand: [b(1), b(1), b(9), c(1), c(9), ch(1), ch(9), E, S, W, N, R, G, Wh], byDiscard: false, winTile: b(1) }, 13, ['thirteen-orphans']); checkFixture('pure one suit', { hand: [b(1), b(1), b(1), b(2), b(3), b(4), b(4), b(5), b(6), b(7), b(8), b(9), b(9)], byDiscard: true, winTile: b(9) }, 8, ['pure-one-suit', 'concealed', 'no-bonus'], ['mixed-one-suit']); checkFixture('mixed one suit + round wind', { hand: [c(1), c(2), c(3), c(4), c(5), c(6), c(7), c(8), c(9), E, E, R, R], seat: 1, byDiscard: true, winTile: E }, 6, ['mixed-one-suit', 'round-wind', 'concealed', 'no-bonus'], ['seat-wind', 'pure-one-suit']); checkFixture('all pungs (exposed)', { hand: [b(2), b(2), c(5), c(5), c(5), ch(7), ch(7), ch(7), b(9), b(9)], melds: [exposedPung(N)], seat: 2, byDiscard: true, winTile: b(2) }, 4, ['all-pungs', 'no-bonus'], ['concealed', 'seat-wind', 'round-wind']); checkFixture('common hand', { hand: [b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9)], byDiscard: true, winTile: c(9) }, 3, ['common-hand', 'concealed', 'no-bonus']); checkFixture('chicken hand scores zero', { hand: [c(2), c(3), c(4), c(5), c(6), c(7), ch(3), ch(4), ch(8), ch(8)], melds: [exposedPung(b(2))], bonus: [35], byDiscard: true, winTile: ch(5) }, 0, [], ['no-bonus', 'seat-flower', 'common-hand']); checkFixture('dealer east pung stacks seat + round wind', { hand: [b(2), b(3), b(5), b(6), b(7), ch(3), ch(3), ch(3), c(9), c(9)], melds: [exposedPung(E)], seat: 0, dealer: 0, byDiscard: true, winTile: b(4) }, 3, ['seat-wind', 'round-wind', 'no-bonus']); checkFixture('great dragons', { hand: [Wh, Wh, Wh, b(2), b(3), c(5), c(5)], melds: [exposedPung(R), exposedPung(G)], byDiscard: true, winTile: b(4) }, 9, ['great-dragons', 'no-bonus'], ['dragon-pung', 'small-dragons']); checkFixture('small dragons', { hand: [Wh, Wh, b(2), b(3), b(4), c(6), c(7)], melds: [exposedPung(R), exposedPung(G)], byDiscard: true, winTile: c(8) }, 6, ['small-dragons', 'no-bonus'], ['dragon-pung', 'great-dragons']); checkFixture('all honors capped at limit', { hand: [E, E, E, S, S, S, R, R, R, G, G, G, N, N], byDiscard: false, winTile: N }, 13, ['all-honors'], ['all-pungs', 'mixed-one-suit']); checkFixture('self draw context', { hand: [b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9), c(9)], byDiscard: false, winTile: c(9) }, 4, ['common-hand', 'self-draw', 'concealed', 'no-bonus']); checkFixture('kong replacement context', { hand: [b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9), c(9)], byDiscard: false, winTile: c(9), lastKong: true }, 5, ['kong-draw', 'self-draw']); checkFixture('ambiguous 111222333 read as chows', { hand: [b(1), b(1), b(1), b(2), b(2), b(2), b(3), b(3), b(3), ch(7), ch(8), ch(9), c(5)], byDiscard: true, winTile: c(5) }, 3, ['common-hand', 'concealed', 'no-bonus']); checkFixture('flowers: own + full set', { hand: [b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9)], bonus: [34, 35, 36, 37], byDiscard: true, winTile: c(9) }, 5, ['common-hand', 'concealed', 'seat-flower', 'flower-set'], ['no-bonus']); // decomposer / shanten sanity { const tenpai = counts34([b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9)]); check(shanten(tenpai, 0) === 0, `tenpai hand shanten ${shanten(tenpai, 0)}, expected 0`); const complete = counts34([b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9), c(9)]); check(shanten(complete, 0) === -1, `complete hand shanten ${shanten(complete, 0)}, expected -1`); check(decompose(complete, 4).length > 0, 'complete hand fails to decompose'); const waits = winningTiles([b(1), b(2), b(3), b(4), b(5), b(6), c(1), c(2), c(3), ch(4), ch(5), ch(6), c(9)], []); check(waits.length === 1 && waits[0] === c(9), `waits [${waits.join(',')}], expected [${c(9)}]`); check(canWinWith([b(1), b(1), b(9), c(1), c(9), ch(1), ch(9), E, S, W, N, R, G], [], Wh), 'thirteen orphans not recognised by canWinWith'); check(isThirteenOrphans(counts34([b(1), b(1), b(9), c(1), c(9), ch(1), ch(9), E, S, W, N, R, G, Wh])), 'isThirteenOrphans rejects a valid hand'); } console.log(` ${failures === 0 ? 'ok' : 'see failures above'}`); // ── 3. Points ladder ───────────────────────────────────────────────────────── console.log('Points ladder:'); check(BASE_POINTS.length === LIMIT_FAAN + 1, 'BASE_POINTS does not cover 0..LIMIT_FAAN'); for (let i = 1; i < BASE_POINTS.length; i++) { check(BASE_POINTS[i] > BASE_POINTS[i - 1], `BASE_POINTS not monotonic at faan ${i}`); } console.log(' ok'); // ── 4. AI self-play ────────────────────────────────────────────────────────── console.log('Self-play (40 sessions, mixed skills):'); function tileConservation(state) { let total = state.wallEnd - state.wallPos + 1; for (const p of state.players) { total += p.hand.length + p.bonus.length; for (const m of p.melds) total += m.kinds.length; } for (const river of state.discards) total += river.length; return total; } function checkInvariants(state, tag) { if (state.phase === 'handOver' || state.phase === 'gameOver') return; const total = tileConservation(state); check(total === 144, `${tag}: ${total} tiles accounted for, expected 144`); for (const p of state.players) { const drawing = state.phase === 'awaitDiscard' && state.turn === p.seat; const expected = (drawing ? 14 : 13) - 3 * p.melds.length; check(p.hand.length === expected, `${tag}: seat ${p.seat} hand ${p.hand.length}, expected ${expected} (${state.phase})`); } } function playSession(seed) { const skills = { 0: 1 + (seed % 5), 1: 1 + ((seed >> 2) % 5), 2: 3, 3: 5 }; const state = createInitialState({ names: ['A', 'B', 'C', 'D'], skills, seed }); const dealersSeen = new Set(); let hands = 0, wins = 0, draws = 0, claims = 0, kongs = 0; let guard = 100000; while (!isGameOver(state) && guard-- > 0) { checkInvariants(state, `seed ${seed} hand ${state.handNumber}`); if (state.phase === 'handOver') { dealersSeen.add(state.dealer); hands++; const r = state.result; if (r.type === 'win') { wins++; check(r.faan >= MIN_FAAN, `seed ${seed}: win below minimum faan (${r.faan})`); check(r.payments.reduce((a, x) => a + x, 0) === 0, `seed ${seed}: payments not zero-sum`); } else { draws++; } const sum = state.players.reduce((a, p) => a + p.score, 0); check(sum === 0, `seed ${seed}: cumulative scores sum to ${sum}, expected 0`); startNextHand(state); continue; } if (state.phase === 'awaitDiscard') { const seat = state.turn; const p = state.players[seat]; const acts = selfActions(state); const sa = chooseSelfAction(state, seat, acts, p.skill); if (sa?.type === 'win') { check(acts.canWin, `seed ${seed}: AI declared an unoffered win`); declareWin(state, seat, { byDiscard: false }); continue; } if (sa?.type === 'kong') { kongs++; check(declareKong(state, sa.spec), `seed ${seed}: illegal kong ${JSON.stringify(sa.spec)}`); continue; } const d = chooseDiscard(state, seat, p.skill); check(p.hand.includes(d), `seed ${seed}: AI discarded unheld kind ${d}`); discardTile(state, d); continue; } if (state.phase === 'awaitClaims') { const from = state.lastDiscard.from; const intents = []; for (let seat = 0; seat < 4; seat++) { const options = claimOptionsFor(state, seat); if (!options) continue; check(seat !== from, `seed ${seed}: discarder offered a claim`); if (options.chows.length) { check(seat === (from + 1) % 4, `seed ${seed}: chow offered to non-left seat`); } const claim = chooseClaim(state, seat, options, state.players[seat].skill); if (claim) intents.push({ seat, claim }); } const { applied } = resolveClaims(state, intents); if (applied && applied.claim.type !== 'win') claims++; continue; } check(false, `seed ${seed}: unknown phase ${state.phase}`); break; } check(guard > 0, `seed ${seed}: session did not terminate`); check(dealersSeen.size === 4 || guard <= 0, `seed ${seed}: only ${dealersSeen.size}/4 seats dealt`); check(getWinners(state).length >= 1, `seed ${seed}: no session winner`); return { hands, wins, draws, claims, kongs }; } let tHands = 0, tWins = 0, tDraws = 0, tClaims = 0, tKongs = 0; const t0 = Date.now(); for (let g = 0; g < 40; g++) { const r = playSession(1000 + g * 7919); tHands += r.hands; tWins += r.wins; tDraws += r.draws; tClaims += r.claims; tKongs += r.kongs; } const secs = ((Date.now() - t0) / 1000).toFixed(1); console.log(` ${tHands} hands over 40 sessions in ${secs}s — ${tWins} wins, ${tDraws} drawn, ${tClaims} claims, ${tKongs} kongs`); check(tWins > 0, 'no hand was ever won — evaluator or AI suspect'); check(tWins / Math.max(1, tHands) > 0.3, `only ${tWins}/${tHands} hands won — too many goulash draws`); if (failures) { console.error(`\nFAILED: ${failures} check(s).`); process.exit(1); } console.log('\nAll Mahjong checks passed.');