237 lines
11 KiB
JavaScript
237 lines
11 KiB
JavaScript
// 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);
|