fertig-classic-games/server/scripts/verifyGinRummy.js

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