293 lines
14 KiB
JavaScript
293 lines
14 KiB
JavaScript
// 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.');
|