fertig-classic-games/tools/verifyRummikub.js

200 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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