224 lines
8.2 KiB
JavaScript
224 lines
8.2 KiB
JavaScript
// 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 },
|
||
};
|
||
}
|