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