feat: add Gin Rummy card game with AI opponents

Implement a fully playable Gin Rummy game with Phaser-based UI,
pure logic engine, and heuristic AI (skill levels 1-5).

Features:
- Complete Gin Rummy rules: draw, discard, knock, gin, layoff, scoring
- Meld detection with optimal deadwood minimization
- 2-4 player support with AI opponents
- Drag-to-reorder hand, visual meld highlights, turn indicators
- Round and game-over overlays with score tracking
- Headless verification script with unit tests and self-play simulation

Files added:
- public/src/games/ginrummy/GinRummyData.js (card helpers, layout, constants)
- public/src/games/ginrummy/GinRummyLogic.js (deterministic game engine)
- public/src/games/ginrummy/GinRummyAI.js (AI decision making)
- public/src/games/ginrummy/GinRummyGame.js (Phaser scene)
- server/scripts/verifyGinRummy.js (headless test harness)

Also updates game-icons/opponents assets and registers the game
in the server registry and game room scene dispatcher.
This commit is contained in:
Brian Fertig 2026-06-14 20:51:15 -06:00
parent 6927b148f2
commit 8a79b6909d
18 changed files with 2009 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,131 @@
// Gin Rummy — heuristic AI (skill 1-5). No Phaser, no state mutation.
import {
bestMeldGroups,
allCandidateMelds,
canLayoff,
ginDeadwoodValue,
ginRunRank,
MAX_DEADWOOD_TO_KNOCK,
} from './GinRummyData.js';
const clampSkill = (s) => Math.max(1, Math.min(5, s | 0)) || 3;
export function thinkDelay(skill) {
const base = [1100, 950, 820, 680, 540][clampSkill(skill) - 1];
return base + Math.random() * 400;
}
/**
* Decide whether to draw from stock or take the discard.
* @returns {'stock'|'discard'}
*/
export function chooseDrawSource(hand, discardCard, skill = 3) {
const sk = clampSkill(skill);
if (!discardCard) return 'stock';
if (sk <= 2) return Math.random() < 0.25 ? 'discard' : 'stock';
// Would taking the discard improve our best deadwood?
const { deadwood: currentDW } = bestMeldGroups(hand);
const { deadwood: withDW } = bestMeldGroups([...hand, discardCard]);
// Only take discard if it reduces deadwood (or completes a meld)
const improvement = currentDW - withDW;
const threshold = sk >= 4 ? 1 : 5; // skilled AI is more willing to take
return improvement >= threshold ? 'discard' : 'stock';
}
/**
* Choose which card to discard after drawing.
* @returns {Card} card to discard
*/
export function chooseDiscard(hand, skill = 3) {
const sk = clampSkill(skill);
const noise = (5 - sk) * 2.5;
let bestCard = null;
let bestVal = -Infinity;
for (const candidate of hand) {
const rest = hand.filter(c => c.key !== candidate.key);
const { deadwood: dw } = bestMeldGroups(rest);
// Lower deadwood after removal = better discard
let val = -dw;
val += (Math.random() * 2 - 1) * noise;
if (val > bestVal) { bestVal = val; bestCard = candidate; }
}
return bestCard;
}
/**
* Decide whether to knock given current hand.
* @returns {boolean}
*/
export function shouldKnock(hand, skill = 3) {
const sk = clampSkill(skill);
const { deadwood: dw } = bestMeldGroups(hand);
if (dw > MAX_DEADWOOD_TO_KNOCK) return false;
if (dw === 0) return true; // always gin
// Skill-based knock threshold
const thresholds = [10, 8, 6, 4, 2]; // skill 1..5: knock at or below this deadwood
return dw <= thresholds[sk - 1];
}
/**
* Find all valid layoffs an opponent can make on the knocker's melds.
* @param {Card[]} hand - opponent's hand
* @param {Card[][]} knockerMelds - knocker's declared meld groups
* @returns {{ cardKey: string, meldIdx: number }[]}
*/
export function findLayoffs(hand, knockerMelds, skill = 3) {
const results = [];
const usedKeys = new Set();
for (const card of hand) {
if (usedKeys.has(card.key)) continue;
for (let mi = 0; mi < knockerMelds.length; mi++) {
// Simulate the meld after previous layoffs
const meld = [...knockerMelds[mi]];
for (const r of results.filter(r => r.meldIdx === mi)) {
// add layoff card to simulated meld
const lc = hand.find(c => c.key === r.cardKey);
if (lc) meld.push(lc);
}
if (canLayoff(card, meld)) {
results.push({ cardKey: card.key, meldIdx: mi });
usedKeys.add(card.key);
break;
}
}
}
return results;
}
/**
* Full AI turn: returns the sequence of actions to take.
* @returns {{ drawSource: 'stock'|'discard', discardKey: string, knock: boolean, gin: boolean, meldGroups: Card[][] }}
*/
export function planTurn(hand, discardCard, skill = 3) {
const drawSource = chooseDrawSource(hand, discardCard, skill);
// Simulate drawing
const handAfterDraw = drawSource === 'discard' && discardCard
? [...hand, discardCard]
: [...hand, null]; // null = placeholder for stock card (unknown)
// We can't know stock card, so just plan based on current + discard scenario
const { melds, deadwood } = bestMeldGroups(handAfterDraw.filter(Boolean));
const knock = shouldKnock(handAfterDraw.filter(Boolean), skill);
const gin = deadwood === 0;
const discardCard2 = chooseDiscard(handAfterDraw.filter(Boolean), skill);
return {
drawSource,
discardKey: discardCard2?.key ?? null,
knock: knock && !gin,
gin,
meldGroups: melds,
};
}

View File

@ -0,0 +1,223 @@
// Gin Rummy — static data, card helpers, layout geometry. No Phaser, no state.
// Imported by the logic engine, AI, Phaser scene and headless verify harness.
import { Card, Deck, SUITS, RANKS } from '../cards/Deck.js';
export { Card, Deck, SUITS, RANKS };
export const ICON_FRAME = 69;
export const WIN_SCORE = 100;
export const HAND_SIZE = 10;
export const MAX_DEADWOOD_TO_KNOCK = 10;
export const GIN_BONUS = 25;
export const UNDERCUT_BONUS = 10;
// ── Card value helpers ──────────────────────────────────────────────────────
/** Deadwood point value for a card in Gin Rummy: A=1, 2-9=pip, T/J/Q/K=10. */
export function ginDeadwoodValue(card) {
if (card.rank === 'A') return 1;
return Math.min(10, card.value); // card.value is 2-14, so A=14 → ignored above
}
/** Run-order rank for Gin Rummy: A=1 (low only), 2=2 … K=13. */
export function ginRunRank(card) {
return card.rank === 'A' ? 1 : card.value; // card.value: T=10,J=11,Q=12,K=13,A=14
}
// ── Meld detection ──────────────────────────────────────────────────────────
/** All valid melds (length ≥ 3) that can be formed from a subset of hand. */
export function allCandidateMelds(hand) {
const melds = [];
// Sets: 34 cards of same rank
const byRank = {};
for (const c of hand) {
if (!byRank[c.rank]) byRank[c.rank] = [];
byRank[c.rank].push(c);
}
for (const cards of Object.values(byRank)) {
if (cards.length < 3) continue;
// All 3-card combinations
for (let i = 0; i < cards.length - 2; i++)
for (let j = i + 1; j < cards.length - 1; j++)
for (let k = j + 1; k < cards.length; k++)
melds.push([cards[i], cards[j], cards[k]]);
// 4-card set
if (cards.length === 4) melds.push([...cards]);
}
// Runs: 3+ consecutive ranks, same suit
const bySuit = {};
for (const c of hand) {
if (!bySuit[c.suit]) bySuit[c.suit] = [];
bySuit[c.suit].push(c);
}
for (const cards of Object.values(bySuit)) {
const sorted = cards.slice().sort((a, b) => ginRunRank(a) - ginRunRank(b));
for (let start = 0; start < sorted.length; start++) {
for (let end = start + 2; end < sorted.length; end++) {
if (ginRunRank(sorted[end]) !== ginRunRank(sorted[end - 1]) + 1) break;
melds.push(sorted.slice(start, end + 1));
}
}
}
return melds;
}
/**
* Find the meld grouping that minimises deadwood.
* @returns {{ melds: Card[][], deadwood: number }}
*/
export function bestMeldGroups(hand) {
const possible = allCandidateMelds(hand).sort((a, b) => b.length - a.length);
const totalDW = hand.reduce((s, c) => s + ginDeadwoodValue(c), 0);
let bestDeadwood = totalDW;
let bestMelds = [];
function bt(meldIdx, usedKeys, chosenMelds) {
const dw = hand.filter(c => !usedKeys.has(c.key)).reduce((s, c) => s + ginDeadwoodValue(c), 0);
if (dw < bestDeadwood) {
bestDeadwood = dw;
bestMelds = chosenMelds.map(m => [...m]);
}
if (bestDeadwood === 0) return;
for (let i = meldIdx; i < possible.length; i++) {
const m = possible[i];
if (!m.every(c => !usedKeys.has(c.key))) continue;
const next = new Set([...usedKeys, ...m.map(c => c.key)]);
bt(i + 1, next, [...chosenMelds, m]);
}
}
bt(0, new Set(), []);
return { melds: bestMelds, deadwood: bestDeadwood };
}
/** Deadwood total given a hand and declared meld groups. */
export function deadwoodTotal(hand, melds) {
const melded = new Set(melds.flat().map(c => c.key));
return hand.filter(c => !melded.has(c.key)).reduce((s, c) => s + ginDeadwoodValue(c), 0);
}
/** True if card can be legally laid off onto an existing meld. */
export function canLayoff(card, meld) {
if (!meld || meld.length === 0) return false;
const isSet = meld.every(c => c.rank === meld[0].rank);
if (isSet) {
return meld.length < 4
&& card.rank === meld[0].rank
&& !meld.some(c => c.suit === card.suit);
}
// Run
const sorted = meld.slice().sort((a, b) => ginRunRank(a) - ginRunRank(b));
if (card.suit !== sorted[0].suit) return false;
const minR = ginRunRank(sorted[0]);
const maxR = ginRunRank(sorted[sorted.length - 1]);
return ginRunRank(card) === minR - 1 || ginRunRank(card) === maxR + 1;
}
// ── Sorting helpers ─────────────────────────────────────────────────────────
/** Sort by suit order (s,h,d,c) then by run-rank ascending. */
export function sortBySuit(hand) {
const SUIT_ORDER = { s: 0, h: 1, d: 2, c: 3 };
return hand.slice().sort((a, b) =>
(SUIT_ORDER[a.suit] - SUIT_ORDER[b.suit]) || (ginRunRank(a) - ginRunRank(b))
);
}
/** Sort by run-rank ascending then by suit. */
export function sortByRank(hand) {
const SUIT_ORDER = { s: 0, h: 1, d: 2, c: 3 };
return hand.slice().sort((a, b) =>
(ginRunRank(a) - ginRunRank(b)) || (SUIT_ORDER[a.suit] - SUIT_ORDER[b.suit])
);
}
// ── Theme ───────────────────────────────────────────────────────────────────
export const THEME = {
feltTop: 0x1a2d1a,
feltBottom: 0x0d1a0d,
tableRail: 0x2d1f10,
railEdge: 0x1a1208,
cardFace: 0xfdf8ee,
cardBack: 0x3a1a6e,
cardBackHi: 0x5a2e9e,
gold: 0xd4a017,
goldHex: '#d4a017',
ivory: 0xf2ead8,
ivoryHex: '#f2ead8',
meldGlow: 0x22cc66,
knockGlow: 0xe8a020,
discardHi: 0x5588ff,
stockHi: 0x44aa66,
textHex: '#f2ead8',
mutedHex: '#9e9080',
};
// ── Layout ──────────────────────────────────────────────────────────────────
export const CARD_W = 80;
export const CARD_H = 112;
export const CARD_R = 8;
export const HAND_SPREAD = 88; // px between card centres in human hand
export const AI_SPREAD = 28; // compact fan for face-down AI hands
// Canvas dimensions
const GW = 1920;
const GH = 1080;
/**
* Per-seat display info for nPlayers (24).
* seat 0 = human (bottom), others = AI.
* Returns array of { x, y, axis:'h'|'v', nameX, nameY, nameAnchor:[ox,oy] }
*/
export function seatPositions(nPlayers) {
// Portrait layout constants (must match buildPortraits in GinRummyGame.js):
// R=36, gap=16, n=10 cards at AI_SPREAD=28 → spread half = 126
// horizontal portrait x offset from seat centre = -(126+R+16) = -178
// vertical portrait y = seat.y - 126 - R - 16 = seat.y - 178
// name below portrait (h): py = seat.y + R + 8 = seat.y + 44
// name above portrait (v): py = portrait_y - R - 8 = (seat.y-178) - 44 = seat.y - 222
// Human seat is always bottom-centre (no portrait name used)
const human = { x: GW / 2, y: GH - 140, axis: 'h', nameX: GW / 2, nameY: GH - 56, nameAnchor: [0.5, 0.5] };
if (nPlayers === 2) {
return [
human,
// top-centre: name below portrait
{ x: GW / 2, y: 140, axis: 'h', nameX: GW / 2 - 178, nameY: 184, nameAnchor: [0.5, 0] },
];
}
if (nPlayers === 3) {
return [
human,
// top-left: name below portrait
{ x: 440, y: 140, axis: 'h', nameX: 440 - 178, nameY: 184, nameAnchor: [0.5, 0] },
// top-right: name below portrait
{ x: GW - 440, y: 140, axis: 'h', nameX: GW - 440 - 178, nameY: 184, nameAnchor: [0.5, 0] },
];
}
// 4 players
return [
human,
// left: name above portrait
{ x: 100, y: GH / 2, axis: 'v', nameX: 100, nameY: GH / 2 - 222, nameAnchor: [0.5, 1] },
// top-centre: name below portrait
{ x: GW / 2, y: 140, axis: 'h', nameX: GW / 2 - 178, nameY: 184, nameAnchor: [0.5, 0] },
// right: name above portrait
{ x: GW - 100, y: GH / 2, axis: 'v', nameX: GW - 100, nameY: GH / 2 - 222, nameAnchor: [0.5, 1] },
];
}
/** Center positions of stock and discard piles. */
export function pilePositions() {
return {
stock: { x: GW / 2 - 90, y: GH / 2 },
discard: { x: GW / 2 + 90, y: GH / 2 },
};
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,258 @@
// Gin Rummy — pure, deterministic game engine. No Phaser, no timers.
// Scene and headless harness drive it identically.
import {
Deck,
HAND_SIZE,
WIN_SCORE,
MAX_DEADWOOD_TO_KNOCK,
GIN_BONUS,
UNDERCUT_BONUS,
bestMeldGroups,
deadwoodTotal,
canLayoff,
} from './GinRummyData.js';
export class GinRummyLogic {
constructor(nPlayers = 4, rng = null) {
this.nPlayers = nPlayers;
this.rng = rng ?? Math.random;
this.players = null; // [{ hand, score }]
this.stock = [];
this.discard = [];
this.currentPlayer = 0;
this.phase = 'draw'; // 'draw' | 'discard' | 'gameover'
this.knocker = null;
this.knockerMelds = [];
this.layoffPlayer = -1; // seat currently doing layoff (-1 = not in layoff)
this.roundScores = []; // delta per player for the current round
this.round = 1;
this.firstPlayer = 0; // who goes first each round (rotates)
}
// ── Round setup ────────────────────────────────────────────────────────────
newGame() {
this.players = Array.from({ length: this.nPlayers }, () => ({ hand: [], score: 0 }));
this.round = 1;
this.firstPlayer = 0;
this.phase = 'draw';
this._dealRound();
}
newRound() {
this.round++;
this.firstPlayer = (this.firstPlayer + 1) % this.nPlayers;
this._dealRound();
}
_dealRound() {
const deck = new Deck();
// Fisher-Yates using injected rng
const cards = deck.cards;
for (let i = cards.length - 1; i > 0; i--) {
const j = Math.floor(this.rng() * (i + 1));
[cards[i], cards[j]] = [cards[j], cards[i]];
}
for (const p of this.players) p.hand = [];
// Deal HAND_SIZE cards to each player (rotating from firstPlayer)
let idx = 0;
for (let c = 0; c < HAND_SIZE; c++) {
for (let s = 0; s < this.nPlayers; s++) {
const seat = (this.firstPlayer + s) % this.nPlayers;
this.players[seat].hand.push(cards[idx++]);
}
}
this.stock = cards.slice(idx + 1); // remaining stock after top card to discard
this.discard = [cards[idx]]; // initial discard card
this.currentPlayer = this.firstPlayer;
this.phase = 'draw';
this.knocker = null;
this.knockerMelds = [];
this.layoffPlayer = -1;
this.roundScores = new Array(this.nPlayers).fill(0);
}
// ── Draw ───────────────────────────────────────────────────────────────────
drawStock(seat) {
if (this.phase !== 'draw' || this.currentPlayer !== seat) return false;
if (this.stock.length === 0) {
// Reshuffle discard pile (minus the top card) back into stock
if (this.discard.length <= 1) {
// True impasse — end round with no scoring (rare)
this.phase = 'roundover';
this.winner = null;
return true;
}
const top = this.discard.pop();
this.stock = this.discard.reverse();
for (let i = this.stock.length - 1; i > 0; i--) {
const j = Math.floor(this.rng() * (i + 1));
[this.stock[i], this.stock[j]] = [this.stock[j], this.stock[i]];
}
this.discard = [top];
}
this.players[seat].hand.push(this.stock.shift());
this.phase = 'discard';
return true;
}
drawDiscard(seat) {
if (this.phase !== 'draw' || this.currentPlayer !== seat) return false;
if (this.discard.length === 0) return false;
this.players[seat].hand.push(this.discard.pop());
this.phase = 'discard';
return true;
}
// ── Discard ────────────────────────────────────────────────────────────────
discardCard(seat, cardKey) {
if (this.phase !== 'discard' || this.currentPlayer !== seat) return false;
const idx = this.players[seat].hand.findIndex(c => c.key === cardKey);
if (idx === -1) return false;
const [card] = this.players[seat].hand.splice(idx, 1);
this.discard.push(card);
this.currentPlayer = (this.currentPlayer + 1) % this.nPlayers;
this.phase = 'draw';
return true;
}
// ── Knock / Gin ────────────────────────────────────────────────────────────
// Both methods discard the given card internally (hand goes 11→10), then
// validate the remaining 10-card hand before committing.
/**
* Knock: discard `discardKey`, then declare melds on remaining 10 cards.
* Validates deadwood MAX_DEADWOOD_TO_KNOCK. Returns false if illegal.
*/
knock(seat, discardKey, meldGroups) {
if (this.phase !== 'discard' || this.currentPlayer !== seat) return false;
const idx = this.players[seat].hand.findIndex(c => c.key === discardKey);
if (idx === -1) return false;
const [discarded] = this.players[seat].hand.splice(idx, 1);
const dw = deadwoodTotal(this.players[seat].hand, meldGroups);
if (dw > MAX_DEADWOOD_TO_KNOCK) {
this.players[seat].hand.splice(idx, 0, discarded); // rollback
return false;
}
this.discard.push(discarded);
this.knocker = seat;
this.knockerMelds = meldGroups.map(m => [...m]);
this.phase = 'layoff';
this.layoffPlayer = (seat + 1) % this.nPlayers;
return true;
}
/** Gin: discard `discardKey`, validate 0 deadwood, trigger immediate scoring. */
gin(seat, discardKey, meldGroups) {
if (this.phase !== 'discard' || this.currentPlayer !== seat) return false;
const idx = this.players[seat].hand.findIndex(c => c.key === discardKey);
if (idx === -1) return false;
const [discarded] = this.players[seat].hand.splice(idx, 1);
const dw = deadwoodTotal(this.players[seat].hand, meldGroups);
if (dw !== 0) {
this.players[seat].hand.splice(idx, 0, discarded); // rollback
return false;
}
this.discard.push(discarded);
this.knocker = seat;
this.knockerMelds = meldGroups.map(m => [...m]);
this.phase = 'roundover';
this._computeRoundScores(true);
return true;
}
// ── Layoff ─────────────────────────────────────────────────────────────────
/**
* Lay off cards from `seat` onto knocker's melds.
* layoffs: [{ cardKey, meldIdx }]
* Returns false if any layoff is illegal.
*/
layoff(seat, layoffs) {
if (this.phase !== 'layoff' || seat !== this.layoffPlayer) return false;
const player = this.players[seat];
// Validate and apply each layoff in sequence
for (const { cardKey, meldIdx } of layoffs) {
const cardIdx = player.hand.findIndex(c => c.key === cardKey);
if (cardIdx === -1) return false;
const meld = this.knockerMelds[meldIdx];
if (!meld || !canLayoff(player.hand[cardIdx], meld)) return false;
meld.push(player.hand[cardIdx]);
player.hand.splice(cardIdx, 1);
}
return true;
}
/**
* Pass layoff for `seat` (or explicitly end their layoff turn).
* Advances to next opponent, or triggers scoring when all done.
*/
passLayoff(seat) {
if (this.phase !== 'layoff' || seat !== this.layoffPlayer) return false;
const next = (seat + 1) % this.nPlayers;
if (next === this.knocker) {
// All opponents have had their turn
this.phase = 'roundover';
this._computeRoundScores(false);
} else {
this.layoffPlayer = next;
}
return true;
}
// ── Scoring ────────────────────────────────────────────────────────────────
_computeRoundScores(isGin) {
const k = this.knocker;
const { melds: kMelds, deadwood: kDW } = bestMeldGroups(this.players[k].hand);
// Use declared melds if they're better (they should be equal or declared was manual)
const knockerDW = Math.min(kDW, deadwoodTotal(this.players[k].hand, this.knockerMelds));
for (let s = 0; s < this.nPlayers; s++) {
if (s === k) continue;
const { deadwood: oppDW } = bestMeldGroups(this.players[s].hand);
if (isGin) {
// Gin: knocker earns oppDW + GIN_BONUS, no undercut possible
this.roundScores[k] += oppDW + GIN_BONUS;
} else if (knockerDW < oppDW) {
// Normal knock win
this.roundScores[k] += oppDW - knockerDW;
} else {
// Undercut or tie: opponent wins
this.roundScores[s] += knockerDW - oppDW + UNDERCUT_BONUS;
}
}
// Apply to cumulative scores
for (let s = 0; s < this.nPlayers; s++) {
this.players[s].score += this.roundScores[s];
}
// Check win
const winner = this.players.findIndex(p => p.score >= WIN_SCORE);
this.phase = winner >= 0 ? 'gameover' : 'roundover';
this.winner = winner >= 0 ? winner : null;
}
// ── Helpers ────────────────────────────────────────────────────────────────
get discardTop() {
return this.discard.length > 0 ? this.discard[this.discard.length - 1] : null;
}
get stockCount() {
return this.stock.length;
}
/** Compute best meld grouping for a seat (for AI and UI hints). */
bestMelds(seat) {
return bestMeldGroups(this.players[seat].hand);
}
}

View File

@ -78,6 +78,7 @@ import CanastaGame from './games/canasta/CanastaGame.js';
import DotLinkGame from './games/dotlink/DotLinkGame.js'; import DotLinkGame from './games/dotlink/DotLinkGame.js';
import Game2048 from './games/2048/2048Game.js'; import Game2048 from './games/2048/2048Game.js';
import RummikubGame from './games/rummikub/RummikubGame.js'; import RummikubGame from './games/rummikub/RummikubGame.js';
import GinRummyGame from './games/ginrummy/GinRummyGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -169,6 +170,7 @@ const config = {
DotLinkGame, DotLinkGame,
Game2048, Game2048,
RummikubGame, RummikubGame,
GinRummyGame,
], ],
}; };

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame' }; const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -93,3 +93,4 @@ registerGame({ slug: 'canasta', name: 'Canasta', category: 'cards', cardGame: tr
registerGame({ slug: 'dotlink', name: 'Dot Link', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 66 }); registerGame({ slug: 'dotlink', name: 'Dot Link', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 66 });
registerGame({ slug: '2048', name: '2048', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 67 }); registerGame({ slug: '2048', name: '2048', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 67 });
registerGame({ slug: 'rummikub', name: 'Rummikub', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 68 }); registerGame({ slug: 'rummikub', name: 'Rummikub', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 68 });
registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 69 });

View File

@ -0,0 +1,261 @@
// 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);