diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index e897ea0..fc1f620 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 9a5136d..e9dc9e5 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/assets/images/opponents.png b/public/assets/images/opponents.png index c4f6a90..c32f9d4 100644 Binary files a/public/assets/images/opponents.png and b/public/assets/images/opponents.png differ diff --git a/public/assets/images/opponents.psd b/public/assets/images/opponents.psd index 12a5c1c..d4d4b06 100644 Binary files a/public/assets/images/opponents.psd and b/public/assets/images/opponents.psd differ diff --git a/public/assets/images/originals/dv-8-2303.png b/public/assets/images/originals/dv-8-2303.png new file mode 100644 index 0000000..f0e8087 Binary files /dev/null and b/public/assets/images/originals/dv-8-2303.png differ diff --git a/public/assets/images/originals/kage.png b/public/assets/images/originals/kage.png new file mode 100644 index 0000000..538a36d Binary files /dev/null and b/public/assets/images/originals/kage.png differ diff --git a/public/assets/images/originals/nicole.png b/public/assets/images/originals/nicole.png new file mode 100644 index 0000000..81f468c Binary files /dev/null and b/public/assets/images/originals/nicole.png differ diff --git a/public/assets/videos/dv-8-2303-happy.mp4 b/public/assets/videos/dv-8-2303-happy.mp4 new file mode 100644 index 0000000..42f79b2 Binary files /dev/null and b/public/assets/videos/dv-8-2303-happy.mp4 differ diff --git a/public/assets/videos/dv-8-2303-idle.mp4 b/public/assets/videos/dv-8-2303-idle.mp4 new file mode 100644 index 0000000..a11bd25 Binary files /dev/null and b/public/assets/videos/dv-8-2303-idle.mp4 differ diff --git a/public/assets/videos/dv-8-2303-upset.mp4 b/public/assets/videos/dv-8-2303-upset.mp4 new file mode 100644 index 0000000..f58c265 Binary files /dev/null and b/public/assets/videos/dv-8-2303-upset.mp4 differ diff --git a/public/src/games/ginrummy/GinRummyAI.js b/public/src/games/ginrummy/GinRummyAI.js new file mode 100644 index 0000000..6a7a726 --- /dev/null +++ b/public/src/games/ginrummy/GinRummyAI.js @@ -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, + }; +} diff --git a/public/src/games/ginrummy/GinRummyData.js b/public/src/games/ginrummy/GinRummyData.js new file mode 100644 index 0000000..67a8b5a --- /dev/null +++ b/public/src/games/ginrummy/GinRummyData.js @@ -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 }, + }; +} diff --git a/public/src/games/ginrummy/GinRummyGame.js b/public/src/games/ginrummy/GinRummyGame.js new file mode 100644 index 0000000..ade70d2 --- /dev/null +++ b/public/src/games/ginrummy/GinRummyGame.js @@ -0,0 +1,1132 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js'; +import { + THEME, CARD_W, CARD_H, CARD_R, HAND_SPREAD, AI_SPREAD, + WIN_SCORE, MAX_DEADWOOD_TO_KNOCK, + seatPositions, pilePositions, + sortBySuit, sortByRank, + allCandidateMelds, bestMeldGroups, canLayoff, ginDeadwoodValue, +} from './GinRummyData.js'; +import { GinRummyLogic } from './GinRummyLogic.js'; +import { + chooseDrawSource, chooseDiscard, shouldKnock, findLayoffs, thinkDelay, +} from './GinRummyAI.js'; + +const CX = GAME_WIDTH / 2; +const CY = GAME_HEIGHT / 2; +const PLAYER_HAND_Y = 920; + +const D = { + felt: -2, rail: -1, pile: 2, card: 10, glow: 9, cardText: 11, + ui: 30, toast: 50, overlay: 60, overlayUI: 62, +}; + +const SUIT_RED = '#c92a2a'; +const SUIT_BLK = '#1a1208'; + +export default class GinRummyGame extends Phaser.Scene { + constructor() { super('GinRummyGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'ginrummy', name: 'Gin Rummy' }; + this.opponents = data.opponents ?? []; + this.playfield = data.playfield ?? null; + this.nPlayers = 1 + this.opponents.length; + + this.logic = new GinRummyLogic(this.nPlayers); + + this.humanCards = []; // card containers for seat 0 (hand order matches logic.players[0].hand) + this.aiCardObjs = []; // aiCardObjs[seat] = array of containers + this.pileObjs = {}; // { stock, discard } + this.revealObjs = []; // containers during knock reveal / layoff + + this.selectedCard = null; // { key, container } during discard phase + this.layoffSelected = null; // card key selected during layoff + this.humanMode = 'idle'; // 'idle'|'draw'|'discard'|'layoff' + this.busy = false; + + // Drag state (Phase 10 pattern) + this.potentialDrag = null; + this.dragState = null; + } + + create() { + try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch (_) {} + + this._seatPos = seatPositions(this.nPlayers); + this._pilePos = pilePositions(); + + this.buildBackdrop(); + this.buildScorePanel(); + this.buildPortraits(); + this.buildPiles(); + this.buildActionBar(); + this.buildSortButtons(); + this.setupDragHandlers(); + + this.logic.newGame(); + this.startRound(); + } + + // ── Backdrop ─────────────────────────────────────────────────────────────── + + buildBackdrop() { + const pf = this.playfield; + if (pf?.key && this.textures.exists(pf.key)) { + this.add.image(CX, CY, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.felt); + } else if (pf?.fallbackColor) { + const color = parseInt(pf.fallbackColor.replace('#', ''), 16); + this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, color).setDepth(D.felt); + } else { + const g = this.add.graphics().setDepth(D.felt); + g.fillGradientStyle(THEME.feltTop, THEME.feltTop, THEME.feltBottom, THEME.feltBottom, 1); + g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + } + + // Table rail border + const r = this.add.graphics().setDepth(D.rail); + r.fillStyle(THEME.tableRail, 1); + r.fillRoundedRect(30, 30, GAME_WIDTH - 60, GAME_HEIGHT - 60, 40); + r.fillStyle(THEME.feltTop, 1); + r.fillRoundedRect(50, 50, GAME_WIDTH - 100, GAME_HEIGHT - 100, 30); + + r.lineStyle(3, THEME.gold, 0.4); + r.strokeRoundedRect(50, 50, GAME_WIDTH - 100, GAME_HEIGHT - 100, 30); + + // Table title + this.add.text(CX, 30, 'GIN RUMMY', { + fontFamily: 'Righteous', fontSize: '20px', color: THEME.goldHex, + }).setOrigin(0.5, 0.5).setDepth(D.ui).setAlpha(0.7); + } + + // ── Portraits ────────────────────────────────────────────────────────────── + + buildPortraits() { + const R = 36; + const n = 10; // HAND_SIZE — used to compute spread extents for portrait placement + + this.turnRings = []; + + for (let seat = 0; seat < this.nPlayers; seat++) { + const sp = this._seatPos[seat]; + let px, py; + + if (sp.axis === 'h') { + // Horizontal spread: portrait sits to the left of the hand + const handY = seat === 0 ? PLAYER_HAND_Y : sp.y; + const spread = seat === 0 ? HAND_SPREAD : AI_SPREAD; + const leftmostX = sp.x + (0 - (n - 1) / 2) * spread; + px = leftmostX - R - 16 - (seat === 0 ? 100 : 0); + py = handY + (seat === 0 ? 50 : 0); + } else { + // Vertical spread: portrait sits above the hand + const topmostY = sp.y + (0 - (n - 1) / 2) * AI_SPREAD; + px = sp.x; + py = topmostY - R - 16; + } + + if (seat === 0) { + createPlayerPortrait(this, px, py, R, D.ui, 'GinRummyGame'); + } else { + createOpponentPortrait(this, this.opponents[seat - 1], px, py, R, D.ui); + } + + // Turn ring — bright yellow pulsing stroke, hidden until it's this seat's turn + const ring = this.add.graphics().setDepth(D.ui + 1).setAlpha(0); + ring.lineStyle(4, 0xffee00, 1); + ring.strokeCircle(px, py, R + 5); + this.turnRings.push(ring); + } + } + + setTurnRing(seat) { + this.turnRings?.forEach((ring, i) => { + if (i !== seat) { + this.tweens.killTweensOf(ring); + ring.setAlpha(0); + } + }); + const ring = this.turnRings?.[seat]; + if (!ring) return; + ring.setAlpha(1); + this.tweens.killTweensOf(ring); + this.tweens.add({ + targets: ring, + alpha: { from: 1, to: 0.25 }, + duration: 600, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut', + }); + } + + // ── Score panel ──────────────────────────────────────────────────────────── + + buildScorePanel() { + const px = GAME_WIDTH - 370, py = 120; + const panelH = 60 + this.nPlayers * 68; + const g = this.add.graphics().setDepth(D.ui - 1); + g.fillStyle(0x000000, 0.35); + g.fillRoundedRect(px - 10, py - 10, 160, panelH, 12); + g.lineStyle(1.5, THEME.gold, 0.4); + g.strokeRoundedRect(px - 10, py - 10, 160, panelH, 12); + + this.add.text(px + 70, py, 'SCORES', { + fontFamily: 'Righteous', fontSize: '16px', color: THEME.goldHex, + }).setOrigin(0.5, 0).setDepth(D.ui).setAlpha(0.8); + + this.scoreTexts = []; + const names = ['You', ...this.opponents.map(o => o.name?.split(' ')[0] ?? 'AI')]; + for (let s = 0; s < this.nPlayers; s++) { + const ry = py + 36 + s * 68; + const col = s === 0 ? '#f07a6d' : THEME.ivoryHex; + this.add.text(px, ry, names[s], { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: THEME.mutedHex, + }).setDepth(D.ui); + this.scoreTexts.push(this.add.text(px + 140, ry + 24, '0', { + fontFamily: 'Righteous', fontSize: '36px', color: col, + }).setOrigin(1, 0).setDepth(D.ui)); + } + + // Round indicator + this.roundText = this.add.text(px + 70, py + 36 + this.nPlayers * 68 + 8, 'Round 1', { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex, + }).setOrigin(0.5, 0).setDepth(D.ui); + } + + refreshScores() { + for (let s = 0; s < this.nPlayers; s++) { + this.scoreTexts[s].setText(String(this.logic.players[s].score)); + } + this.roundText.setText(`Round ${this.logic.round}`); + } + + // ── Pile placeholders ────────────────────────────────────────────────────── + + buildPiles() { + const { stock, discard } = this._pilePos; + + this._drawCardBack(stock.x, stock.y, 0.6).setDepth(D.pile - 1); + this._drawCardBack(discard.x, discard.y, 0.6).setDepth(D.pile - 1); + + this.add.text(stock.x, stock.y + CARD_H / 2 + 14, 'STOCK', { fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex }).setOrigin(0.5, 0).setDepth(D.ui); + this.add.text(discard.x, discard.y + CARD_H / 2 + 14, 'DISCARD', { fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex }).setOrigin(0.5, 0).setDepth(D.ui); + + // Stock count text + this.stockCountText = this.add.text(stock.x, stock.y - CARD_H / 2 - 14, '', { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex, + }).setOrigin(0.5, 1).setDepth(D.ui); + } + + _drawCardBack(x, y, alpha = 1) { + const g = this.add.graphics(); + g.fillStyle(THEME.cardBack, alpha); + g.fillRoundedRect(x - CARD_W / 2, y - CARD_H / 2, CARD_W, CARD_H, CARD_R); + g.lineStyle(2, THEME.cardBackHi, alpha * 0.5); + g.strokeRoundedRect(x - CARD_W / 2 + 4, y - CARD_H / 2 + 4, CARD_W - 8, CARD_H - 8, CARD_R - 2); + return g; + } + + // ── Action bar ───────────────────────────────────────────────────────────── + + buildActionBar() { + this.statusText = this.add.text(CX, 1000, '', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: THEME.textHex, + }).setOrigin(0.5).setDepth(D.ui); + + this.knockBtn = new Button(this, CX - 80, PLAYER_HAND_Y - CARD_H / 2 - 34, 'Knock', () => this.onKnock(), { + width: 130, height: 46, fontSize: 20, + }).setDepth(D.ui).setVisible(false); + + this.ginBtn = new Button(this, CX + 80, 1008, 'Gin!', () => this.onGin(), { + width: 130, height: 46, fontSize: 20, variant: 'accent', + }).setDepth(D.ui).setVisible(false); + + this.layoffDoneBtn = new Button(this, CX, 1008, 'Done Laying Off', () => this.onLayoffDone(), { + width: 200, height: 46, fontSize: 18, + }).setDepth(D.ui).setVisible(false); + + new Button(this, GAME_WIDTH - 80, 1054, 'Leave', () => this.scene.start('GameMenu'), { + variant: 'ghost', width: 130, height: 40, fontSize: 16, + }).setDepth(D.ui); + } + + // ── Sort buttons ─────────────────────────────────────────────────────────── + + buildSortButtons() { + new Button(this, 80, 1008, 'By Suit', () => this.sortHandBySuit(), { + variant: 'ghost', width: 110, height: 40, fontSize: 16, + }).setDepth(D.ui); + + new Button(this, 200, 1008, 'By Rank', () => this.sortHandByRank(), { + variant: 'ghost', width: 110, height: 40, fontSize: 16, + }).setDepth(D.ui); + } + + sortHandBySuit() { + this.logic.players[0].hand = sortBySuit(this.logic.players[0].hand); + this.renderHand(0); + this.updateActionButtons(); + } + + sortHandByRank() { + this.logic.players[0].hand = sortByRank(this.logic.players[0].hand); + this.renderHand(0); + this.updateActionButtons(); + } + + // ── Drag-to-reorder (adapted from Phase10Game.js) ───────────────────────── + + setupDragHandlers() { + this.input.on('pointermove', (pointer) => { + if (this.potentialDrag && !this.dragState) { + const dx = pointer.x - this.potentialDrag.startX; + const dy = pointer.y - this.potentialDrag.startY; + if (Math.sqrt(dx * dx + dy * dy) >= 8) { + const pd = this.potentialDrag; + this.potentialDrag = null; + this.startCardDrag(pd.handIdx, pd.offsetX, pd.offsetY); + } + } + if (this.dragState) this.updateCardDrag(pointer); + }); + + this.input.on('pointerup', () => { + if (this.potentialDrag) { + const idx = this.potentialDrag.handIdx; + this.potentialDrag = null; + this.onHandClick(idx); + return; + } + if (this.dragState) this.endCardDrag(); + }); + } + + onHandPointerDown(handIdx, pointer) { + if (this.humanMode !== 'discard') return; + if (this.dragState) return; + const card = this.humanCards[handIdx]; + if (!card) return; + this.potentialDrag = { + handIdx, startX: pointer.x, startY: pointer.y, + offsetX: pointer.x - card.x, offsetY: pointer.y - card.y, + }; + } + + _humanHandX(n, i) { + return CX + (i - (n - 1) / 2) * HAND_SPREAD; + } + + startCardDrag(handIdx, offsetX, offsetY) { + const card = this.humanCards[handIdx]; + if (!card) return; + const n = this.logic.players[0].hand.length; + const insertIdx = Phaser.Math.Clamp( + Math.round((card.x - (CX - (n - 1) / 2 * HAND_SPREAD)) / HAND_SPREAD), 0, n - 1, + ); + + const shadow = this.add.graphics(); + shadow.fillStyle(0x000000, 0.35); + shadow.fillEllipse(0, 0, CARD_W * 1.1, 26); + shadow.setPosition(card.x + 5, card.y + 14).setDepth(D.card + 9); + + const slotIndicator = this.add.graphics(); + slotIndicator.lineStyle(3, 0x22cc66, 0.8); + slotIndicator.strokeRoundedRect(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, 10); + slotIndicator.fillStyle(0x22cc66, 0.1); + slotIndicator.fillRoundedRect(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, 10); + slotIndicator.setPosition(this._humanHandX(n, insertIdx), PLAYER_HAND_Y).setDepth(D.card - 1); + + this.dragState = { cardIdx: handIdx, offsetX, offsetY, prevX: card.x, shadow, slotIndicator, insertIdx, dropTarget: null }; + card.setDepth(D.card + 10); + this.tweens.add({ targets: card, scaleX: 1.08, scaleY: 1.08, duration: 120, ease: 'Cubic.easeOut' }); + } + + updateCardDrag(pointer) { + const ds = this.dragState; + const card = this.humanCards[ds.cardIdx]; + const n = this.logic.players[0].hand.length; + + card.x = pointer.x - ds.offsetX; + card.y = pointer.y - ds.offsetY; + ds.shadow.setPosition(card.x + 5, card.y + 14); + + const tiltRad = Phaser.Math.Clamp((pointer.x - ds.prevX) * 0.008, -0.17, 0.17); + card.setRotation(tiltRad); + ds.prevX = pointer.x; + + const newDrop = this._getDropTarget(card.x, card.y); + if (newDrop !== ds.dropTarget) { + ds.dropTarget = newDrop; + if (newDrop) { + this.tweens.killTweensOf(ds.slotIndicator); + ds.slotIndicator.setAlpha(0); + this._settleNonDraggedCards(); + } else { + ds.slotIndicator.setAlpha(1); + this._updateNonDraggedCards(); + } + } + + if (!newDrop) { + const newInsert = Phaser.Math.Clamp( + Math.round((card.x - (CX - (n - 1) / 2 * HAND_SPREAD)) / HAND_SPREAD), 0, n - 1, + ); + if (newInsert !== ds.insertIdx) { + ds.insertIdx = newInsert; + this.tweens.killTweensOf(ds.slotIndicator); + this.tweens.add({ targets: ds.slotIndicator, x: this._humanHandX(n, newInsert), duration: 100, ease: 'Cubic.easeOut' }); + this._updateNonDraggedCards(); + } + } + } + + _getDropTarget(x, y) { + if (this.humanMode !== 'discard') return null; + const { discard } = this._pilePos; + if (Math.abs(x - discard.x) < 80 && Math.abs(y - discard.y) < 100) return 'discard'; + return null; + } + + _updateNonDraggedCards() { + const ds = this.dragState; + const n = this.humanCards.length; + for (let j = 0; j < n; j++) { + if (j === ds.cardIdx) continue; + const k = j < ds.cardIdx ? j : j - 1; + const finalPos = k < ds.insertIdx ? k : k + 1; + const targetX = this._humanHandX(n, finalPos); + const distFromGap = Math.abs(finalPos - ds.insertIdx); + const leanDir = finalPos < ds.insertIdx ? -1 : 1; + const targetRot = leanDir * Math.max(0, 2 - distFromGap) * 0.04; + const targetScale = distFromGap <= 1 ? 0.95 : 1.0; + this.tweens.killTweensOf(this.humanCards[j]); + this.tweens.add({ targets: this.humanCards[j], x: targetX, rotation: targetRot, scaleX: targetScale, scaleY: targetScale, duration: 100, ease: 'Cubic.easeOut' }); + } + } + + _settleNonDraggedCards() { + const ds = this.dragState; + const n = this.humanCards.length; + let k = 0; + for (let j = 0; j < n; j++) { + if (j === ds.cardIdx) continue; + this.tweens.killTweensOf(this.humanCards[j]); + this.tweens.add({ targets: this.humanCards[j], x: this._humanHandX(n, k), y: PLAYER_HAND_Y, rotation: 0, scaleX: 1, scaleY: 1, duration: 100, ease: 'Cubic.easeOut' }); + k++; + } + } + + endCardDrag() { + const ds = this.dragState; + const card = this.humanCards[ds.cardIdx]; + this.dragState = null; + + ds.shadow.destroy(); + ds.slotIndicator.destroy(); + + if (ds.dropTarget === 'discard') { + // Discard this card + const n = this.humanCards.length; + this._settleNonDraggedCardsExcept(ds.cardIdx); + const { discard } = this._pilePos; + this.tweens.killTweensOf(card); + this.tweens.add({ + targets: card, x: discard.x, y: discard.y, rotation: 0, scaleX: 1, scaleY: 1, + duration: 180, ease: 'Cubic.easeOut', + onComplete: () => { card.destroy(); this.commitDiscard(ds.cardIdx); }, + }); + return; + } + + // Reorder + const n = this.logic.players[0].hand.length; + const finalIdx = Phaser.Math.Clamp( + Math.round((card.x - (CX - (n - 1) / 2 * HAND_SPREAD)) / HAND_SPREAD), 0, n - 1, + ); + this.tweens.killTweensOf(card); + this.tweens.add({ targets: card, x: this._humanHandX(n, finalIdx), y: PLAYER_HAND_Y, rotation: 0, scaleX: 1, scaleY: 1, duration: 220, ease: 'Back.easeOut' }); + + for (let j = 0; j < n; j++) { + if (j === ds.cardIdx) continue; + const k = j < ds.cardIdx ? j : j - 1; + const finalPos = k < finalIdx ? k : k + 1; + this.tweens.killTweensOf(this.humanCards[j]); + this.tweens.add({ targets: this.humanCards[j], x: this._humanHandX(n, finalPos), rotation: 0, scaleX: 1, scaleY: 1, duration: 120, ease: 'Cubic.easeOut' }); + } + + const hand = this.logic.players[0].hand; + const [moved] = hand.splice(ds.cardIdx, 1); + hand.splice(finalIdx, 0, moved); + const [movedObj] = this.humanCards.splice(ds.cardIdx, 1); + this.humanCards.splice(finalIdx, 0, movedObj); + + card.setDepth(D.card); + this.time.delayedCall(250, () => { + this.updateActionButtons(); + this._applyMeldGlows(); + }); + } + + _settleNonDraggedCardsExcept(exceptIdx) { + const n = this.humanCards.length; + let k = 0; + for (let j = 0; j < n; j++) { + if (j === exceptIdx) continue; + this.tweens.killTweensOf(this.humanCards[j]); + this.tweens.add({ targets: this.humanCards[j], x: this._humanHandX(n - 1, k), y: PLAYER_HAND_Y, rotation: 0, scaleX: 1, scaleY: 1, duration: 120, ease: 'Cubic.easeOut' }); + k++; + } + } + + // ── Card rendering ───────────────────────────────────────────────────────── + + drawFace(container, card, faceUp) { + const x = -CARD_W / 2, y = -CARD_H / 2; + const g = this.add.graphics(); + if (!faceUp) { + g.fillStyle(THEME.cardBack, 1); g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R); + g.lineStyle(2.5, THEME.cardBackHi, 0.6); g.strokeRoundedRect(x + 5, y + 5, CARD_W - 10, CARD_H - 10, CARD_R - 2); + g.lineStyle(1, THEME.cardBackHi, 0.25); g.strokeRoundedRect(x + 9, y + 9, CARD_W - 18, CARD_H - 18, CARD_R - 3); + container.add(g); + container.add(this.add.text(0, 0, '♦', { fontFamily: 'serif', fontSize: '32px', color: '#7050a0' }).setOrigin(0.5).setAlpha(0.4)); + return; + } + g.fillStyle(THEME.cardFace, 1); g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R); + g.lineStyle(1.5, 0xc8a060, 0.4); g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1); + container.add(g); + const col = card.isRed ? SUIT_RED : SUIT_BLK; + container.add(this.add.text(x + 7, y + 4, card.label, { fontFamily: 'Righteous', fontSize: '21px', color: col })); + container.add(this.add.text(x + 8, y + 29, card.suitSymbol, { fontFamily: 'sans-serif', fontSize: '19px', color: col })); + container.add(this.add.text(0, 4, card.suitSymbol, { fontFamily: 'sans-serif', fontSize: '40px', color: col }).setOrigin(0.5)); + container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 4, card.label, { fontFamily: 'Righteous', fontSize: '21px', color: col }).setOrigin(1, 1)); + } + + makeCard(card, x, y, { faceUp = true, depth = D.card } = {}) { + const c = this.add.container(x, y).setDepth(depth); + c.cardRef = card; + this.drawFace(c, card, faceUp); + return c; + } + + setCardInteractive(container, handler) { + container.setSize(CARD_W, CARD_H); + container.setInteractive({ useHandCursor: true }); + container.on('pointerdown', handler); + } + + // ── Round lifecycle ──────────────────────────────────────────────────────── + + startRound() { + this.busy = false; + this.humanMode = 'idle'; + this.selectedCard = null; + this.layoffSelected = null; + this.knockBtn.setVisible(false); + this.ginBtn.setVisible(false); + this.layoffDoneBtn.setVisible(false); + this.clearReveal(); + + playSound(this, SFX.CARD_SHUFFLE); + this.refreshScores(); + this.renderAll(); + this.advance(); + } + + clearCards() { + for (const c of this.humanCards) c.destroy(); + this.humanCards = []; + for (const arr of this.aiCardObjs) { for (const c of arr) c?.destroy(); } + this.aiCardObjs = Array.from({ length: this.nPlayers }, () => []); + this.clearPileObjs(); + } + + clearPileObjs() { + this.pileObjs.stock?.destroy(); + this.pileObjs.discard?.destroy(); + this.pileObjs.stock = null; + this.pileObjs.discard = null; + } + + clearReveal() { + for (const o of this.revealObjs) o?.destroy(); + this.revealObjs = []; + } + + renderAll() { + this.clearCards(); + this.renderPiles(); + for (let s = 0; s < this.nPlayers; s++) this.renderHand(s); + } + + renderPiles() { + const { stock, discard } = this._pilePos; + if (this.logic.stockCount > 0) { + // Must be a Container (not raw Graphics) so setSize/setInteractive work correctly + this.pileObjs.stock = this.makeCard(null, stock.x, stock.y, { faceUp: false, depth: D.pile }); + } + this.stockCountText?.setText(`${this.logic.stockCount}`); + + if (this.logic.discardTop) { + const c = this.makeCard(this.logic.discardTop, discard.x, discard.y, { depth: D.pile }); + this.pileObjs.discard = c; + } + } + + renderHand(seat) { + const player = this.logic.players[seat]; + const sp = this._seatPos[seat]; + const faceUp = seat === 0; + const spread = faceUp ? HAND_SPREAD : AI_SPREAD; + const n = player.hand.length; + + if (faceUp) { + this.humanCards = []; + for (let i = 0; i < n; i++) { + const x = this._humanHandX(n, i); + const c = this.makeCard(player.hand[i], x, PLAYER_HAND_Y); + c.baseY = PLAYER_HAND_Y; + this.humanCards.push(c); + } + this._applyMeldGlows(); + } else { + this.aiCardObjs[seat] = []; + for (let i = 0; i < n; i++) { + let x = sp.x, y = sp.y; + if (sp.axis === 'h') x = sp.x + (i - (n - 1) / 2) * spread; + else y = sp.y + (i - (n - 1) / 2) * spread; + const c = this.makeCard(null, x, y, { faceUp: false, depth: D.card + i }); + this.aiCardObjs[seat].push(c); + } + // Name + count label + const name = seat === 0 ? 'You' : (this.opponents[seat - 1]?.name?.split(' ')[0] ?? `AI ${seat}`); + const lbl = this.add.text(sp.nameX, sp.nameY, `${name} (${n})`, { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: THEME.mutedHex, + }).setOrigin(...sp.nameAnchor).setDepth(D.ui); + this.aiCardObjs[seat].push(lbl); + } + } + + _applyMeldGlows() { + // Remove old glows + this.humanCards.forEach(c => { + const old = c.getByName?.('glow'); + if (old) { c.remove(old, true); } + }); + + const hand = this.logic.players[0].hand; + const melds = allCandidateMelds(hand); + const inMeld = new Set(melds.flat().map(c => c.key)); + + this.humanCards.forEach((container, i) => { + const card = hand[i]; + if (!card || !inMeld.has(card.key)) return; + const glow = this.add.graphics(); + glow.lineStyle(3, THEME.meldGlow, 0.7); + glow.strokeRoundedRect(-CARD_W / 2 - 3, -CARD_H / 2 - 3, CARD_W + 6, CARD_H + 6, CARD_R + 2); + glow.setName('glow'); + container.addAt(glow, 0); + }); + } + + // ── Turn flow ────────────────────────────────────────────────────────────── + + advance() { + if (this.busy) return; + const p = this.logic.phase; + if (p === 'gameover') { this.showGameOver(this.logic.winner); return; } + if (p === 'roundover') { this.showRoundScores(); return; } + + const cur = this.logic.currentPlayer; + this.setTurnRing(cur); + if (cur === 0) { + this.beginHumanTurn(); + } else { + this.busy = true; + this.runAiTurn(cur); + } + } + + // ── Human turn ───────────────────────────────────────────────────────────── + + beginHumanTurn() { + if (this.logic.phase === 'draw') { + this.humanMode = 'draw'; + this.setStatus('Draw from the stock pile or take the discard'); + this.knockBtn.setVisible(false); + this.ginBtn.setVisible(false); + + // Make stock clickable + if (this.pileObjs.stock) { + this.pileObjs.stock.setSize(CARD_W, CARD_H); + this.pileObjs.stock.setInteractive({ useHandCursor: true }); + this.pileObjs.stock.on('pointerdown', () => this.onDrawStock()); + this.pileObjs.stock.on('pointerover', () => { this.pileObjs.stock?.setAlpha(0.8); }); + this.pileObjs.stock.on('pointerout', () => { this.pileObjs.stock?.setAlpha(1); }); + } + + // Make discard pile clickable + if (this.pileObjs.discard) { + this.pileObjs.discard.setSize(CARD_W, CARD_H); + this.pileObjs.discard.setInteractive({ useHandCursor: true }); + this.pileObjs.discard.on('pointerdown', () => this.onDrawDiscard()); + this.pileObjs.discard.on('pointerover', () => { this.pileObjs.discard?.setAlpha(0.8); }); + this.pileObjs.discard.on('pointerout', () => { this.pileObjs.discard?.setAlpha(1); }); + } + } else if (this.logic.phase === 'discard') { + this.humanMode = 'discard'; + this.setStatus('Select a card to discard, or Knock / Gin'); + this.updateActionButtons(); + this._setupHandInteraction(); + } + } + + _setupHandInteraction() { + const hand = this.logic.players[0].hand; + this.humanCards.forEach((c, i) => { + c.setSize(CARD_W, CARD_H); + c.setInteractive({ useHandCursor: true }); + c.on('pointerdown', (pointer) => this.onHandPointerDown(i, pointer)); + c.on('pointerover', () => { if (!this.dragState) c.setAlpha(0.85); }); + c.on('pointerout', () => { c.setAlpha(1); }); + }); + } + + updateActionButtons() { + if (this.humanMode !== 'discard') { + this.knockBtn.setVisible(false); + this.ginBtn.setVisible(false); + return; + } + const hand = this.logic.players[0].hand; + let canKn = false, canGin = false; + for (const c of hand) { + const rest = hand.filter(x => x.key !== c.key); + const { deadwood } = bestMeldGroups(rest); + if (deadwood === 0) { canGin = true; canKn = true; break; } + if (deadwood <= MAX_DEADWOOD_TO_KNOCK) canKn = true; + } + this.knockBtn.setVisible(canKn && !canGin); + this.ginBtn.setVisible(canGin); + } + + onHandClick(handIdx) { + if (this.humanMode !== 'discard') return; + if (this.selectedCard) { + // Deselect previous + const prev = this.humanCards.find(c => c.cardRef?.key === this.selectedCard); + if (prev) this.tweens.add({ targets: prev, y: PLAYER_HAND_Y, duration: 120 }); + } + const card = this.logic.players[0].hand[handIdx]; + if (this.selectedCard === card.key) { + this.selectedCard = null; + this.setStatus('Select a card to discard, or Knock / Gin'); + } else { + this.selectedCard = card.key; + const obj = this.humanCards[handIdx]; + this.tweens.add({ targets: obj, y: PLAYER_HAND_Y - 24, duration: 120 }); + this.setStatus('Click the discard pile to discard this card'); + } + } + + onDrawStock() { + if (this.humanMode !== 'draw') return; + if (!this.logic.drawStock(0)) return; + playSound(this, SFX.CARD_DEAL); + this.renderAll(); + this.beginHumanTurn(); + } + + onDrawDiscard() { + if (this.humanMode !== 'draw') return; + if (!this.logic.drawDiscard(0)) return; + playSound(this, SFX.CARD_DEAL); + this.renderAll(); + this.beginHumanTurn(); + } + + commitDiscard(handIdx) { + const hand = this.logic.players[0].hand; + const key = hand[handIdx]?.key; + if (!key) return; + if (!this.logic.discardCard(0, key)) return; + playSound(this, SFX.CARD_PLACE); + this.humanMode = 'idle'; + this.selectedCard = null; + this.humanCards.splice(handIdx, 1); + this.renderPiles(); + this.renderHand(0); + this.advance(); + } + + onKnock() { + if (this.humanMode !== 'discard') return; + const hand = this.logic.players[0].hand; + let bestDiscard = null, bestDW = Infinity, bestMelds = []; + for (const c of hand) { + const rest = hand.filter(x => x.key !== c.key); + const { deadwood, melds } = bestMeldGroups(rest); + if (deadwood < bestDW) { bestDW = deadwood; bestDiscard = c; bestMelds = melds; } + } + if (!bestDiscard || bestDW > MAX_DEADWOOD_TO_KNOCK) return; + if (!this.logic.knock(0, bestDiscard.key, bestMelds)) return; + playSound(this, SFX.CARD_PLACE); + this.humanMode = 'idle'; + this.renderAll(); + this.doKnockReveal(false); + } + + onGin() { + if (this.humanMode !== 'discard') return; + const hand = this.logic.players[0].hand; + let bestDiscard = null, bestMelds = []; + for (const c of hand) { + const rest = hand.filter(x => x.key !== c.key); + const { deadwood, melds } = bestMeldGroups(rest); + if (deadwood === 0) { bestDiscard = c; bestMelds = melds; break; } + } + if (!bestDiscard) return; + if (!this.logic.gin(0, bestDiscard.key, bestMelds)) return; + playSound(this, SFX.CARD_SHOW); + this.humanMode = 'idle'; + this.renderAll(); + this.doKnockReveal(true); + } + + // ── AI turn ──────────────────────────────────────────────────────────────── + + async runAiTurn(seat) { + const delay = (ms) => new Promise(r => this.time.delayedCall(ms, r)); + const skill = this.opponents[seat - 1]?.skill ?? 3; + const hand = this.logic.players[seat].hand; + const discardTop = this.logic.discardTop; + + // Draw + const src = chooseDrawSource(hand, discardTop, skill); + await delay(thinkDelay(skill)); + if (src === 'discard' && discardTop) { + this.logic.drawDiscard(seat); + } else { + this.logic.drawStock(seat); + } + playSound(this, SFX.CARD_DEAL); + this.renderHand(seat); + this.renderPiles(); + + await delay(thinkDelay(skill) * 0.6); + + // Decide knock/gin/discard + const handNow = this.logic.players[seat].hand; + const { melds, deadwood } = bestMeldGroups(handNow); + + if (deadwood === 0) { + // Gin — find best discard + let bestDiscard = null, bestMelds2 = []; + for (const c of handNow) { + const rest = handNow.filter(x => x.key !== c.key); + const { deadwood: dw2, melds: m2 } = bestMeldGroups(rest); + if (dw2 === 0) { bestDiscard = c; bestMelds2 = m2; break; } + } + if (bestDiscard && this.logic.gin(seat, bestDiscard.key, bestMelds2)) { + playSound(this, SFX.CARD_SHOW); + this.busy = false; + this.renderAll(); + this.doKnockReveal(true); + return; + } + } + + if (shouldKnock(handNow, skill)) { + let bestDiscard = null, bestDW = Infinity, bestMelds2 = []; + for (const c of handNow) { + const rest = handNow.filter(x => x.key !== c.key); + const { deadwood: dw2, melds: m2 } = bestMeldGroups(rest); + if (dw2 < bestDW) { bestDW = dw2; bestDiscard = c; bestMelds2 = m2; } + } + if (bestDiscard && bestDW <= MAX_DEADWOOD_TO_KNOCK && this.logic.knock(seat, bestDiscard.key, bestMelds2)) { + playSound(this, SFX.CARD_PLACE); + this.busy = false; + this.renderAll(); + this.doKnockReveal(false); + return; + } + } + + // Normal discard + const discardCard = chooseDiscard(handNow, skill); + if (discardCard) { + this.logic.discardCard(seat, discardCard.key); + playSound(this, SFX.CARD_PLACE); + } + this.renderAll(); + this.busy = false; + this.advance(); + } + + // ── Knock / Gin reveal ───────────────────────────────────────────────────── + + async doKnockReveal(isGin) { + const delay = (ms) => new Promise(r => this.time.delayedCall(ms, r)); + this.busy = true; + const k = this.logic.knocker; + const kMelds = this.logic.knockerMelds; + const kName = k === 0 ? 'You' : (this.opponents[k - 1]?.name?.split(' ')[0] ?? `AI ${k}`); + const verb = isGin ? 'Gin!' : 'Knock!'; + + this.setStatus(`${kName} called ${verb}`); + playSound(this, SFX.CARD_SHOW); + await delay(600); + + // Reveal knocker's melds in center + this.clearReveal(); + this._renderRevealZone(k, kMelds, isGin); + + await delay(1200); + + if (isGin) { + // No layoffs — go straight to scoring + this.showRoundScores(); + return; + } + + // Layoff phase: each opponent of knocker in order + const order = []; + let s = (k + 1) % this.nPlayers; + while (s !== k) { order.push(s); s = (s + 1) % this.nPlayers; } + + for (const seat of order) { + if (seat === 0) { + await this.runHumanLayoff(); + } else { + await this.runAiLayoffAuto(seat); + } + } + + this.showRoundScores(); + } + + _renderRevealZone(knockerSeat, meldGroups, isGin) { + const revealY = 420; + const revealStartX = CX - 400; + let cx = revealStartX; + + // Meld groups + for (let mi = 0; mi < meldGroups.length; mi++) { + const meld = meldGroups[mi]; + const groupLabel = this.add.text(cx + meld.length * 44 / 2, revealY - 70, + mi === 0 ? 'MELDS' : '', { fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex }) + .setOrigin(0.5, 0).setDepth(D.overlay); + this.revealObjs.push(groupLabel); + + for (let j = 0; j < meld.length; j++) { + const c = this.makeCard(meld[j], cx + j * 44, revealY, { depth: D.overlay + j }); + c.setScale(0.55); + // Green outline glow for meld cards + const glow = this.add.graphics(); + glow.lineStyle(3, THEME.meldGlow, 0.8); + glow.strokeRoundedRect(-CARD_W / 2 * 0.55 - 3, -CARD_H / 2 * 0.55 - 3, CARD_W * 0.55 + 6, CARD_H * 0.55 + 6, 6); + c.addAt(glow, 0); + this.revealObjs.push(c); + } + cx += meld.length * 44 + 20; + } + + // Deadwood cards from knocker's hand + const kHand = this.logic.players[knockerSeat].hand; + const meldedKeys = new Set(meldGroups.flat().map(c => c.key)); + const deadwood = kHand.filter(c => !meldedKeys.has(c.key)); + + if (deadwood.length > 0) { + const dwLabel = this.add.text(cx, revealY - 70, isGin ? '' : 'DEADWOOD', + { fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex }) + .setOrigin(0, 0).setDepth(D.overlay); + this.revealObjs.push(dwLabel); + + for (let j = 0; j < deadwood.length; j++) { + const c = this.makeCard(deadwood[j], cx + j * 44, revealY, { depth: D.overlay + j }); + c.setScale(0.55); + const dwVal = this.add.text(cx + j * 44, revealY + 36, String(ginDeadwoodValue(deadwood[j])), + { fontFamily: 'Righteous', fontSize: '11px', color: THEME.goldHex }) + .setOrigin(0.5).setDepth(D.overlay + 10); + this.revealObjs.push(c, dwVal); + } + } + } + + // ── Human layoff ─────────────────────────────────────────────────────────── + + runHumanLayoff() { + return new Promise((resolve) => { + this._layoffResolve = resolve; + this.humanMode = 'layoff'; + this.layoffSelected = null; + this.setStatus('Lay off cards on the knocker\'s melds, then click Done'); + this.knockBtn.setVisible(false); + this.ginBtn.setVisible(false); + this.layoffDoneBtn.setVisible(true); + + this._setupLayoffHandInteraction(); + }); + } + + _setupLayoffHandInteraction() { + this.humanCards.forEach((c, i) => { + c.setSize(CARD_W, CARD_H); + c.setInteractive({ useHandCursor: true }); + c.on('pointerdown', () => this.onLayoffCardClick(i)); + }); + this._refreshLayoffHighlights(); + } + + onLayoffCardClick(handIdx) { + if (this.humanMode !== 'layoff') return; + const card = this.logic.players[0].hand[handIdx]; + + // Check which melds it can go on + const validMelds = []; + for (let mi = 0; mi < this.logic.knockerMelds.length; mi++) { + if (canLayoff(card, this.logic.knockerMelds[mi])) validMelds.push(mi); + } + if (validMelds.length === 0) return; + + // Auto-apply the layoff (take first valid meld) + this.logic.layoff(0, [{ cardKey: card.key, meldIdx: validMelds[0] }]); + playSound(this, SFX.CARD_PLACE); + this.renderHand(0); + this._setupLayoffHandInteraction(); + this._refreshLayoffHighlights(); + } + + _refreshLayoffHighlights() { + const hand = this.logic.players[0].hand; + this.humanCards.forEach((c, i) => { + if (!hand[i]) return; + const card = hand[i]; + const canLay = this.logic.knockerMelds.some(m => canLayoff(card, m)); + c.setAlpha(canLay ? 1.0 : 0.55); + }); + } + + onLayoffDone() { + if (this.humanMode !== 'layoff') return; + this.logic.passLayoff(0); + this.layoffDoneBtn.setVisible(false); + this.humanMode = 'idle'; + const resolve = this._layoffResolve; + this._layoffResolve = null; + resolve?.(); + } + + // ── AI layoff (auto) ─────────────────────────────────────────────────────── + + async runAiLayoffAuto(seat) { + const delay = (ms) => new Promise(r => this.time.delayedCall(ms, r)); + await delay(700); + const skill = this.opponents[seat - 1]?.skill ?? 3; + const hand = this.logic.players[seat].hand; + const layoffs = findLayoffs(hand, this.logic.knockerMelds, skill); + if (layoffs.length > 0) { + this.logic.layoff(seat, layoffs); + playSound(this, SFX.CARD_PLACE); + } + this.logic.passLayoff(seat); + await delay(400); + } + + // ── Status text ──────────────────────────────────────────────────────────── + + setStatus(text) { + this.statusText?.setText(text); + } + + // ── Round scores overlay ─────────────────────────────────────────────────── + + async showRoundScores() { + this.busy = true; + const delay = (ms) => new Promise(r => this.time.delayedCall(ms, r)); + + const ovBg = this.add.graphics().setDepth(D.overlay); + ovBg.fillStyle(0x000000, 0.62); + ovBg.fillRoundedRect(CX - 320, CY - 220, 640, 440, 22); + ovBg.lineStyle(2, THEME.gold, 0.6); + ovBg.strokeRoundedRect(CX - 320, CY - 220, 640, 440, 22); + + const title = this.add.text(CX, CY - 185, 'Round Results', { + fontFamily: 'Righteous', fontSize: '32px', color: THEME.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + + const k = this.logic.knocker; + const kName = k === 0 ? 'You' : (this.opponents[k - 1]?.name?.split(' ')[0] ?? `AI ${k}`); + const scores = this.logic.roundScores; + + const names = ['You', ...this.opponents.map(o => o.name?.split(' ')[0] ?? 'AI')]; + const textObjs = [title]; + + for (let s = 0; s < this.nPlayers; s++) { + const ry = CY - 120 + s * 68; + const delta = scores[s]; + const col = delta > 0 ? '#55dd88' : delta < 0 ? '#ff6666' : THEME.ivoryHex; + const t1 = this.add.text(CX - 200, ry, names[s], { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: THEME.mutedHex, + }).setOrigin(0, 0.5).setDepth(D.overlayUI); + const t2 = this.add.text(CX + 200, ry, delta > 0 ? `+${delta}` : String(delta), { + fontFamily: 'Righteous', fontSize: '28px', color: col, + }).setOrigin(1, 0.5).setDepth(D.overlayUI); + textObjs.push(t1, t2); + } + + await delay(3800); + + for (const o of textObjs) o?.destroy(); + ovBg.destroy(); + + if (this.logic.phase === 'gameover') { + this.showGameOver(this.logic.winner); + } else { + this.clearReveal(); + this.logic.newRound(); + this.startRound(); + } + } + + // ── Game over ────────────────────────────────────────────────────────────── + + showGameOver(winnerSeat) { + const names = ['You', ...this.opponents.map(o => o.name?.split(' ')[0] ?? 'AI')]; + const winner = names[winnerSeat ?? 0]; + const youWon = winnerSeat === 0; + + const ovBg = this.add.graphics().setDepth(D.overlay); + ovBg.fillStyle(0x000000, 0.75); + ovBg.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + + this.add.text(CX, CY - 100, youWon ? 'You Win!' : `${winner} Wins!`, { + fontFamily: 'Righteous', fontSize: '72px', color: youWon ? '#55dd88' : THEME.ivoryHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + + const finalScores = this.logic.players.map((p, s) => `${names[s]}: ${p.score}`).join(' '); + this.add.text(CX, CY + 20, finalScores, { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: THEME.mutedHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + + new Button(this, CX - 100, CY + 130, 'Play Again', () => { + this.logic.newGame(); + this.clearReveal(); + this.startRound(); + }, { width: 180, height: 56, fontSize: 22 }).setDepth(D.overlayUI); + + new Button(this, CX + 100, CY + 130, 'Leave', () => this.scene.start('GameMenu'), { + variant: 'ghost', width: 180, height: 56, fontSize: 22, + }).setDepth(D.overlayUI); + } + + // ── Utility ──────────────────────────────────────────────────────────────── + + delay(ms) { + return new Promise(r => this.time.delayedCall(ms, r)); + } +} diff --git a/public/src/games/ginrummy/GinRummyLogic.js b/public/src/games/ginrummy/GinRummyLogic.js new file mode 100644 index 0000000..35a0582 --- /dev/null +++ b/public/src/games/ginrummy/GinRummyLogic.js @@ -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); + } +} diff --git a/public/src/main.js b/public/src/main.js index 25224d0..66dbb9c 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index b67ba6a..43adf4e 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -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, diff --git a/server/games/registry.js b/server/games/registry.js index b1e458a..9691d9c 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -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 }); diff --git a/server/scripts/verifyGinRummy.js b/server/scripts/verifyGinRummy.js new file mode 100644 index 0000000..4002d45 --- /dev/null +++ b/server/scripts/verifyGinRummy.js @@ -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);