fertig-classic-games/src/games/ginrummy/GinRummyData.js

224 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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