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

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