// Headless verification for Canasta. // node server/scripts/verifyCanasta.js [--games=N] // Exits non-zero on any failure. // // 1. Fixture tests: card values, canasta/red-three/go-out scoring, the initial // meld minimum, freeze behaviour and discard-pile take legality. // 2. Self-play: full partnership games driven by the heuristic AI in all four // seats, asserting invariants (no exceptions, hands have legal sizes, melds // are well-formed, the match terminates with a winner) over many seeded games. import { Card, cardScore, minimumMeld, NATURAL_CANASTA, MIXED_CANASTA, ALL_RED_THREES, GO_OUT, CONCEALED_GO_OUT, TEAM_OF_SEAT, PLAYER_COUNT, isWild, isRedThree, } from '../../public/src/games/canasta/CanastaData.js'; import { createInitialState, drawStock, takeDiscard, meld, discard, startNextHand, takePlan, scoreTeamHand, teamMeld, isCanasta, meldNaturals, meldWilds, } from '../../public/src/games/canasta/CanastaLogic.js'; import { chooseDraw, chooseMelds, chooseDiscard } from '../../public/src/games/canasta/CanastaAI.js'; let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); return; } failures++; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); } // Base ids well above the 0..107 the engine deck uses, so fixture cards injected // into a real state never collide with dealt cards. let _id = 100000; const C = (rank, suit) => { const c = new Card(rank, suit); c.id = _id++; return c; }; const JK = () => C('JK', 'r'); // Minimal state for scoring fixtures. function mkState() { const players = []; for (let i = 0; i < PLAYER_COUNT; i++) players.push({ seat: i, team: TEAM_OF_SEAT[i], hand: [] }); const teams = [0, 1].map(() => ({ melds: [], redThrees: [], hasMelded: false, score: 0 })); return { players, teams, turnTeamMeldedAtStart: false }; } // ── 1. Card values ───────────────────────────────────────────────────────────── console.log('Card values:'); check('joker = 50', cardScore(C('JK', 'r')) === 50); check('two = 20', cardScore(C('2', 's')) === 20); check('ace = 20', cardScore(C('A', 'h')) === 20); check('king = 10', cardScore(C('K', 'c')) === 10); check('eight = 10', cardScore(C('8', 'd')) === 10); check('seven = 5', cardScore(C('7', 's')) === 5); check('four = 5', cardScore(C('4', 'c')) === 5); // ── 2. Canasta + red-three + go-out scoring ───────────────────────────────────── console.log('Scoring fixtures:'); { const s = mkState(); s.teams[0].hasMelded = true; // Natural canasta of seven kings: 70 cards + 500 bonus = 570. s.teams[0].melds = [{ rank: 'K', cards: ['K','K','K','K','K','K','K'].map((r) => C(r, 's')) }]; const d = scoreTeamHand(s, 0, { outPlayer: null }); check('natural canasta cards = 70', d.meldPoints === 70, `got ${d.meldPoints}`); check('natural canasta bonus = 500', d.canastaBonus === NATURAL_CANASTA, `got ${d.canastaBonus}`); check('one natural canasta counted', d.naturalCanastas === 1); } { const s = mkState(); s.teams[0].hasMelded = true; // Mixed canasta: five sixes + two wilds. s.teams[0].melds = [{ rank: '6', cards: [C('6','s'),C('6','d'),C('6','c'),C('6','h'),C('6','s'), JK(), C('2','c')] }]; const d = scoreTeamHand(s, 0, { outPlayer: null }); check('mixed canasta bonus = 300', d.canastaBonus === MIXED_CANASTA, `got ${d.canastaBonus}`); check('mixed canasta counted', d.mixedCanastas === 1 && d.naturalCanastas === 0); } { const s = mkState(); s.teams[1].hasMelded = true; s.teams[1].redThrees = [C('3','h'), C('3','d'), C('3','h'), C('3','d')]; const d = scoreTeamHand(s, 1, { outPlayer: null }); check('all four red threes = 800', d.redThreeBonus === ALL_RED_THREES, `got ${d.redThreeBonus}`); } { const s = mkState(); // team did NOT meld → red threes go negative s.teams[0].hasMelded = false; s.teams[0].redThrees = [C('3','h'), C('3','d')]; const d = scoreTeamHand(s, 0, { outPlayer: null }); check('unmelded red threes are negative', d.redThreeBonus === -200, `got ${d.redThreeBonus}`); } { const s = mkState(); s.teams[0].hasMelded = true; s.turnTeamMeldedAtStart = true; const normal = scoreTeamHand(s, 0, { outPlayer: 0 }); check('go-out bonus = 100', normal.goOut === GO_OUT, `got ${normal.goOut}`); s.turnTeamMeldedAtStart = false; const concealed = scoreTeamHand(s, 0, { outPlayer: 2 }); check('concealed go-out = 200', concealed.goOut === CONCEALED_GO_OUT, `got ${concealed.goOut}`); } { const s = mkState(); s.teams[0].hasMelded = true; s.teams[0].melds = [{ rank: '5', cards: [C('5','s'),C('5','d'),C('5','c')] }]; // 15 pts s.players[0].hand = [C('K','s'), C('A','h')]; // 30 pts left in hand const d = scoreTeamHand(s, 0, { outPlayer: null }); check('hand cards deducted', d.handPenalty === 30 && d.total === 15 - 30, `got total ${d.total}`); } // ── 3. Minimum meld thresholds ────────────────────────────────────────────────── console.log('Minimum meld:'); check('negative score → 15', minimumMeld(-50) === 15); check('0 → 50', minimumMeld(0) === 50); check('1495 → 50', minimumMeld(1495) === 50); check('1500 → 90', minimumMeld(1500) === 90); check('3000 → 120', minimumMeld(3000) === 120); // ── 4. Take-pile legality ─────────────────────────────────────────────────────── console.log('Take-pile legality:'); { const s = createInitialState({ seed: 7 }); const seat = s.currentPlayer; // Force a known top card and a matching natural pair in hand. s.discard = [C('9','s')]; s.frozen = false; s.players[seat].hand = [C('9','d'), C('9','c'), C('K','h'), C('4','s')]; const plan = takePlan(s, seat); check('two naturals can take an unfrozen pile', !!plan && plan.naturalIds.length === 2); } { const s = createInitialState({ seed: 8 }); const seat = s.currentPlayer; s.discard = [C('9','s')]; s.frozen = true; s.players[seat].hand = [C('9','d'), JK(), C('K','h')]; // one natural + wild, but frozen check('frozen pile needs two naturals (one+wild fails)', takePlan(s, seat) === null); } { const s = createInitialState({ seed: 9 }); const seat = s.currentPlayer; s.discard = [JK()]; // wild on top can never be captured s.frozen = true; s.players[seat].hand = [JK(), C('2','c'), C('K','h')]; check('cannot take a pile topped by a wild', takePlan(s, seat) === null); } { const s = createInitialState({ seed: 10 }); const seat = s.currentPlayer; const t = s.teams[TEAM_OF_SEAT[seat]]; t.hasMelded = true; t.melds = [{ rank: '9', cards: [C('9','s'),C('9','d'),C('9','c')] }]; s.discard = [C('9','h')]; s.frozen = false; s.players[seat].hand = [C('9','c'), C('K','h')]; // one natural + existing meld const plan = takePlan(s, seat); check('one natural takes via existing meld when unfrozen', !!plan); } // ── 5. Freeze on wild discard (full turn through the engine) ───────────────────── console.log('Freeze behaviour:'); { let s = createInitialState({ seed: 3 }); const seat = s.currentPlayer; s = drawStock(s); // Give the player a wild to discard. s.players[seat].hand.push(C('2', 's')); const wild = s.players[seat].hand[s.players[seat].hand.length - 1]; s = discard(s, wild.id); check('discarding a wild freezes the pile', s.frozen === true); } // ── 6. Self-play ──────────────────────────────────────────────────────────────── const games = Number((process.argv.find((a) => a.startsWith('--games=')) || '').split('=')[1]) || 300; console.log(`Self-play (${games} games):`); function meldWellFormed(m) { const n = meldNaturals(m), w = meldWilds(m); return n >= 2 && w <= 3 && w <= n; } function aiTurn(s) { const seat = s.currentPlayer; const skill = 3 + (seat % 2); // alternate 3 / 4 const draw = chooseDraw(s, seat, skill); s = draw.type === 'take' ? takeDiscard(s, draw.plan) : drawStock(s); if (s.phase === 'handOver' || s.phase === 'gameOver') return s; // stock-out during draw if (s.phase === 'draw') s = drawStock(s); // take was rejected → fall back to stock if (s.phase === 'handOver' || s.phase === 'gameOver') return s; const { actions } = chooseMelds(s, seat, skill); for (const a of actions) s = meld(s, a.rank, a.cardIds); if (s.phase === 'meld') { const cardId = chooseDiscard(s, seat, skill); s = discard(s, cardId); } return s; } let wins = [0, 0], exceptions = 0, maxHands = 0, malformed = 0, badHandSize = 0; let meldedGames = 0, draws = 0; for (let g = 1; g <= games; g++) { try { let s = createInitialState({ seed: g * 2654435761 }); let turns = 0, hands = 0, sawMeld = false; while (s.phase !== 'gameOver') { if (s.phase === 'handOver') { if (++hands > 400) throw new Error('match did not terminate'); s = startNextHand(s); continue; } if (++turns > 100000) throw new Error('turn loop did not terminate'); const before = s.currentPlayer; s = aiTurn(s); // Validate any melds present. for (const t of s.teams) for (const m of t.melds) { if (m.cards.length >= 3 && !meldWellFormed(m)) malformed++; if (t.melds.length) sawMeld = true; } // Hand sizes never negative / absurd. for (const p of s.players) if (p.hand.length < 0 || p.hand.length > 40) badHandSize++; if (s.currentPlayer === before && s.phase === 'draw') throw new Error('turn failed to advance'); } if (sawMeld) meldedGames++; maxHands = Math.max(maxHands, hands); if (s.winnerTeam === null) draws++; else wins[s.winnerTeam]++; } catch (e) { exceptions++; if (exceptions <= 5) console.error(` game ${g}: ${e.message}`); } } check('no exceptions during self-play', exceptions === 0, `${exceptions} games threw`); check('all melds well-formed', malformed === 0, `${malformed} malformed`); check('hand sizes stay sane', badHandSize === 0, `${badHandSize} bad`); check('teams meld in most games', meldedGames >= games * 0.9, `${meldedGames}/${games}`); check('both teams win some games', wins[0] > 0 && wins[1] > 0, `wins ${wins[0]}/${wins[1]}, draws ${draws}`); console.log(` results: team0 ${wins[0]} / team1 ${wins[1]} / draws ${draws}, longest match ${maxHands} hands`); console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed.'); process.exit(failures ? 1 : 0);