200 lines
9.9 KiB
JavaScript
200 lines
9.9 KiB
JavaScript
// Headless verification for Rummikub.
|
||
// node server/scripts/verifyRummikub.js [--games=N]
|
||
// Exits non-zero on any failure.
|
||
//
|
||
// 1. Fixture tests: set validation (runs/groups/jokers), table partitioning, the
|
||
// 30-point initial meld, and core engine transitions.
|
||
// 2. Self-play: full 2–4 player games driven by the heuristic AI in every seat,
|
||
// asserting invariants (no exceptions, strict tile conservation = 106 every
|
||
// turn, legal sets, the opening-meld rule, termination with a winner).
|
||
|
||
import {
|
||
isValidSet, partitionable, solvePartition, bestMeldDecomposition,
|
||
} from '../src/games/rummikub/RummikubSolver.js';
|
||
import {
|
||
createInitialState, commitTurn, drawTile, stageAiPlan, validateCommit,
|
||
buildTiles, rackPenalty, beginTurn,
|
||
} from '../src/games/rummikub/RummikubLogic.js';
|
||
import { planTurn } from '../src/games/rummikub/RummikubAI.js';
|
||
import { INITIAL_MELD_MIN } from '../src/games/rummikub/RummikubData.js';
|
||
|
||
let failures = 0;
|
||
function check(name, cond, detail = '') {
|
||
if (cond) { console.log(` ok ${name}`); return; }
|
||
failures++;
|
||
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
||
}
|
||
|
||
// Fixture tiles use ids well above the engine's 0..105 so they never collide.
|
||
let _id = 100000;
|
||
const T = (color, number) => ({ id: _id++, color, number, isJoker: false });
|
||
const J = () => ({ id: _id++, color: null, number: null, isJoker: true });
|
||
|
||
// ── 1. Single-set validation ────────────────────────────────────────────────────
|
||
console.log('Set validation:');
|
||
check('run 4-5-6 red', isValidSet([T('red', 4), T('red', 5), T('red', 6)]).valid);
|
||
check('group 7 r/b/k', isValidSet([T('red', 7), T('blue', 7), T('black', 7)]).valid);
|
||
check('group of four', isValidSet([T('red', 7), T('blue', 7), T('black', 7), T('orange', 7)]).valid);
|
||
check('five not a group', !isValidSet([T('red', 7), T('blue', 7), T('black', 7), T('orange', 7), T('red', 7)]).valid);
|
||
check('group needs distinct colours', !isValidSet([T('red', 7), T('red', 7), T('blue', 7)]).valid);
|
||
check('run rejects duplicate', !isValidSet([T('red', 5), T('red', 5), T('red', 6)]).valid);
|
||
check('run does not wrap 13→1', !isValidSet([T('red', 12), T('red', 13), T('red', 1)]).valid);
|
||
check('two tiles too short', !isValidSet([T('red', 4), T('red', 5)]).valid);
|
||
{
|
||
const r = isValidSet([T('red', 5), J(), T('red', 7)]);
|
||
check('joker fills run gap (=red6)', r.valid && r.points === 18, `points ${r.points}`);
|
||
}
|
||
{
|
||
const g = isValidSet([T('red', 7), T('blue', 7), J()]);
|
||
check('joker fills group, scored at 7', g.valid && g.points === 21, `points ${g.points}`);
|
||
}
|
||
check('non-adjacent reals + joker invalid', !isValidSet([T('red', 2), T('red', 9), J()]).valid);
|
||
check('13 reals + joker too long', !isValidSet(
|
||
Array.from({ length: 13 }, (_, i) => T('blue', i + 1)).concat(J())).valid);
|
||
check('all-joker set invalid', !isValidSet([J(), J(), J()]).valid);
|
||
|
||
// ── 2. Table partitioning ───────────────────────────────────────────────────────
|
||
console.log('Partitioning:');
|
||
check('two clean sets partition', partitionable([
|
||
T('red', 1), T('red', 2), T('red', 3), T('blue', 7), T('black', 7), T('orange', 7),
|
||
]));
|
||
check('missing tile fails', !partitionable([T('red', 1), T('red', 2), T('blue', 7), T('black', 7)]));
|
||
check('empty table partitions', partitionable([]));
|
||
{
|
||
// A shared-tile rearrangement: red 1..5 (two copies of 3) = run 1-2-3 + run 3-4-5.
|
||
const tiles = [T('red', 1), T('red', 2), T('red', 3), T('red', 3), T('red', 4), T('red', 5)];
|
||
check('overlapping runs partition', partitionable(tiles));
|
||
}
|
||
{
|
||
const sol = solvePartition([T('red', 3), T('red', 4), T('red', 5), T('blue', 9), T('black', 9), J()]);
|
||
check('joker-in-table partition reconstructs', !!sol && sol.length === 2);
|
||
}
|
||
{
|
||
// odd count that cannot fully partition (7 tiles, one stranded)
|
||
check('stranded tile fails', !partitionable([
|
||
T('red', 1), T('red', 2), T('red', 3), T('blue', 7), T('black', 7), T('orange', 7), T('orange', 1),
|
||
]));
|
||
}
|
||
|
||
// ── 3. Initial-meld rule ────────────────────────────────────────────────────────
|
||
console.log('Initial meld:');
|
||
{
|
||
// 29 points from one run → rejected; 30 → accepted (via bestMeldDecomposition).
|
||
const rack29 = [T('blue', 9), T('blue', 10), T('blue', 11)]; // 30 actually; craft 29:
|
||
const r29 = [T('red', 4), T('red', 5), T('red', 6), T('black', 9)]; // 4+5+6=15 only set; <30
|
||
const dec29 = bestMeldDecomposition(r29, [], { mustReach: INITIAL_MELD_MIN, alreadyMelded: false });
|
||
check('sub-30 opening rejected', dec29 === null);
|
||
const dec30 = bestMeldDecomposition(rack29, [], { mustReach: INITIAL_MELD_MIN, alreadyMelded: false });
|
||
check('30 opening accepted', !!dec30 && dec30.tilesPlayed.length === 3);
|
||
}
|
||
{
|
||
// joker counted at represented value toward the 30.
|
||
const rack = [T('orange', 10), T('orange', 11), J()]; // 10+11+12 = 33
|
||
const dec = bestMeldDecomposition(rack, [], { mustReach: INITIAL_MELD_MIN, alreadyMelded: false });
|
||
check('joker counts toward opening meld', !!dec);
|
||
}
|
||
|
||
// ── 4. Engine transitions ───────────────────────────────────────────────────────
|
||
console.log('Engine:');
|
||
for (const pc of [2, 3, 4]) {
|
||
const s = createInitialState({ seed: 123, playerCount: pc });
|
||
const dealt = s.players.reduce((a, p) => a + p.rack.length, 0);
|
||
check(`${pc}p deal: 14 each + pool = 106`, dealt === 14 * pc && s.pool.length === 106 - 14 * pc,
|
||
`dealt ${dealt}, pool ${s.pool.length}`);
|
||
}
|
||
{
|
||
let s = createInitialState({ seed: 5, playerCount: 4 });
|
||
const before = s.currentPlayer;
|
||
const poolBefore = s.pool.length;
|
||
s = drawTile(s);
|
||
check('draw advances turn', s.currentPlayer === (before + 1) % 4);
|
||
check('draw removes one pool tile', s.pool.length === poolBefore - 1);
|
||
}
|
||
{
|
||
// Reject an invalid working board (a 2-tile "set").
|
||
let s = createInitialState({ seed: 6, playerCount: 2 });
|
||
s = { ...s, workingTable: [[s.workingRack[0], s.workingRack[1]]], workingRack: s.workingRack.slice(2) };
|
||
const v = validateCommit(s);
|
||
check('invalid board rejected', !v.ok);
|
||
const after = commitTurn(s);
|
||
check('rejected commit leaves an error', !!after.commitError && after.currentPlayer === s.currentPlayer);
|
||
}
|
||
check('tile factory builds 106', buildTiles().length === 106);
|
||
check('rack penalty: joker=30', rackPenalty([J()]) === 30);
|
||
|
||
// ── 5. Self-play ────────────────────────────────────────────────────────────────
|
||
const games = Number((process.argv.find((a) => a.startsWith('--games=')) || '').split('=')[1]) || 300;
|
||
console.log(`Self-play (${games} games):`);
|
||
|
||
function tileTotal(s) {
|
||
const rack = s.players.reduce((a, p) => a + p.rack.length, 0);
|
||
const table = s.table.reduce((a, set) => a + set.length, 0);
|
||
return rack + table + s.pool.length;
|
||
}
|
||
|
||
let exceptions = 0, conserveBad = 0, illegalSet = 0, badRack = 0, noWinner = 0, firstMeldBad = 0;
|
||
const winsBySeat = {};
|
||
let meldedGames = 0;
|
||
|
||
for (let g = 1; g <= games; g++) {
|
||
try {
|
||
const playerCount = 2 + (g % 3); // cycles 2,3,4
|
||
let s = createInitialState({ seed: g * 2654435761, playerCount });
|
||
let turns = 0, sawMeld = false;
|
||
|
||
while (s.phase !== 'gameOver') {
|
||
if (++turns > 100000) throw new Error('turn loop did not terminate');
|
||
if (tileTotal(s) !== 106) { conserveBad++; throw new Error(`tile total ${tileTotal(s)}`); }
|
||
|
||
const seat = s.currentPlayer;
|
||
const meldedBefore = s.players[seat].hasMelded;
|
||
const skill = 1 + ((g + seat) % 5);
|
||
const plan = planTurn(s, seat, skill);
|
||
|
||
if (plan.type === 'commit') {
|
||
// Opening-meld rule must hold.
|
||
if (!meldedBefore && plan.firstMeld) {
|
||
let pts = 0;
|
||
for (const set of plan.newTable) {
|
||
const ids = set.map((t) => t.id);
|
||
const fresh = ids.some((id) => plan.tilesPlayed.includes(id));
|
||
if (fresh && set.every((t) => plan.tilesPlayed.includes(t.id))) pts += isValidSet(set).points;
|
||
}
|
||
if (pts < INITIAL_MELD_MIN) firstMeldBad++;
|
||
}
|
||
s = stageAiPlan(s, plan);
|
||
const v = validateCommit(s);
|
||
if (!v.ok) { illegalSet++; throw new Error(`AI proposed illegal commit: ${v.reason}`); }
|
||
s = commitTurn(s);
|
||
sawMeld = true;
|
||
} else {
|
||
s = drawTile(s);
|
||
}
|
||
|
||
// Every committed table set is legal.
|
||
for (const set of s.table) if (!isValidSet(set).valid) illegalSet++;
|
||
for (const p of s.players) if (p.rack.length < 0 || p.rack.length > 60) badRack++;
|
||
}
|
||
|
||
if (s.winner == null) noWinner++; else winsBySeat[s.winner] = (winsBySeat[s.winner] || 0) + 1;
|
||
if (sawMeld) meldedGames++;
|
||
} catch (e) {
|
||
exceptions++;
|
||
if (exceptions <= 5) console.error(` game ${g}: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
check('no exceptions during self-play', exceptions === 0, `${exceptions} games threw`);
|
||
check('tile conservation holds (106 every turn)', conserveBad === 0, `${conserveBad} violations`);
|
||
check('all committed sets legal', illegalSet === 0, `${illegalSet} illegal`);
|
||
check('rack sizes stay sane', badRack === 0, `${badRack} bad`);
|
||
check('opening-meld rule never broken', firstMeldBad === 0, `${firstMeldBad} bad`);
|
||
check('most games see a meld', meldedGames >= games * 0.8, `${meldedGames}/${games}`);
|
||
check('games terminate with a winner', noWinner <= games * 0.05, `${noWinner} without winner`);
|
||
const seatList = Object.entries(winsBySeat).map(([k, v]) => `seat${k}:${v}`).join(' ');
|
||
check('wins spread across seats', Object.keys(winsBySeat).length >= 2, seatList);
|
||
console.log(` results: ${seatList}, no-winner ${noWinner}, melded ${meldedGames}/${games}`);
|
||
|
||
console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed.');
|
||
process.exit(failures ? 1 : 0);
|