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.
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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: 3–4 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 (2–4).
|
||||
* 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 },
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +78,7 @@ import CanastaGame from './games/canasta/CanastaGame.js';
|
|||
import DotLinkGame from './games/dotlink/DotLinkGame.js';
|
||||
import Game2048 from './games/2048/2048Game.js';
|
||||
import RummikubGame from './games/rummikub/RummikubGame.js';
|
||||
import GinRummyGame from './games/ginrummy/GinRummyGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -169,6 +170,7 @@ const config = {
|
|||
DotLinkGame,
|
||||
Game2048,
|
||||
RummikubGame,
|
||||
GinRummyGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
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]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -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: '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: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 69 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||