262 lines
11 KiB
JavaScript
262 lines
11 KiB
JavaScript
// 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 '../../public/src/games/ginrummy/GinRummyData.js';
|
|
import { GinRummyLogic } from '../../public/src/games/ginrummy/GinRummyLogic.js';
|
|
import {
|
|
chooseDrawSource, chooseDiscard, shouldKnock, findLayoffs,
|
|
} from '../../public/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);
|