Compare commits
No commits in common. "8a79b6909da37fb72cdfbc00dadce49b9172fe49" and "f2da9bfb20cfdb01b0d674dd883d1f00f0311a6f" have entirely different histories.
8a79b6909d
...
f2da9bfb20
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
|
@ -1,131 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
// 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 },
|
||||
};
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -90,24 +90,17 @@ export function buildTableLayout(playerCount, { width, height }) {
|
|||
};
|
||||
// AI seats by count: 2p → top; 3p → top + left; 4p → top + left + right.
|
||||
const order = [];
|
||||
if (playerCount >= 2) order.push({ seat: 1, x: cx, y: 200, dir: 'h', side: 'top' });
|
||||
if (playerCount >= 2) order.push({ seat: 1, x: cx, y: 150, dir: 'h', side: 'top' });
|
||||
if (playerCount >= 3) order.push({ seat: 2, x: 150, y: 470, dir: 'v', side: 'left' });
|
||||
if (playerCount >= 4) order.push({ seat: 3, x: width - 150, y: 470, dir: 'v', side: 'right' });
|
||||
for (const o of order) seats[o.seat] = o;
|
||||
|
||||
// Side opponents at x≈150 (left) and x≈1770 (right) need clear space.
|
||||
// Portrait ring + name text extends ~180px inward from each side opponent.
|
||||
const lm = playerCount >= 3 ? 350 : 120; // left margin
|
||||
const rm = playerCount >= 4 ? 350 : 120; // right margin
|
||||
|
||||
const tableX = lm, tableY = 300, tableW = width - lm - rm, tableH = 430;
|
||||
|
||||
return {
|
||||
cx,
|
||||
seats,
|
||||
// Pool sits just outside the table band to the right, near the bottom edge.
|
||||
pool: { x: tableX + tableW + 70, y: tableY + tableH - 20 },
|
||||
table: { x: tableX, y: tableY, w: tableW, h: tableH },
|
||||
pool: { x: cx, y: 232 }, // face-down draw pool anchor
|
||||
// Central band where committed sets are laid out (top-left origin, wraps).
|
||||
table: { x: 120, y: 300, w: width - 240, h: 430 },
|
||||
rack: { x: 120, y: height - 168, w: width - 240, h: 150 },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ export default class RummikubGame extends Phaser.Scene {
|
|||
this.busy = false;
|
||||
this.recorded = false;
|
||||
this.dragging = null;
|
||||
this.rackDragState = null;
|
||||
this.originalTableIds = new Set();
|
||||
|
||||
this.tableObjs = [];
|
||||
|
|
@ -68,7 +67,7 @@ export default class RummikubGame extends Phaser.Scene {
|
|||
this.buildBackdrop();
|
||||
this.buildPortraits();
|
||||
|
||||
this.statusText = this.add.text(GAME_WIDTH / 2, 780, '', {
|
||||
this.statusText = this.add.text(GAME_WIDTH / 2, 264, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui).setShadow(0, 2, '#000', 6);
|
||||
|
||||
|
|
@ -107,7 +106,7 @@ export default class RummikubGame extends Phaser.Scene {
|
|||
fontFamily: 'Righteous', fontSize: '32px', color: HEX(THEME.brassHi),
|
||||
}).setOrigin(0.5, 0).setDepth(D.ui).setAlpha(0.6);
|
||||
|
||||
this.add.text(this.layout.table.x - 16, this.layout.table.y - 34, 'TABLE', {
|
||||
this.add.text(this.layout.table.x - 16, this.layout.table.y - 24, 'TABLE', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 1).setDepth(D.ui);
|
||||
}
|
||||
|
|
@ -115,26 +114,17 @@ export default class RummikubGame extends Phaser.Scene {
|
|||
buildPortraits() {
|
||||
for (let seat = 0; seat < this.playerCount; seat++) {
|
||||
const a = this.layout.seats[seat];
|
||||
const px = a.side === 'bottom' ? 80 : a.x;
|
||||
const py = a.side === 'bottom' ? a.y : a.y - 80;
|
||||
const px = a.side === 'bottom' ? 80 : a.side === 'top' ? 80 : a.x;
|
||||
const py = a.side === 'bottom' ? a.y : a.side === 'top' ? a.y : a.y - 110;
|
||||
this.portraits[seat] = seat === 0
|
||||
? createPlayerPortrait(this, px, py, 42, D.ui, 'RummikubGame')
|
||||
: createOpponentPortrait(this, this.seatOpp[seat], px, py, 42, D.ui, { playIntro: seat === 1 });
|
||||
// Left/right opponents: name + count stacked above the portrait circle.
|
||||
// Top/bottom opponents: name + count to the right of the portrait.
|
||||
const sideOpponent = a.side === 'left' || a.side === 'right';
|
||||
this.add.text(
|
||||
sideOpponent ? px : px + 58,
|
||||
sideOpponent ? py - 48 : py - 8,
|
||||
this.seatName[seat].toUpperCase(),
|
||||
{ fontFamily: 'Righteous', fontSize: '18px', color: HEX(THEME.brassHi) },
|
||||
).setOrigin(sideOpponent ? 0.5 : 0, sideOpponent ? 1 : 0.5).setDepth(D.ui);
|
||||
this.portraits[seat].countText = this.add.text(
|
||||
sideOpponent ? px : px + 58,
|
||||
sideOpponent ? py - 70 : py + 16,
|
||||
'',
|
||||
{ fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex },
|
||||
).setOrigin(sideOpponent ? 0.5 : 0, sideOpponent ? 1 : 0.5).setDepth(D.ui);
|
||||
this.add.text(px + 58, py - 8, this.seatName[seat].toUpperCase(), {
|
||||
fontFamily: 'Righteous', fontSize: '18px', color: HEX(THEME.brassHi),
|
||||
}).setOrigin(0, 0.5).setDepth(D.ui);
|
||||
this.portraits[seat].countText = this.add.text(px + 58, py + 16, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.ui);
|
||||
const ring = this.add.graphics().setDepth(D.ring).setVisible(false);
|
||||
ring.lineStyle(5, THEME.brassHi, 0.95); ring.strokeCircle(px, py, 48);
|
||||
this.portraits[seat].ring = ring;
|
||||
|
|
@ -143,7 +133,7 @@ export default class RummikubGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
buildButtons() {
|
||||
const y = 840;
|
||||
const y = GAME_HEIGHT - 30;
|
||||
const cx = GAME_WIDTH / 2;
|
||||
this.btn = {};
|
||||
this.btn.play = new Button(this, cx - 300, y, 'Play', () => this.humanCommit(),
|
||||
|
|
@ -154,7 +144,7 @@ export default class RummikubGame extends Phaser.Scene {
|
|||
{ width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui);
|
||||
this.btn.sort = new Button(this, cx + 300, y, 'Sort', () => this.sortRack(),
|
||||
{ width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui);
|
||||
this.btn.leave = new Button(this, GAME_WIDTH - 100, 140, 'Leave', () => this.scene.start('GameMenu'),
|
||||
this.btn.leave = new Button(this, GAME_WIDTH - 100, 40, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 150, height: 46, fontSize: 19 }).setDepth(D.ui);
|
||||
this.hideActionButtons();
|
||||
}
|
||||
|
|
@ -288,15 +278,6 @@ export default class RummikubGame extends Phaser.Scene {
|
|||
if (this.isHumanTurn()) this.makeDraggable(obj);
|
||||
this.tableObjs.push(obj);
|
||||
});
|
||||
|
||||
// Point value label below valid sets containing staged tiles.
|
||||
if (this.isHumanTurn() && valid.valid && set.some((t) => !this.originalTableIds.has(t.id))) {
|
||||
const label = this.add.text(rect.x + rect.w / 2, rect.y + rect.h + 4, `${valid.points} pts`, {
|
||||
fontFamily: 'Righteous', fontSize: '14px', color: HEX(THEME.brassHi),
|
||||
}).setOrigin(0.5, 0).setDepth(D.ui);
|
||||
this.tableObjs.push(label);
|
||||
}
|
||||
|
||||
cx += setW + setGap;
|
||||
});
|
||||
|
||||
|
|
@ -369,16 +350,12 @@ export default class RummikubGame extends Phaser.Scene {
|
|||
obj.setDepth(D.drag);
|
||||
this.dragging = obj;
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this.tweens.add({ targets: obj, scaleX: 1.06, scaleY: 1.06, duration: 120, ease: 'Cubic.easeOut' });
|
||||
if (obj._loc?.type === 'rack') this._initRackDragState(obj);
|
||||
});
|
||||
obj.on('drag', (p, dx, dy) => { obj.x = dx; obj.y = dy; this.pointer = p; this._onRackDragMove(obj, p); });
|
||||
obj.on('drag', (p, dx, dy) => { obj.x = dx; obj.y = dy; this.pointer = p; });
|
||||
obj.on('dragend', (p) => this.onDrop(obj, p));
|
||||
}
|
||||
|
||||
onDrop(obj, pointer) {
|
||||
const insertIndex = this.rackDragState?.insertIdx;
|
||||
this._cleanupRackDragState();
|
||||
this.dragging = null;
|
||||
const px = pointer.x, py = pointer.y;
|
||||
const tile = obj.tileRef;
|
||||
|
|
@ -395,7 +372,7 @@ export default class RummikubGame extends Phaser.Scene {
|
|||
}
|
||||
if (!dest && py >= rack.y - 30) {
|
||||
if (this.originalTableIds.has(tile.id)) dest = null; // table tiles can't return to rack
|
||||
else dest = { type: 'rack', insertIndex: insertIndex ?? this.state.workingRack.length };
|
||||
else dest = { type: 'rack' };
|
||||
}
|
||||
|
||||
if (dest) {
|
||||
|
|
@ -405,126 +382,6 @@ export default class RummikubGame extends Phaser.Scene {
|
|||
this.renderAll();
|
||||
}
|
||||
|
||||
// ── Rack insert-indicator helpers ─────────────────────────────────────────────
|
||||
|
||||
_rackGridPos(slotIdx, totalSlots) {
|
||||
const r = this.layout.rack;
|
||||
const tileW = TW * RACK_SCALE + TILE_GAP;
|
||||
const perRow = Math.max(1, Math.floor(r.w / tileW));
|
||||
const rows = Math.ceil(totalSlots / perRow) || 1;
|
||||
const rowH = TH * RACK_SCALE + 8;
|
||||
const row = Math.floor(slotIdx / perRow);
|
||||
const slotsInRow = Math.min(perRow, totalSlots - row * perRow);
|
||||
const col = slotIdx % perRow;
|
||||
const rowW = slotsInRow * tileW;
|
||||
const x = r.x + (r.w - rowW) / 2 + col * tileW + tileW / 2;
|
||||
const y = r.y + (r.h - rows * rowH) / 2 + row * rowH + rowH / 2;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
_rackInsertIdx(px, py, totalSlots) {
|
||||
let best = 0, bestDist = Infinity;
|
||||
for (let k = 0; k < totalSlots; k++) {
|
||||
const { x, y } = this._rackGridPos(k, totalSlots);
|
||||
const d = (px - x) * (px - x) + (py - y) * (py - y);
|
||||
if (d < bestDist) { bestDist = d; best = k; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
_makeSlotIndicator(x, y) {
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(THEME.brassHi, 0.15);
|
||||
g.fillRoundedRect(-TW / 2 - 5, -TH / 2 - 5, TW + 10, TH + 10, TR + 2);
|
||||
g.lineStyle(2.5, THEME.brassHi, 0.85);
|
||||
g.strokeRoundedRect(-TW / 2 - 5, -TH / 2 - 5, TW + 10, TH + 10, TR + 2);
|
||||
return g.setPosition(x, y).setDepth(8);
|
||||
}
|
||||
|
||||
_initRackDragState(obj) {
|
||||
const initIdx = this.rackObjs.indexOf(obj);
|
||||
const totalSlots = this.state.workingRack.length;
|
||||
const pos = this._rackGridPos(Math.max(0, initIdx), totalSlots);
|
||||
const indicator = this._makeSlotIndicator(pos.x, pos.y);
|
||||
this.rackDragState = { insertIdx: initIdx, slotIndicator: indicator, isRackTile: true, visible: true };
|
||||
this._updateRackGap(obj, totalSlots);
|
||||
}
|
||||
|
||||
_onRackDragMove(obj, pointer) {
|
||||
const rack = this.layout.rack;
|
||||
const px = pointer.x, py = pointer.y;
|
||||
const inRackZone = py >= rack.y - 40;
|
||||
const isRackTile = obj._loc?.type === 'rack';
|
||||
|
||||
if (!inRackZone) {
|
||||
if (this.rackDragState?.visible) {
|
||||
this.rackDragState.slotIndicator.setAlpha(0);
|
||||
this.rackDragState.visible = false;
|
||||
this._settleRackTiles(obj);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSlots = isRackTile
|
||||
? this.state.workingRack.length
|
||||
: this.state.workingRack.length + 1;
|
||||
|
||||
if (!this.rackDragState) {
|
||||
const initIdx = isRackTile ? this.rackObjs.indexOf(obj) : this.state.workingRack.length;
|
||||
const clamped = Math.max(0, Math.min(initIdx, totalSlots - 1));
|
||||
const pos = this._rackGridPos(clamped, totalSlots);
|
||||
this.rackDragState = {
|
||||
insertIdx: clamped,
|
||||
slotIndicator: this._makeSlotIndicator(pos.x, pos.y),
|
||||
isRackTile,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
const ds = this.rackDragState;
|
||||
const wasHidden = !ds.visible;
|
||||
ds.visible = true;
|
||||
ds.slotIndicator.setAlpha(1);
|
||||
|
||||
const newInsertIdx = this._rackInsertIdx(px, py, totalSlots);
|
||||
if (newInsertIdx !== ds.insertIdx || wasHidden) {
|
||||
ds.insertIdx = newInsertIdx;
|
||||
const pos = this._rackGridPos(newInsertIdx, totalSlots);
|
||||
this.tweens.killTweensOf(ds.slotIndicator);
|
||||
this.tweens.add({ targets: ds.slotIndicator, x: pos.x, y: pos.y, duration: 100, ease: 'Cubic.easeOut' });
|
||||
this._updateRackGap(obj, totalSlots);
|
||||
}
|
||||
}
|
||||
|
||||
_updateRackGap(draggedObj, totalSlots) {
|
||||
const insertIdx = this.rackDragState.insertIdx;
|
||||
let k = 0;
|
||||
for (const rackObj of this.rackObjs) {
|
||||
if (rackObj === draggedObj) continue;
|
||||
const targetSlot = k < insertIdx ? k : k + 1;
|
||||
const pos = this._rackGridPos(targetSlot, totalSlots);
|
||||
this.tweens.killTweensOf(rackObj);
|
||||
this.tweens.add({ targets: rackObj, x: pos.x, y: pos.y, duration: 100, ease: 'Cubic.easeOut' });
|
||||
k++;
|
||||
}
|
||||
}
|
||||
|
||||
_settleRackTiles(draggedObj) {
|
||||
const others = this.rackObjs.filter((o) => o !== draggedObj);
|
||||
others.forEach((rackObj, k) => {
|
||||
const pos = this._rackGridPos(k, others.length);
|
||||
this.tweens.killTweensOf(rackObj);
|
||||
this.tweens.add({ targets: rackObj, x: pos.x, y: pos.y, duration: 100, ease: 'Cubic.easeOut' });
|
||||
});
|
||||
}
|
||||
|
||||
_cleanupRackDragState() {
|
||||
if (this.rackDragState) {
|
||||
this.rackDragState.slotIndicator.destroy();
|
||||
this.rackDragState = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Human actions ─────────────────────────────────────────────────────────---
|
||||
humanCommit() {
|
||||
if (!this.isHumanTurn()) return;
|
||||
|
|
|
|||
|
|
@ -111,9 +111,7 @@ export function applyDrag(state, tileId, dest) {
|
|||
if (!tile) return state; // unknown tile
|
||||
|
||||
if (dest.type === 'rack') {
|
||||
const idx = (dest.insertIndex !== undefined && dest.insertIndex >= 0)
|
||||
? Math.min(dest.insertIndex, workingRack.length) : workingRack.length;
|
||||
workingRack.splice(idx, 0, tile);
|
||||
workingRack.push(tile);
|
||||
} else if (dest.type === 'newSet') {
|
||||
workingTable.push([tile]);
|
||||
} else if (dest.type === 'set') {
|
||||
|
|
@ -135,21 +133,10 @@ export function stageAiPlan(state, plan) {
|
|||
|
||||
export function resetTurn(state) {
|
||||
const ts = state.turnStart;
|
||||
// Preserve manual rack ordering: tiles already in workingRack keep their
|
||||
// current positions; tiles being returned from the table append at the end
|
||||
// in their original turnStart order.
|
||||
const rankInWorking = new Map(state.workingRack.map((t, i) => [t.id, i]));
|
||||
const origRank = new Map(ts.rack.map((t, i) => [t.id, i]));
|
||||
const n = ts.rack.length;
|
||||
const resetRack = ts.rack.slice().sort((a, b) => {
|
||||
const ai = rankInWorking.has(a.id) ? rankInWorking.get(a.id) : n + origRank.get(a.id);
|
||||
const bi = rankInWorking.has(b.id) ? rankInWorking.get(b.id) : n + origRank.get(b.id);
|
||||
return ai - bi;
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
workingTable: ts.table.map((s) => s.slice()),
|
||||
workingRack: resetRack,
|
||||
workingRack: ts.rack.slice(),
|
||||
drewThisTurn: false,
|
||||
commitError: null,
|
||||
};
|
||||
|
|
@ -243,11 +230,8 @@ export function drawTile(state) {
|
|||
|
||||
const pool = state.pool.slice();
|
||||
const tile = pool.shift();
|
||||
// Use workingRack so any manual reordering done before drawing is preserved;
|
||||
// draw is only allowed when no tiles have been moved to the table, so
|
||||
// workingRack has the same tile set as players[seat].rack.
|
||||
const players = state.players.map((p, i) =>
|
||||
i === seat ? { ...p, rack: [...state.workingRack, tile] } : p);
|
||||
i === seat ? { ...p, rack: [...state.players[seat].rack, tile] } : p);
|
||||
const next = { ...state, pool, players, consecutivePasses: 0, drewThisTurn: true, commitError: null };
|
||||
return advanceTurn(next);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ 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,
|
||||
|
|
@ -170,7 +169,6 @@ 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', ginrummy: 'GinRummyGame' };
|
||||
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' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -93,4 +93,3 @@ 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 });
|
||||
|
|
|
|||
|
|
@ -1,261 +0,0 @@
|
|||
// 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);
|
||||