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

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