// Headless verification for Cribbage. // node server/scripts/verifyCribbage.js [--games=N] // Exits non-zero on any failure. // // 1. Fixture tests: canonical hands (perfect 29, flush, nobs) and pegging // combinations (fifteen, pairs, runs) score the known values. // 2. Self-play: full games driven by the heuristic AI on both seats, asserting // invariants (scores never pass 121 before a win, the play always terminates, // a winner is reached) over many seeded games. import { Card } from '../../public/src/games/cribbage/CribbageData.js'; import { CribbageLogic, scoreHand, scorePlay } from '../../public/src/games/cribbage/CribbageLogic.js'; import { chooseDiscard, choosePlay } from '../../public/src/games/cribbage/CribbageAI.js'; let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); return; } failures++; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); } function mulberry32(seed) { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const C = (rank, suit) => new Card(rank, suit); // ── 1. Scoring fixtures ───────────────────────────────────────────────────── console.log('Scoring fixtures:'); { // Perfect 29: J♥ + 5♠ 5♣ 5♦, cut 5♥. const hand = [C('J', 'h'), C('5', 's'), C('5', 'c'), C('5', 'd')]; const r = scoreHand(hand, C('5', 'h'), false); check('perfect hand scores 29', r.total === 29, `got ${r.total}`); check(' → 16 in fifteens', r.fifteens === 16, `got ${r.fifteens}`); check(' → 12 in pairs', r.pairs === 12, `got ${r.pairs}`); check(' → 1 for nobs', r.nobs === 1, `got ${r.nobs}`); } { // Four-card hand flush, starter off-suit → 4; on-suit → 5. const hand = [C('2', 's'), C('4', 's'), C('6', 's'), C('9', 's')]; check('4-card flush, off-suit cut → 4', scoreHand(hand, C('K', 'h')).flush === 4); check('5-card flush, on-suit cut → 5', scoreHand(hand, C('K', 's')).flush === 5); } { // Crib needs all five suited; a 4-card crib flush does not count. const crib = [C('2', 's'), C('4', 's'), C('6', 's'), C('9', 's')]; check('crib 4-flush off-suit → 0', scoreHand(crib, C('K', 'h'), true).flush === 0); check('crib 5-flush on-suit → 5', scoreHand(crib, C('K', 's'), true).flush === 5); } { // Nobs: Jack matching the starter suit. const hand = [C('J', 'd'), C('3', 's'), C('7', 'c'), C('9', 'h')]; check('nobs when J matches starter suit', scoreHand(hand, C('A', 'd')).nobs === 1); check('no nobs when J off-suit', scoreHand(hand, C('A', 'c')).nobs === 0); } { // A double run of three with a pair = 8 (run 3×2 + pair 2) plus fifteens. const r = scoreHand([C('3', 's'), C('4', 's'), C('5', 'c'), C('5', 'd')], C('6', 'h')); // runs: 3-4-5-6 doubled (two 5s) = 8; pair of 5s = 2; fifteens: (4+5+6),(4+5+6),(5+5+... ) check('double run + pair runs = 8', r.runs === 8, `got ${r.runs}`); check('double run pair = 2', r.pairs === 2, `got ${r.pairs}`); } // ── 2. Pegging fixtures ───────────────────────────────────────────────────── console.log('Pegging fixtures:'); check('fifteen pegs 2', scorePlay([C('7', 's'), C('8', 'd')]).points === 2); check('thirty-one pegs 2', scorePlay([C('K', 's'), C('T', 'd'), C('6', 'c'), C('5', 'h')]).points === 2); check('pair pegs 2', scorePlay([C('4', 's'), C('4', 'd')]).points === 2); check('pair-royal pegs 6', scorePlay([C('4', 's'), C('4', 'd'), C('4', 'c')]).points === 6); check('run of 3 pegs 3', scorePlay([C('3', 's'), C('5', 'd'), C('4', 'c')]).points === 3); check('run of 4 pegs 4', scorePlay([C('3', 's'), C('5', 'd'), C('4', 'c'), C('6', 'h')]).points === 4); check('broken run scores no run', scorePlay([C('3', 's'), C('5', 'd'), C('8', 'c')]).points === 0); // ── 3. Self-play ──────────────────────────────────────────────────────────── const games = Number((process.argv.find((a) => a.startsWith('--games=')) || '').split('=')[1]) || 500; console.log(`Self-play (${games} games):`); function runPlay(g) { let guard = 0; while (g.phase === 'play') { if (++guard > 200) throw new Error('pegging did not terminate'); const p = g.turn; const legal = g.legalPlays(p); if (legal.length === 0) g.go(p); else g.play(p, choosePlay(legal, g.pile, g.count, 3 + (p % 2))); } } let wins = [0, 0], maxRounds = 0, overflow = false, exceptions = 0; for (let s = 1; s <= games; s++) { try { const g = new CribbageLogic({ rng: mulberry32(s * 2654435761), startDealer: s % 2 }); let rounds = 0; while (g.winner === null) { if (++rounds > 300) throw new Error('game did not terminate'); g.newDeal(); for (const p of [0, 1]) g.discard(p, chooseDiscard(g.hands[p], p === g.dealer, p === 0 ? 4 : 3)); g.cut(); if (g.scores[0] > 121 || g.scores[1] > 121) overflow = true; if (g.winner !== null) break; runPlay(g); if (g.scores[0] > 121 || g.scores[1] > 121) overflow = true; if (g.winner !== null) break; g.show(); if (g.scores[0] > 121 || g.scores[1] > 121) overflow = true; if (g.winner !== null) break; g.dealer = 1 - g.dealer; } wins[g.winner]++; maxRounds = Math.max(maxRounds, rounds); } catch (e) { exceptions++; if (exceptions <= 3) console.error(` game ${s}: ${e.message}`); } } check('no exceptions during self-play', exceptions === 0, `${exceptions} games threw`); check('scores never exceed 121', !overflow); check('both seats win some games', wins[0] > 0 && wins[1] > 0, `wins ${wins[0]}/${wins[1]}`); console.log(` results: human ${wins[0]} / ai ${wins[1]}, longest game ${maxRounds} deals`); console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed.'); process.exit(failures ? 1 : 0);