// Headless verification for Gin Rummy. // node server/scripts/verifyGinRummy.js // Exits non-zero on any failure. // // 1. Unit tests: deadwood values, meld detection, canLayoff, sort helpers. // 2. Logic engine: deal, draw, discard, knock, gin, layoff, scoring. // 3. Self-play: 4-player and 2-player games driven by the AI until completion. import { Card, ginDeadwoodValue, ginRunRank, allCandidateMelds, bestMeldGroups, canLayoff, sortBySuit, sortByRank, HAND_SIZE, MAX_DEADWOOD_TO_KNOCK, } from '../src/games/ginrummy/GinRummyData.js'; import { GinRummyLogic } from '../src/games/ginrummy/GinRummyLogic.js'; import { chooseDrawSource, chooseDiscard, shouldKnock, findLayoffs, } from '../src/games/ginrummy/GinRummyAI.js'; let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); return; } failures++; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); } // ── Card helpers ────────────────────────────────────────────────────────────── console.log('\nCard value helpers:'); check('A deadwood = 1', ginDeadwoodValue(new Card('A', 's')) === 1); check('9 deadwood = 9', ginDeadwoodValue(new Card('9', 'h')) === 9); check('T deadwood = 10', ginDeadwoodValue(new Card('T', 'd')) === 10); check('J deadwood = 10', ginDeadwoodValue(new Card('J', 'c')) === 10); check('K deadwood = 10', ginDeadwoodValue(new Card('K', 's')) === 10); check('A run rank = 1', ginRunRank(new Card('A', 's')) === 1); check('2 run rank = 2', ginRunRank(new Card('2', 'h')) === 2); check('K run rank = 13', ginRunRank(new Card('K', 'd')) === 13); // ── Meld detection ──────────────────────────────────────────────────────────── console.log('\nMeld detection:'); const c = (r, s) => new Card(r, s); const set3 = [c('7','s'), c('7','h'), c('7','d')]; check('3-card set found', allCandidateMelds(set3).length >= 1); check('3-card set has length 3', allCandidateMelds(set3)[0].length === 3); const set4 = ['s','h','d','c'].map(s => c('Q', s)); check('4-card set found', allCandidateMelds(set4).some(m => m.length === 4)); const run3 = [c('A','h'), c('2','h'), c('3','h')]; check('A-2-3 run found', allCandidateMelds(run3).some(m => m.length === 3)); const runJQK = [c('J','s'), c('Q','s'), c('K','s')]; check('J-Q-K run found', allCandidateMelds(runJQK).some(m => m.length === 3)); const noMeld = [c('2','s'), c('5','s'), c('9','s')]; check('non-consecutive same-suit not a meld', allCandidateMelds(noMeld).length === 0); const longRun = ['A','2','3','4','5'].map(r => c(r,'d')); const longMelds = allCandidateMelds(longRun); check('A-2-3-4-5 yields multiple sub-runs', longMelds.length >= 3); // bestMeldGroups: gin hand (0 deadwood) // A-2-3-4 spades run (4) + 5-6-7 hearts run (3) + Q♥Q♦Q♣ set (3) = 10 cards, 0 deadwood const ginHand = [ ...['A','2','3','4'].map(r => c(r,'s')), ...['5','6','7'].map(r => c(r,'h')), c('Q','h'), c('Q','d'), c('Q','c'), ]; check('gin hand: deadwood = 0', bestMeldGroups(ginHand).deadwood === 0); // High-deadwood hand const deadwoodHand = ['2','5','8','J','Q'].map(r => c(r,'s')).concat(['3','6','9','K'].map(r => c(r,'h'))).slice(0, HAND_SIZE); const { deadwood: mixedDW } = bestMeldGroups(deadwoodHand); check('mixed hand has positive deadwood', mixedDW > 0); // ── canLayoff ───────────────────────────────────────────────────────────────── console.log('\nLayoff validation:'); const run456h = ['4','5','6'].map(r => c(r,'h')); check('6h extends run at high end', canLayoff(c('7','h'), run456h)); check('3h extends run at low end', canLayoff(c('3','h'), run456h)); check('wrong suit rejected', !canLayoff(c('7','s'), run456h)); check('non-consecutive rejected', !canLayoff(c('8','h'), run456h)); const setAAA = ['s','h','d'].map(s => c('A', s)); check('Ac extends AAA set', canLayoff(c('A','c'), setAAA)); check('duplicate suit rejected', !canLayoff(c('A','s'), setAAA)); check('wrong rank rejected', !canLayoff(c('2','c'), setAAA)); const fullSet = ['s','h','d','c'].map(s => c('K', s)); check('5th card on full set rejected', !canLayoff(c('K','s'), fullSet)); // ── Sort helpers ────────────────────────────────────────────────────────────── console.log('\nSort helpers:'); const mixedHand = [c('3','h'), c('A','s'), c('2','h'), c('K','s'), c('5','d')]; const byS = sortBySuit(mixedHand); check('sortBySuit: first two are spades', byS[0].suit === 's' && byS[1].suit === 's'); check('sortBySuit: within suit sorted by rank', ginRunRank(byS[0]) <= ginRunRank(byS[1])); const byR = sortByRank(mixedHand); check('sortByRank: first card is Ace (rank 1)', byR[0].rank === 'A'); check('sortByRank: last card is King (rank 13)', byR[byR.length-1].rank === 'K'); // ── Logic engine ────────────────────────────────────────────────────────────── console.log('\nGinRummyLogic:'); function newLogic(n = 4) { const l = new GinRummyLogic(n, Math.random); l.newGame(); return l; } const l1 = newLogic(4); check('newGame: 4 players each get 10 cards', l1.players.every(p => p.hand.length === HAND_SIZE)); check('newGame: initial phase is draw', l1.phase === 'draw'); check('newGame: stock non-empty', l1.stockCount > 0); check('newGame: discard top exists', l1.discardTop !== null); const l2 = newLogic(2); const firstSeat = l2.currentPlayer; l2.drawStock(firstSeat); check('drawStock: hand becomes 11', l2.players[firstSeat].hand.length === HAND_SIZE + 1); check('drawStock: phase becomes discard', l2.phase === 'discard'); const l3 = newLogic(2); const firstSeat3 = l3.currentPlayer; const topCard = l3.discardTop; l3.drawDiscard(firstSeat3); check('drawDiscard: hand becomes 11', l3.players[firstSeat3].hand.length === HAND_SIZE + 1); check('drawDiscard: discard pile shortened', l3.discard.length === 0); const l4 = newLogic(2); const fs4 = l4.currentPlayer; l4.drawStock(fs4); const hand4 = l4.players[fs4].hand; const discKey = hand4[0].key; l4.discardCard(fs4, discKey); check('discardCard: hand returns to 10', l4.players[fs4].hand.length === HAND_SIZE); check('discardCard: turn advances', l4.currentPlayer !== fs4); check('discardCard: phase back to draw', l4.phase === 'draw'); // Knock validation const l5 = newLogic(2); const fs5 = l5.currentPlayer; l5.drawStock(fs5); const { melds: m5 } = bestMeldGroups(l5.players[fs5].hand); // knock(seat, discardKey, meldGroups) now includes discard internally let knocked5 = false; for (const cx of l5.players[fs5].hand) { const rest = l5.players[fs5].hand.filter(x => x.key !== cx.key); const { melds: rm, deadwood: rdw } = bestMeldGroups(rest); if (rdw <= MAX_DEADWOOD_TO_KNOCK) { const ok = l5.knock(fs5, cx.key, rm); check('knock: accepted when deadwood ≤ 10', ok); check('knock: phase becomes layoff', l5.phase === 'layoff'); knocked5 = true; break; } } if (!knocked5) check('knock test skipped (hand not knockable)', true); // ── AI helpers ──────────────────────────────────────────────────────────────── console.log('\nAI helpers:'); const testHand = ginHand; // gin hand check('shouldKnock: gin hand → true at any skill', shouldKnock(testHand, 3)); check('chooseDiscard returns a card', chooseDiscard([...testHand, c('2','c')], 3) !== null); const aiHand = [...'23456'.split('').map(r => c(r,'h')), ...['K','K','K'].map((r,i) => c(r,['s','d','c'][i])), c('Q','s'), c('J','s')]; check('findLayoffs: finds valid layoff on matching run', findLayoffs([c('7','h')], [['4','5','6'].map(r => c(r,'h'))], 3).length > 0 ); check('findLayoffs: rejects invalid card', findLayoffs([c('7','s')], [['4','5','6'].map(r => c(r,'h'))], 3).length === 0 ); // ── Self-play simulation ────────────────────────────────────────────────────── console.log('\nSelf-play (4-player, up to 40 rounds):'); function runGame(nPlayers) { const logic = new GinRummyLogic(nPlayers, Math.random); logic.newGame(); let turns = 0, maxTurns = 2000; while (logic.phase !== 'gameover' && turns < maxTurns) { turns++; if (logic.phase === 'roundover') { logic.newRound(); continue; } const seat = logic.currentPlayer; if (logic.phase === 'draw') { const hand = logic.players[seat].hand; const discardTop = logic.discardTop; const src = chooseDrawSource(hand, discardTop, 3); if (src === 'discard' && discardTop) logic.drawDiscard(seat); else logic.drawStock(seat); } if (logic.phase === 'discard') { const handNow = logic.players[seat].hand; let acted = false; // Try gin for (const cx of handNow) { const rest = handNow.filter(x => x.key !== cx.key); const { deadwood: dw, melds: rm } = bestMeldGroups(rest); if (dw === 0) { acted = logic.gin(seat, cx.key, rm); break; } } // Try knock if (!acted) { let best = null, bestDW = Infinity, bestMelds = []; for (const cx of handNow) { const rest = handNow.filter(x => x.key !== cx.key); const { deadwood: dw, melds: m } = bestMeldGroups(rest); if (dw < bestDW) { bestDW = dw; best = cx; bestMelds = m; } } if (best && bestDW <= MAX_DEADWOOD_TO_KNOCK) { acted = logic.knock(seat, best.key, bestMelds); } } // Normal discard if (!acted) { const d = chooseDiscard(handNow, 3); if (d) logic.discardCard(seat, d.key); } } if (logic.phase === 'layoff') { const ls = logic.layoffPlayer; if (ls !== logic.knocker) { const lHand = logic.players[ls].hand; const layoffs = findLayoffs(lHand, logic.knockerMelds, 3); if (layoffs.length > 0) logic.layoff(ls, layoffs); logic.passLayoff(ls); } } } return { phase: logic.phase, winner: logic.winner, turns }; } const res4 = runGame(4); check('4-player: game ends with gameover', res4.phase === 'gameover', `phase=${res4.phase}`); check('4-player: winner index is 0-3', res4.winner >= 0 && res4.winner < 4, `winner=${res4.winner}`); const res2 = runGame(2); check('2-player: game ends with gameover', res2.phase === 'gameover', `phase=${res2.phase}`); check('2-player: winner index is 0-1', res2.winner >= 0 && res2.winner < 2, `winner=${res2.winner}`); const res3 = runGame(3); check('3-player: game ends with gameover', res3.phase === 'gameover', `phase=${res3.phase}`); check('3-player: winner index is 0-2', res3.winner >= 0 && res3.winner < 3, `winner=${res3.winner}`); // ── Summary ──────────────────────────────────────────────────────────────────── console.log(`\n── ${failures === 0 ? 'All tests passed' : `${failures} test(s) FAILED`} ──\n`); if (failures > 0) process.exit(1);