feat: add Cribbage and Canasta single-player card games

- Implement full game logic, scoring, and UI scenes for Cribbage and Canasta
- Add heuristic AI opponents with 5 skill levels and reaction delays for both games
- Cribbage: race-to-121 track, pegging, crib scoring, and cut mechanics
- Canasta: 108-card shoe, partnership melding, canastas, frozen/blocked piles, and go-out rules
- Include headless verification scripts (fixture + self-play tests) for both games
- Register new games in server registry and update scene routing
- Add tutorial markdown files and update game icon assets
This commit is contained in:
Brian Fertig 2026-06-13 21:54:20 -06:00
parent fc7ae2c53d
commit f5b0c545f4
17 changed files with 3045 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

View File

@ -0,0 +1,199 @@
// Canasta — heuristic partnership AI. No Phaser, no state mutation. Each chooser
// inspects the (already-cloned) engine state and returns a plain action
// descriptor that the scene / verify harness applies through CanastaLogic.
//
// A full AI turn is three calls, in order:
// 1. chooseDraw → { type:'stock' } | { type:'take', plan }
// 2. chooseMelds → { actions:[{rank,cardIds}] } (applied in sequence)
// 3. chooseDiscard→ cardId
// The AI is partner-aware: melds belong to the team, so it freely extends a
// partner's melds and counts the partnership's canastas when deciding to go out.
import {
takePlan, takeDiscard, meld, meetsInitialMeld, teamMeld, isCanasta,
meldNaturals, meldWilds,
} from './CanastaLogic.js';
import {
isWild, isMeldable, isBlackThree, cardScore, minimumMeld,
TEAM_OF_SEAT, CANASTA_SIZE, MAX_WILDS_IN_MELD,
} from './CanastaData.js';
// Skill 15 → reaction delay and decision noise (mirrors the other games).
const PROFILE = {
1: { delay: [820, 1300], aggression: 0.35, pushCanasta: false, noise: 2.4 },
2: { delay: [720, 1150], aggression: 0.5, pushCanasta: false, noise: 1.6 },
3: { delay: [620, 1000], aggression: 0.65, pushCanasta: true, noise: 1.0 },
4: { delay: [520, 880], aggression: 0.8, pushCanasta: true, noise: 0.5 },
5: { delay: [440, 760], aggression: 0.95, pushCanasta: true, noise: 0.0 },
};
const prof = (skill) => PROFILE[Math.max(1, Math.min(5, skill | 0))] || PROFILE[3];
export function thinkDelay(skill) {
const [lo, hi] = prof(skill).delay;
return lo + Math.random() * (hi - lo);
}
function team(state, seat) { return state.teams[TEAM_OF_SEAT[seat]]; }
function valueOfCards(cards) { return cards.reduce((s, c) => s + cardScore(c), 0); }
function groupNaturalsByRank(cards) {
const g = {};
for (const c of cards) {
if (isWild(c) || !isMeldable(c)) continue;
(g[c.rank] ||= []).push(c);
}
return g;
}
// ── Core meld builder (no minimum gate) ──────────────────────────────────────
// Greedily proposes meld actions against a working copy of the seat's hand:
// extend the team's existing melds, then form fresh melds from triples, then
// 2-natural + wild starters. Returns { actions, hand, usedIds }.
function buildMeldActions(state, seat, p) {
const t = team(state, seat);
const hand = state.players[seat].hand.slice();
const used = new Set();
const actions = [];
const remaining = () => hand.filter((c) => !used.has(c.id));
const wilds = () => remaining().filter((c) => isWild(c));
const take = (cards) => cards.forEach((c) => used.add(c.id));
// 1. Extend existing team melds with matching naturals.
for (const m of t.melds) {
const adds = remaining().filter((c) => c.rank === m.rank && !isWild(c));
if (adds.length) { actions.push({ rank: m.rank, cardIds: adds.map((c) => c.id) }); take(adds); }
}
// 2. Fresh melds from ranks with three or more naturals.
let groups = groupNaturalsByRank(remaining());
for (const rank of Object.keys(groups)) {
if (teamMeld(state, seat, rank)) continue;
const nat = groups[rank];
if (nat.length >= 3) { actions.push({ rank, cardIds: nat.map((c) => c.id) }); take(nat); }
}
// 3. Two naturals + a spare wild → a starter meld (2 naturals ≥ 1 wild is legal).
groups = groupNaturalsByRank(remaining());
for (const rank of Object.keys(groups)) {
if (teamMeld(state, seat, rank)) continue;
const nat = groups[rank];
const spareWild = wilds();
if (nat.length === 2 && spareWild.length >= 1 && (t.hasMelded || p.aggression > 0.4)) {
const cards = [...nat, spareWild[0]];
actions.push({ rank, cardIds: cards.map((c) => c.id) }); take(cards);
}
}
return { actions, hand, used };
}
const actionsValue = (actions, hand) =>
actions.reduce((s, a) => s + a.cardIds.reduce((ss, id) => ss + cardScore(hand.find((c) => c.id === id)), 0), 0);
// Simulate the exact turn that would follow a pile-take (take → real chooseMelds
// → meld) and confirm it ends in a legal discard. Using the real chooseMelds path
// guarantees the prediction matches what the turn will actually do, so the AI
// never takes a pile it cannot legally meld off or that would empty its hand with
// no card left to discard (this engine ends a hand only via the final discard).
export function takeYieldsLegalTurn(state, seat, skill, plan) {
let sim = takeDiscard(state, plan);
const { actions } = chooseMelds(sim, seat, skill);
for (const a of actions) sim = meld(sim, a.rank, a.cardIds);
if (!meetsInitialMeld(sim, seat)) return false; // under the initial minimum
return sim.players[seat].hand.length >= 1; // a card remains to discard
}
// ── Draw decision ──────────────────────────────────────────────────────────────
export function chooseDraw(state, seat, skill) {
const p = prof(skill);
const plan = takePlan(state, seat);
if (plan && takeYieldsLegalTurn(state, seat, skill, plan)) {
const t = team(state, seat);
const pileSize = state.discard.length;
const chance = !t.hasMelded
? 0.6 + p.aggression * 0.4
: ((pileSize >= 3 || plan.useMeld || pileSize >= 2) ? 0.5 + p.aggression * 0.5 : 0);
if (Math.random() < chance) return { type: 'take', plan };
}
return { type: 'stock' };
}
// ── Meld planning ──────────────────────────────────────────────────────────────
export function chooseMelds(state, seat, skill) {
const p = prof(skill);
const t = team(state, seat);
const { actions, hand, used } = buildMeldActions(state, seat, p);
const preValue = valueOfCards(state.meldedThisTurn); // captured from a pile-take this turn
const minNeeded = minimumMeld(t.score);
// Initial-meld gate: if the team hasn't melded and even everything on the table
// plus these actions can't clear the minimum, lay nothing extra this turn.
if (!t.hasMelded && preValue + actionsValue(actions, hand) < minNeeded) {
return { actions: [] };
}
const remaining = () => hand.filter((c) => !used.has(c.id));
const wilds = () => remaining().filter((c) => isWild(c));
// Push a near-complete meld up to a canasta with a wild or two.
if (p.pushCanasta) {
for (const a of actions) {
const m = teamMeld(state, seat, a.rank);
let nat = (m ? meldNaturals(m) : 0) + a.cardIds.filter((id) => !isWild(hand.find((c) => c.id === id))).length;
let wild = (m ? meldWilds(m) : 0) + a.cardIds.filter((id) => isWild(hand.find((c) => c.id === id))).length;
let total = nat + wild;
const avail = wilds();
let i = 0;
while (total < CANASTA_SIZE && total >= CANASTA_SIZE - 2 && wild < MAX_WILDS_IN_MELD
&& nat > wild && i < avail.length) {
a.cardIds.push(avail[i].id); used.add(avail[i].id); i++; wild++; total++;
}
}
}
// Will the team hold a canasta once these actions land?
const willHaveCanasta = () => {
if (t.melds.some(isCanasta)) return true;
for (const a of actions) {
const m = teamMeld(state, seat, a.rank);
if ((m ? m.cards.length : 0) + a.cardIds.length >= CANASTA_SIZE) return true;
}
return false;
};
// Keep at least one card to discard; only deplete to a single card when the
// team will then hold a canasta (a legal go-out).
if (!(willHaveCanasta() && remaining().length === 1)) {
while (actions.length && remaining().length < 2) {
const a = actions.pop();
a.cardIds.forEach((id) => used.delete(id));
}
if (!t.hasMelded && preValue + actionsValue(actions, hand) < minNeeded) return { actions: [] };
}
return { actions };
}
// ── Discard decision ───────────────────────────────────────────────────────────
export function chooseDiscard(state, seat, skill) {
const p = prof(skill);
const hand = state.players[seat].hand;
if (hand.length === 0) return null;
if (hand.length === 1) return hand[0].id; // forced (going out / last card)
const counts = {};
for (const c of hand) if (!isWild(c)) counts[c.rank] = (counts[c.rank] || 0) + 1;
// Lower score = better discard candidate. Scored once per card.
const score = (c) => {
if (isWild(c)) return 1000; // never throw wilds
if (teamMeld(state, seat, c.rank)) return 800; // could be laid off — keep
let s = (counts[c.rank] || 1) * 30; // keep pairs/triples
s += cardScore(c); // shed cheap cards first
if (isBlackThree(c)) s -= 18; // black threes block the pile
s += (Math.random() - 0.5) * 2 * p.noise * 12; // skill noise
return s;
};
let best = hand[0], bestS = score(best);
for (const c of hand) { const s = score(c); if (s < bestS) { best = c; bestS = s; } }
return best.id;
}

View File

@ -0,0 +1,132 @@
// Canasta — static data, scoring helpers and table geometry. No Phaser, no
// state: imported by the logic engine, the AI, the Phaser scene and the headless
// verify harness alike.
//
// Classic partnership Canasta: two decks + four jokers (108 cards), four players
// in two fixed partnerships (seats 0 & 2 vs seats 1 & 3), race to 5000 points.
import { SUITS, RANKS, Card } from '../cards/Deck.js';
export { Card };
export const ICON_FRAME = 65;
// ── Rules ────────────────────────────────────────────────────────────────────
export const DECKS = 2; // standard decks in the shoe
export const JOKERS = 4; // jokers added on top → 108 cards total
export const PLAYER_COUNT = 4;
export const INITIAL_HAND = 11; // cards dealt to each player
export const WIN_SCORE = 5000; // cumulative team score that ends the match
export const CANASTA_SIZE = 7; // a meld of seven cards is a canasta
export const MAX_WILDS_IN_MELD = 3; // a meld may hold at most three wild cards
export const MIN_MELD_CARDS = 3; // a fresh meld is at least three cards
// Bonus values (added to a team's hand score).
export const NATURAL_CANASTA = 500; // a canasta with no wild cards (pure)
export const MIXED_CANASTA = 300; // a canasta containing 13 wild cards
export const RED_THREE = 100; // each red three laid out
export const ALL_RED_THREES = 800; // a team holding all four (200 each)
export const GO_OUT = 100; // bonus for the player who goes out
export const CONCEALED_GO_OUT = 200; // going out in one turn having never melded
// ── Card classifiers ─────────────────────────────────────────────────────────
/** Wild card: a joker or any two. */
export function isWild(card) {
return card.rank === 'JK' || card.rank === '2';
}
/** Red three — a bonus card, laid out immediately, never melded or discarded. */
export function isRedThree(card) {
return card.rank === '3' && (card.suit === 'h' || card.suit === 'd');
}
/** Black three — only ever melded when going out (omitted here); blocks the pile. */
export function isBlackThree(card) {
return card.rank === '3' && (card.suit === 's' || card.suit === 'c');
}
/** A natural card that may form/extend a rank meld (4..A, not 3, not wild). */
export function isMeldable(card) {
return !isWild(card) && card.rank !== '3';
}
/**
* Point value of a single card, used for melds, the initial-meld minimum and
* the deduction for cards left in hand. Red threes score as a separate bonus.
*/
export function cardScore(card) {
if (card.rank === 'JK') return 50; // joker
if (card.rank === '2' || card.rank === 'A') return 20;
if (card.rank === '3') return 5; // (black threes; red threes bonus-only)
const v = card.value; // 4..K → 4..13
if (v >= 8) return 10; // 8,9,10,J,Q,K
return 5; // 4,5,6,7
}
/** Minimum total card value required for a team's very first meld of the game. */
export function minimumMeld(teamScore) {
if (teamScore < 0) return 15;
if (teamScore < 1500) return 50;
if (teamScore < 3000) return 90;
return 120;
}
// ── Theme (luxe casino card room) ─────────────────────────────────────────────
export const THEME = {
feltTop: 0x1c6b3a, // emerald baize, lit centre
feltMid: 0x0f4a27, // mid green
feltEdge: 0x062c16, // deep green near the rail
rail: 0x4a1410, // burgundy leather rail
railHi: 0x6e201a, // lit burgundy
railDark: 0x2c0b09, // rail shadow
brass: 0xc9a227, // brass trim
brassHi: 0xf0d77a, // brass highlight
gold: 0xd4a017,
cardFace: 0xfbf6e7,
cardBack: 0x3a2a6b, // deep indigo card back
cardBackHi: 0x6c5bb0,
redThree: 0xe0b24a, // glow for red threes
team0: 0xf0d77a, // your team accent (gold)
team1: 0xe9e1cf, // their team accent (ivory)
text: 0xf2ead8,
suitRed: '#c0392b',
suitBlk: '#1a1208',
jokerHex: '#7b3fb0',
};
// ── Table geometry ────────────────────────────────────────────────────────────
// Four seats around the felt. Seat 0 = South (human), 1 = West, 2 = North
// (human's partner), 3 = East. Returns the anchors the scene needs to lay out
// hands, the central stock/discard and the two team meld strips.
export function buildTableLayout(rect) {
const { x, y, w, h } = rect;
const cx = x + w / 2;
const cy = y + h / 2;
return {
cx, cy,
// hand anchors (centre of each player's fanned hand)
seats: {
0: { x: cx, y: y + h - 96, dir: 'h', label: { x: x + 40, y: y + h - 150, align: 'left' } },
1: { x: x + 150, y: cy - 40, dir: 'v', label: { x: x + 40, y: cy - 220, align: 'left' } },
2: { x: cx, y: y + 96, dir: 'h', label: { x: x + 40, y: y + 40, align: 'left' } },
3: { x: x + w - 150, y: cy - 40, dir: 'v', label: { x: x + w - 40, y: cy - 220, align: 'right' } },
},
// central piles
stock: { x: cx - 70, y: cy },
discard: { x: cx + 70, y: cy },
// team meld strips (team 0 below centre near the human, team 1 above)
meldZones: {
0: { x: cx, y: cy + 150, label: { x: x + 40, y: cy + 120 } },
1: { x: cx, y: cy - 230, label: { x: x + 40, y: cy - 260 } },
},
// team score panels
panels: {
0: { x: x + w - 250, y: y + h - 120 },
1: { x: x + w - 250, y: y + 60 },
},
};
}
// Convenience: the four standard seats and which team each belongs to.
export const TEAM_OF_SEAT = [0, 1, 0, 1];
export const SEATS_OF_TEAM = [[0, 2], [1, 3]];
// Re-export for callers that build the deck.
export { SUITS, RANKS };

View File

@ -0,0 +1,643 @@
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 { api } from '../../services/api.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { THEME, TEAM_OF_SEAT, isWild, isRedThree, minimumMeld } from './CanastaData.js';
import {
createInitialState, drawStock, takeDiscard, meld, discard, startNextHand,
takePlan, teamMeld, isCanasta, isNaturalCanasta, meetsInitialMeld, teamHasCanasta,
} from './CanastaLogic.js';
import { chooseDraw, chooseMelds, chooseDiscard, thinkDelay, takeYieldsLegalTurn } from './CanastaAI.js';
// ── Geometry ──────────────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2;
const CY = 470;
const CARD_W = 74, CARD_H = 104, CARD_R = 8;
const HAND_GAP = 30; // overlap step for the human fan
const MINI = 0.6; // scale for meld / opponent cards
const STOCK = { x: CX - 96, y: CY };
const DISCARD = { x: CX + 96, y: CY };
const SEAT = {
0: { hand: { x: CX, y: 930 }, port: { x: 150, y: 930 }, name: { x: 230, y: 858 } },
1: { hand: { x: 96, y: CY }, port: { x: 110, y: 215 }, name: { x: 110, y: 285 } },
2: { hand: { x: CX, y: 78 }, port: { x: 150, y: 96 }, name: { x: 230, y: 150 } },
3: { hand: { x: GAME_WIDTH - 96, y: CY }, port: { x: GAME_WIDTH - 110, y: 215 }, name: { x: GAME_WIDTH - 110, y: 285 } },
};
const MELD_ZONE = { 0: { y: 690 }, 1: { y: 282 } };
const D = { felt: -4, glow: -3, rail: -2, oval: -1, zone: 0, pile: 4, card: 10, cardText: 11, ui: 30, toast: 60, modal: 80, modalUI: 82 };
const HEX = (n) => '#' + n.toString(16).padStart(6, '0');
export default class CanastaGame extends Phaser.Scene {
constructor() { super('CanastaGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'canasta', name: 'Canasta' };
this.opponents = data.opponents ?? [];
// Seats 1, 2, 3 are the three AI players; seat 2 is the human's partner.
this.seatOpp = { 1: this.opponents[0], 2: this.opponents[1], 3: this.opponents[2] };
this.seatSkill = {
0: 5,
1: this.clampSkill(this.opponents[0]?.skill),
2: this.clampSkill(this.opponents[1]?.skill),
3: this.clampSkill(this.opponents[2]?.skill),
};
this.seatName = {
0: 'You',
1: this.opponents[0]?.name ?? 'West',
2: this.opponents[1]?.name ?? 'Partner',
3: this.opponents[2]?.name ?? 'East',
};
this.state = createInitialState();
this.selected = new Set();
this.busy = false;
this.recorded = false;
this.handObjs = { 0: [], 1: [], 2: [], 3: [] };
this.meldObjs = [];
this.pileObjs = [];
this.portraits = {};
}
clampSkill(s) { return Math.max(1, Math.min(5, s ?? 3)); }
create() {
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ }
this.buildBackdrop();
this.buildCenterLabels();
this.buildPanels();
this.buildPortraits();
this.statusText = this.add.text(CX, 600, '', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui).setShadow(0, 2, '#000', 6);
this.buildButtons();
this.renderDynamic();
this.advance();
}
// ── Backdrop: luxe felt with a spotlight, burgundy rail and brass oval ────────
buildBackdrop() {
const g = this.add.graphics().setDepth(D.felt);
g.fillGradientStyle(THEME.feltMid, THEME.feltMid, THEME.feltEdge, THEME.feltEdge, 1);
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
// Soft radial spotlight over the table centre (stacked translucent ellipses).
const glow = this.add.graphics().setDepth(D.glow);
for (let i = 8; i >= 1; i--) {
glow.fillStyle(THEME.feltTop, 0.05);
glow.fillEllipse(CX, CY, 1500 * (i / 8), 1020 * (i / 8));
}
// Burgundy leather rail around the playing oval.
const rail = this.add.graphics().setDepth(D.rail);
rail.lineStyle(40, THEME.rail, 1);
rail.strokeEllipse(CX, CY + 30, 1640, 1180);
rail.lineStyle(40, THEME.railHi, 0.25);
rail.strokeEllipse(CX, CY + 22, 1640, 1180);
// Brass trim lines.
const oval = this.add.graphics().setDepth(D.oval);
oval.lineStyle(3, THEME.brass, 0.9);
oval.strokeEllipse(CX, CY + 30, 1560, 1100);
oval.lineStyle(1.5, THEME.brassHi, 0.5);
oval.strokeEllipse(CX, CY + 30, 1548, 1088);
this.add.text(CX, 32, 'C A N A S T A', {
fontFamily: 'Righteous', fontSize: '30px', color: HEX(THEME.brassHi),
}).setOrigin(0.5, 0).setDepth(D.ui).setAlpha(0.55);
}
buildCenterLabels() {
this.add.text(STOCK.x, CY - 92, 'STOCK', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.add.text(DISCARD.x, CY - 92, 'DISCARD', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.stockCount = this.add.text(STOCK.x, CY + 92, '', {
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
this.frozenBadge = this.add.text(DISCARD.x, CY + 92, '❄ FROZEN', {
fontFamily: 'Righteous', fontSize: '17px', color: '#8fd3ff',
}).setOrigin(0.5).setDepth(D.ui).setVisible(false);
}
// ── Team score panels ────────────────────────────────────────────────────────
buildPanels() {
this.panel = {};
const make = (teamId, x, y, accent, title) => {
const g = this.add.graphics().setDepth(D.ui);
g.fillStyle(0x000000, 0.42); g.fillRoundedRect(x, y, 290, 96, 12);
g.lineStyle(2, accent, 0.9); g.strokeRoundedRect(x, y, 290, 96, 12);
this.add.text(x + 18, y + 14, title, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: HEX(accent),
}).setDepth(D.ui);
const score = this.add.text(x + 18, y + 40, '0', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
}).setDepth(D.ui);
const sub = this.add.text(x + 270, y + 54, '', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(1, 0.5).setDepth(D.ui);
this.panel[teamId] = { score, sub };
};
make(0, GAME_WIDTH - 320, GAME_HEIGHT - 130, THEME.team0, 'YOUR TEAM');
make(1, GAME_WIDTH - 320, 30, THEME.team1, 'OPPONENTS');
}
buildPortraits() {
for (const seat of [0, 1, 2, 3]) {
const pos = SEAT[seat].port;
this.portraits[seat] = seat === 0
? createPlayerPortrait(this, pos.x, pos.y, 46, D.ui, 'CanastaGame')
: createOpponentPortrait(this, this.seatOpp[seat], pos.x, pos.y, 46, D.ui, { playIntro: seat === 2 });
const n = SEAT[seat].name;
const partnerTag = TEAM_OF_SEAT[seat] === 0 && seat !== 0 ? ' ◆' : '';
this.add.text(n.x, n.y, this.seatName[seat].toUpperCase() + partnerTag, {
fontFamily: 'Righteous', fontSize: '18px',
color: HEX(TEAM_OF_SEAT[seat] === 0 ? THEME.team0 : THEME.team1),
}).setOrigin(seat === 3 ? 1 : 0, 0.5).setDepth(D.ui);
// Glow ring used to mark whose turn it is.
const ring = this.add.graphics().setDepth(D.ui - 1).setVisible(false);
ring.lineStyle(5, THEME.brassHi, 0.95); ring.strokeCircle(pos.x, pos.y, 52);
this.portraits[seat].ring = ring;
}
}
buildButtons() {
const y = 1042;
this.btn = {};
this.btn.draw = new Button(this, CX - 360, y, 'Draw Stock', () => this.humanDraw('stock'),
{ width: 220, height: 56, fontSize: 22 }).setDepth(D.ui);
this.btn.take = new Button(this, CX - 125, y, 'Take Pile', () => this.humanDraw('take'),
{ width: 220, height: 56, fontSize: 22 }).setDepth(D.ui);
this.btn.meld = new Button(this, CX + 110, y, 'Meld', () => this.humanMeld(),
{ width: 200, height: 56, fontSize: 22 }).setDepth(D.ui);
this.btn.discard = new Button(this, CX + 330, y, 'Discard', () => this.humanDiscard(),
{ width: 200, height: 56, fontSize: 22 }).setDepth(D.ui);
this.btn.next = new Button(this, CX, y, 'Next Hand', () => this.onNextHand(),
{ width: 280, height: 56, fontSize: 24 }).setDepth(D.ui).setVisible(false);
this.btn.leave = new Button(this, GAME_WIDTH - 110, 44, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 160, height: 48, fontSize: 20 }).setDepth(D.ui);
this.hideActionButtons();
}
hideActionButtons() {
['draw', 'take', 'meld', 'discard'].forEach((k) => this.btn[k].setVisible(false));
}
// ── Card rendering ───────────────────────────────────────────────────────────
drawFace(container, card, faceUp, scale = 1) {
const w = CARD_W * scale, h = CARD_H * scale, r = CARD_R * scale;
const x = -w / 2, y = -h / 2;
const g = this.add.graphics();
if (!faceUp) {
g.fillStyle(THEME.cardBack, 1); g.fillRoundedRect(x, y, w, h, r);
g.lineStyle(3 * scale, THEME.brass, 0.85); g.strokeRoundedRect(x + 4 * scale, y + 4 * scale, w - 8 * scale, h - 8 * scale, r);
for (let i = -1; i <= 1; i++) {
g.fillStyle(THEME.cardBackHi, 0.5);
g.fillRoundedRect(-5 * scale, y + h * (0.5 + i * 0.22) - 5 * scale, 10 * scale, 10 * scale, 2);
}
container.add(g);
return;
}
g.fillStyle(THEME.cardFace, 1); g.fillRoundedRect(x, y, w, h, r);
g.lineStyle(2, 0xcc9a3a, 0.5); g.strokeRoundedRect(x + 2, y + 2, w - 4, h - 4, r - 1);
container.add(g);
if (card.rank === 'JK') {
container.add(this.add.text(0, -h * 0.22, '★', { fontFamily: 'serif', fontSize: `${36 * scale}px`, color: THEME.jokerHex }).setOrigin(0.5));
container.add(this.add.text(0, h * 0.18, 'JOKER', { fontFamily: 'Righteous', fontSize: `${13 * scale}px`, color: THEME.jokerHex }).setOrigin(0.5));
return;
}
const col = card.isRed ? THEME.suitRed : THEME.suitBlk;
const sym = card.suitSymbol;
container.add(this.add.text(x + 7 * scale, y + 4 * scale, card.label, { fontFamily: 'Righteous', fontSize: `${20 * scale}px`, color: col }));
container.add(this.add.text(x + 8 * scale, y + 27 * scale, sym, { fontFamily: 'sans-serif', fontSize: `${17 * scale}px`, color: col }));
container.add(this.add.text(0, 3 * scale, sym, { fontFamily: 'sans-serif', fontSize: `${40 * scale}px`, color: col }).setOrigin(0.5));
container.add(this.add.text(x + w - 7 * scale, y + h - 4 * scale, card.label, { fontFamily: 'Righteous', fontSize: `${20 * scale}px`, color: col }).setOrigin(1, 1));
if (card.rank === '2') {
container.add(this.add.text(0, h * 0.32, 'WILD', { fontFamily: 'Righteous', fontSize: `${10 * scale}px`, color: HEX(THEME.brass) }).setOrigin(0.5));
}
}
makeCard(card, x, y, { faceUp = true, scale = 1, depth = D.card } = {}) {
const c = this.add.container(x, y).setDepth(depth);
c.cardRef = card;
this.drawFace(c, card, faceUp, scale);
return c;
}
// ── Dynamic layout (hands, melds, piles) ─────────────────────────────────────
renderDynamic() {
for (const seat of [0, 1, 2, 3]) { this.handObjs[seat].forEach((o) => o.destroy()); this.handObjs[seat] = []; }
this.meldObjs.forEach((o) => o.destroy()); this.meldObjs = [];
this.pileObjs.forEach((o) => o.destroy()); this.pileObjs = [];
this.renderPiles();
this.renderHands();
this.renderMelds();
this.renderPanels();
this.updateTurnRing();
}
renderPiles() {
const s = this.state;
// Stock as a neat stack of backs.
const n = Math.min(6, Math.ceil(s.stock.length / 12));
for (let i = 0; i < n; i++) {
this.pileObjs.push(this.makeCard(null, STOCK.x - i * 2, STOCK.y - i * 2, { faceUp: false, depth: D.pile + i }));
}
this.stockCount.setText(`${s.stock.length} left`);
// Discard pile: a slightly fanned stack, top card face up.
const dn = Math.min(5, s.discard.length);
for (let i = 0; i < dn; i++) {
const card = s.discard[s.discard.length - dn + i];
const top = i === dn - 1;
this.pileObjs.push(this.makeCard(card, DISCARD.x + (i - dn) * 3, DISCARD.y + (i - dn) * 2, { faceUp: top, depth: D.pile + i }));
}
this.frozenBadge.setVisible(s.frozen);
}
renderHands() {
const s = this.state;
// Human hand — face up, fanned, sorted for readability.
const me = s.players[0].hand.slice().sort(this.handSort);
const total = (me.length - 1) * HAND_GAP + CARD_W;
me.forEach((card, i) => {
const x = SEAT[0].hand.x - total / 2 + CARD_W / 2 + i * HAND_GAP;
const sel = this.selected.has(card.id);
const c = this.makeCard(card, x, SEAT[0].hand.y - (sel ? 26 : 0), { depth: D.card + i });
c.baseY = SEAT[0].hand.y;
if (isRedThree(card)) this.glowCard(c);
this.makeInteractive(c, () => this.toggleSelect(card.id));
this.handObjs[0].push(c);
});
// Opponents / partner — face-down stacks (count only).
for (const seat of [1, 2, 3]) {
const hand = s.players[seat].hand;
const horiz = seat === 2;
const count = hand.length;
const step = horiz ? 22 : 0;
const vstep = horiz ? 0 : 16;
const span = horiz ? (count - 1) * step : (count - 1) * vstep;
for (let i = 0; i < count; i++) {
const x = SEAT[seat].hand.x + (horiz ? i * step - span / 2 : 0);
const y = SEAT[seat].hand.y + (horiz ? 0 : i * vstep - span / 2);
this.handObjs[seat].push(this.makeCard(null, x, y, { faceUp: false, scale: MINI, depth: D.card + i }));
}
this.handObjs[seat].push(this.add.text(SEAT[seat].hand.x, SEAT[seat].hand.y + (horiz ? 52 : 0),
horiz ? `${count}` : `${count}`, { fontFamily: 'Righteous', fontSize: '16px', color: COLORS.mutedHex })
.setOrigin(0.5).setDepth(D.ui));
}
}
renderMelds() {
for (const teamId of [0, 1]) {
const melds = this.state.teams[teamId].melds;
const reds = this.state.teams[teamId].redThrees.length;
const y = MELD_ZONE[teamId].y;
const groupW = 150;
const totalW = Math.max(1, melds.length + (reds ? 1 : 0)) * groupW;
let gx = CX - totalW / 2 + groupW / 2;
for (const m of melds) { this.renderMeldGroup(m, gx, y); gx += groupW; }
if (reds) {
const badge = this.add.container(gx, y).setDepth(D.zone);
const g = this.add.graphics();
g.fillStyle(0x000000, 0.3); g.fillRoundedRect(-58, -34, 116, 68, 10);
g.lineStyle(2, THEME.redThree, 0.9); g.strokeRoundedRect(-58, -34, 116, 68, 10);
badge.add(g);
badge.add(this.add.text(0, -8, `♥♦ ×${reds}`, { fontFamily: 'Righteous', fontSize: '22px', color: '#e0556a' }).setOrigin(0.5));
badge.add(this.add.text(0, 18, 'red threes', { fontFamily: '"Julius Sans One"', fontSize: '12px', color: COLORS.mutedHex }).setOrigin(0.5));
this.meldObjs.push(badge);
}
}
}
renderMeldGroup(m, x, y) {
const cont = this.add.container(x, y).setDepth(D.zone);
const canasta = isCanasta(m);
if (canasta) {
const ring = this.add.graphics();
const col = isNaturalCanasta(m) ? THEME.brassHi : 0xcfd6e0;
ring.fillStyle(col, 0.16); ring.fillRoundedRect(-66, -46, 132, 96, 14);
ring.lineStyle(3, col, 0.95); ring.strokeRoundedRect(-66, -46, 132, 96, 14);
cont.add(ring);
}
const step = 17;
const span = (m.cards.length - 1) * step;
m.cards.forEach((card, i) => {
const mc = this.add.container(i * step - span / 2, -6).setDepth(D.zone);
this.drawFace(mc, card, true, MINI);
cont.add(mc);
});
const label = canasta ? (isNaturalCanasta(m) ? '★ CANASTA' : 'CANASTA') : `${m.cards.length}`;
cont.add(this.add.text(0, 38, label, {
fontFamily: 'Righteous', fontSize: canasta ? '14px' : '15px',
color: canasta ? HEX(isNaturalCanasta(m) ? THEME.brassHi : 0xcfd6e0) : COLORS.mutedHex,
}).setOrigin(0.5));
this.meldObjs.push(cont);
}
renderPanels() {
for (const teamId of [0, 1]) {
const t = this.state.teams[teamId];
this.panel[teamId].score.setText(String(t.score));
const canastas = t.melds.filter(isCanasta).length;
this.panel[teamId].sub.setText(t.hasMelded ? `${canastas} canasta${canastas === 1 ? '' : 's'}` : 'not melded');
}
}
updateTurnRing() {
for (const seat of [0, 1, 2, 3]) {
const active = this.state.currentPlayer === seat && !['handOver', 'gameOver'].includes(this.state.phase);
this.portraits[seat].ring.setVisible(active);
}
}
glowCard(c) { try { c.postFX.addGlow(THEME.redThree, 4, 0, false, 0.1, 8); } catch { /* fx optional */ } }
handSort(a, b) {
const w = (c) => (isWild(c) ? 100 : isRedThree(c) ? 99 : (c.value || 0));
return w(a) - w(b) || a.suit.localeCompare(b.suit);
}
makeInteractive(c, handler) {
c.setSize(CARD_W, CARD_H);
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.on('pointerdown', handler);
}
// ── Turn flow ────────────────────────────────────────────────────────────────
advance() {
this.updateTurnRing();
const s = this.state;
if (s.phase === 'gameOver') return this.showGameOver();
if (s.phase === 'handOver') return this.showHandOver();
if (s.currentPlayer === 0) return this.enableHumanTurn();
this.runAiTurn();
}
async runAiTurn() {
this.busy = true;
this.hideActionButtons();
const seat = this.state.currentPlayer;
const skill = this.seatSkill[seat];
this.statusText.setText(`${this.seatName[seat]} is thinking…`);
await this.delay(thinkDelay(skill));
// Draw.
const draw = chooseDraw(this.state, seat, skill);
if (draw.type === 'take') {
this.state = takeDiscard(this.state, draw.plan);
this.toast(`${this.seatName[seat]} takes the pile`, DISCARD.x, DISCARD.y - 80);
playSound(this, SFX.CARD_SHOW);
} else {
this.state = drawStock(this.state);
playSound(this, SFX.CARD_DEAL);
}
this.renderDynamic();
if (['handOver', 'gameOver'].includes(this.state.phase)) { this.busy = false; return this.advance(); }
await this.delay(420);
// Meld.
const { actions } = chooseMelds(this.state, seat, skill);
for (const a of actions) {
const before = this.state.teams[TEAM_OF_SEAT[seat]].melds.find((m) => m.rank === a.rank);
const wasCanasta = before ? isCanasta(before) : false;
this.state = meld(this.state, a.rank, a.cardIds);
const after = this.state.teams[TEAM_OF_SEAT[seat]].melds.find((m) => m.rank === a.rank);
this.renderDynamic();
playSound(this, SFX.CARD_PLACE);
if (after && isCanasta(after) && !wasCanasta) this.toast('CANASTA!', CX, MELD_ZONE[TEAM_OF_SEAT[seat]].y - 70, '#f0d77a');
await this.delay(360);
}
// Discard (ends the turn, or the hand if it empties).
if (this.state.phase === 'meld') {
const cardId = chooseDiscard(this.state, seat, skill);
this.state = discard(this.state, cardId);
this.renderDynamic();
playSound(this, SFX.CARD_PLACE);
await this.delay(360);
}
this.busy = false;
this.advance();
}
// ── Human turn ───────────────────────────────────────────────────────────────
enableHumanTurn() {
this.busy = false;
const s = this.state;
if (s.phase === 'draw') {
this.statusText.setText('Draw from the stock, or take the discard pile');
this.btn.draw.setVisible(true).setEnabled(true);
const plan = takePlan(s, 0);
const t = s.teams[0];
const legal = !!plan && (t.hasMelded || takeYieldsLegalTurn(s, 0, 5, plan));
this.btn.take.setVisible(true).setEnabled(legal);
this.btn.meld.setVisible(false);
this.btn.discard.setVisible(false);
} else if (s.phase === 'meld') {
this.updateMeldPhaseUi();
}
}
updateMeldPhaseUi() {
const s = this.state;
const t = s.teams[0];
const need = !t.hasMelded;
this.statusText.setText(need
? `Select cards to meld (first meld must total ${this.minNeeded()} pts), then discard one`
: 'Meld more, or select one card and discard');
this.btn.draw.setVisible(false);
this.btn.take.setVisible(false);
this.btn.meld.setVisible(true).setEnabled(this.selected.size >= 1);
this.btn.discard.setVisible(true).setEnabled(this.selected.size === 1 && this.discardAllowed());
}
minNeeded() {
return minimumMeld(this.state.teams[0].score);
}
discardAllowed() {
// Mirror the engine: an under-minimum initial meld can't be closed with a discard.
const t = this.state.teams[0];
if (!t.hasMelded && this.state.meldedThisTurn.length > 0 && !meetsInitialMeld(this.state, 0)) return false;
// Going out requires a canasta.
if (this.state.players[0].hand.length === 1 && !teamHasCanasta(this.state, 0)) return false;
return true;
}
toggleSelect(id) {
if (this.busy || this.state.currentPlayer !== 0 || this.state.phase !== 'meld') {
// Selection only matters in the meld phase.
if (this.state.phase !== 'meld') return;
}
if (this.selected.has(id)) this.selected.delete(id); else this.selected.add(id);
playSound(this, SFX.PIECE_CLICK);
this.renderDynamic();
this.updateMeldPhaseUi();
}
humanDraw(kind) {
if (this.busy) return;
if (kind === 'stock') {
this.state = drawStock(this.state);
playSound(this, SFX.CARD_DEAL);
} else {
const plan = takePlan(this.state, 0);
if (!plan) return;
this.state = takeDiscard(this.state, plan);
playSound(this, SFX.CARD_SHOW);
}
this.selected.clear();
this.renderDynamic();
this.advance();
}
humanMeld() {
if (this.busy || this.selected.size === 0) return;
const ids = [...this.selected];
const cards = ids.map((id) => this.state.players[0].hand.find((c) => c.id === id)).filter(Boolean);
const naturals = cards.filter((c) => !isWild(c));
let rank = naturals[0]?.rank;
if (rank && !naturals.every((c) => c.rank === rank)) return this.toast('Pick one rank to meld', CX, 560, '#e06c75');
if (!rank) {
// All wilds — only valid as an addition to an existing single team meld.
const melds = this.state.teams[0].melds;
if (melds.length !== 1) return this.toast('Add wilds to a specific meld', CX, 560, '#e06c75');
rank = melds[0].rank;
}
const next = meld(this.state, rank, ids);
if (next === this.state) return this.toast('Not a legal meld', CX, 560, '#e06c75');
this.state = next;
this.selected.clear();
playSound(this, SFX.CARD_PLACE);
this.renderDynamic();
this.updateMeldPhaseUi();
}
humanDiscard() {
if (this.busy || this.selected.size !== 1) return;
if (!this.discardAllowed()) {
return this.toast(`Initial meld needs ${this.minNeeded()} points`, CX, 560, '#e06c75');
}
const id = [...this.selected][0];
const goingOut = this.state.players[0].hand.length === 1;
this.state = discard(this.state, id);
this.selected.clear();
playSound(this, SFX.CARD_PLACE);
if (goingOut) this.toast('You go out!', CX, 560, '#f0d77a');
this.renderDynamic();
this.advance();
}
// ── Hand-over & game-over ────────────────────────────────────────────────────
showHandOver() {
this.hideActionButtons();
this.updateTurnRing();
const scores = this.state.handScores;
const w = 760, h = 420, x = CX - w / 2, y = CY - h / 2 + 20;
const layer = [];
const g = this.add.graphics().setDepth(D.modal);
g.fillStyle(0x000000, 0.6); g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
g.fillStyle(THEME.feltEdge, 1); g.fillRoundedRect(x, y, w, h, 20);
g.lineStyle(3, THEME.brass, 1); g.strokeRoundedRect(x, y, w, h, 20);
layer.push(g);
layer.push(this.add.text(CX, y + 36, 'Hand Over', { fontFamily: 'Righteous', fontSize: '40px', color: HEX(THEME.brassHi) }).setOrigin(0.5).setDepth(D.modalUI));
const names = ['YOUR TEAM', 'OPPONENTS'];
[0, 1].forEach((teamId, idx) => {
const d = scores[idx];
const col = idx === 0 ? THEME.team0 : THEME.team1;
const px = x + 60 + idx * (w / 2 - 20);
layer.push(this.add.text(px, y + 92, names[idx], { fontFamily: '"Julius Sans One"', fontSize: '22px', color: HEX(col) }).setDepth(D.modalUI));
const lines = [
`Melds ${d.meldPoints}`,
`Canasta bonus ${d.canastaBonus}`,
`Red threes ${d.redThreeBonus}`,
d.goOut ? `Go out ${d.goOut}` : null,
`In hand -${d.handPenalty}`,
`———`,
`Hand total ${d.total}`,
`Match ${this.state.teams[teamId].score}`,
].filter(Boolean);
layer.push(this.add.text(px, y + 128, lines.join('\n'), {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex, lineSpacing: 8,
}).setDepth(D.modalUI));
});
this.handOverLayer = layer;
this.btn.next.setVisible(true);
}
onNextHand() {
this.btn.next.setVisible(false);
(this.handOverLayer || []).forEach((o) => o.destroy());
this.handOverLayer = null;
this.state = startNextHand(this.state);
this.selected.clear();
playSound(this, SFX.CARD_SHUFFLE);
this.renderDynamic();
this.advance();
}
showGameOver() {
this.hideActionButtons();
this.btn.next.setVisible(false);
this.updateTurnRing();
(this.handOverLayer || []).forEach((o) => o.destroy());
const won = this.state.winnerTeam === 0;
playSound(this, won ? SFX.CASINO_WIN : SFX.CASINO_LOSE);
this.postHistory(won ? 'win' : 'loss');
const g = this.add.graphics().setDepth(D.modal);
g.fillStyle(0x000000, 0.72); g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
g.fillStyle(THEME.feltMid, 1); g.fillRoundedRect(CX - 380, CY - 170, 760, 340, 24);
g.lineStyle(4, THEME.brass, 1); g.strokeRoundedRect(CX - 380, CY - 170, 760, 340, 24);
this.add.text(CX, CY - 96, won ? 'Your Team Wins!' : 'Opponents Win', {
fontFamily: 'Righteous', fontSize: '56px', color: won ? HEX(THEME.brassHi) : HEX(THEME.team1),
}).setOrigin(0.5).setDepth(D.modalUI);
this.add.text(CX, CY - 16, `${this.state.teams[0].score} ${this.state.teams[1].score}`, {
fontFamily: 'Righteous', fontSize: '44px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.modalUI);
new Button(this, CX, CY + 92, 'Back to Menu', () => this.scene.start('GameMenu'),
{ width: 300, fontSize: 26 }).setDepth(D.modalUI);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
toast(text, x, y, color = '#f2ead8') {
const t = this.add.text(x, y, text, {
fontFamily: 'Righteous', fontSize: '30px', color,
}).setOrigin(0.5).setDepth(D.toast).setShadow(0, 0, 'rgba(255,207,74,0.7)', 12);
this.tweens.add({ targets: t, y: y - 50, alpha: 0, duration: 1200, ease: 'Cubic.easeOut', onComplete: () => t.destroy() });
}
async postHistory(result) {
if (this.recorded) return;
this.recorded = true;
try {
await api.post('/history/single-player', {
slug: 'canasta',
score: this.state.teams[0].score,
opponentScores: [this.state.teams[1].score],
result,
});
} catch { /* non-fatal */ }
}
delay(ms) { return new Promise((resolve) => this.time.delayedCall(ms, resolve)); }
}

View File

@ -0,0 +1,497 @@
// Canasta — pure state engine. No Phaser imports.
//
// Classic partnership rules (with a few documented simplifications so the engine
// stays tractable and always terminates):
//
// - Four players, two fixed partnerships: seats 0 & 2 vs seats 1 & 3.
// - 108-card shoe (two decks + four jokers). 11 cards dealt each.
// - A turn is: DRAW (stock, or take the discard pile) → MELD (optional) →
// DISCARD (mandatory, unless you empty your hand to go out).
// - Wild cards: jokers (50) and twos (20). Naturals are 4..A.
// - Red threes are bonus cards: laid out immediately and replaced from stock.
// - A meld is 3+ cards of one rank, at most 3 wild, at least 2 natural.
// Seven cards = a canasta (natural 500 / mixed 300).
// - Taking the pile: the top card must be a natural (not a 3, not wild). You
// take it by holding two matching naturals (always legal), or — only when the
// pile is NOT frozen and your team has already melded — one natural + one wild,
// or by adding it to an existing team meld of that rank. A team that has not
// yet made its initial meld must use two naturals. A wild discard freezes the
// pile; a black three on top blocks it for the next player.
// - To go out a team must own at least one canasta; the going-out player then
// sheds the rest of their hand. The hand also ends if the stock runs out.
//
// All mutating helpers return a NEW state (immutable style, mirrors HeartsLogic).
import {
SUITS, RANKS, Card, PLAYER_COUNT, INITIAL_HAND, WIN_SCORE, CANASTA_SIZE,
MAX_WILDS_IN_MELD, MIN_MELD_CARDS, NATURAL_CANASTA, MIXED_CANASTA, RED_THREE,
ALL_RED_THREES, GO_OUT, CONCEALED_GO_OUT, TEAM_OF_SEAT, SEATS_OF_TEAM,
isWild, isRedThree, isBlackThree, isMeldable, cardScore, minimumMeld,
} from './CanastaData.js';
export {
isWild, isRedThree, isBlackThree, cardScore, minimumMeld, TEAM_OF_SEAT,
SEATS_OF_TEAM, WIN_SCORE,
};
// ── Seedable PRNG (mirrors the other games) ───────────────────────────────────
function rng(seed) {
let a = (seed >>> 0) || 1;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, rand) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
// ── Card construction (jokers are Cards with a synthetic rank) ─────────────────
function makeCard(rank, suit, id) {
const c = new Card(rank, suit);
c.id = id;
return c;
}
function buildShoe() {
const cards = [];
let id = 0;
for (let d = 0; d < 2; d++) {
for (const suit of SUITS) {
for (const rank of RANKS) cards.push(makeCard(rank, suit, id++));
}
}
for (let j = 0; j < 4; j++) cards.push(makeCard('JK', j < 2 ? 'r' : 'b', id++));
return cards;
}
function cloneCard(c) {
const out = new Card(c.rank, c.suit);
out.id = c.id;
return out;
}
// ── Meld helpers ──────────────────────────────────────────────────────────────
function meldNaturals(meld) { return meld.cards.filter((c) => !isWild(c)).length; }
function meldWilds(meld) { return meld.cards.filter((c) => isWild(c)).length; }
function isCanasta(meld) { return meld.cards.length >= CANASTA_SIZE; }
function isNaturalCanasta(meld) { return isCanasta(meld) && meldWilds(meld) === 0; }
/** Total card-value of a meld (cards only, no bonuses). */
function meldValue(meld) { return meld.cards.reduce((s, c) => s + cardScore(c), 0); }
// ── State ─────────────────────────────────────────────────────────────────────
export function cloneState(state) {
return {
players: state.players.map((p) => ({
seat: p.seat,
team: p.team,
hand: p.hand.map(cloneCard),
})),
teams: state.teams.map((t) => ({
melds: t.melds.map((m) => ({ rank: m.rank, cards: m.cards.map(cloneCard) })),
redThrees: t.redThrees.map(cloneCard),
hasMelded: t.hasMelded,
score: t.score,
})),
stock: state.stock.map(cloneCard),
discard: state.discard.map(cloneCard),
frozen: state.frozen,
currentPlayer: state.currentPlayer,
phase: state.phase,
hasDrawn: state.hasDrawn,
meldedThisTurn: state.meldedThisTurn.map((c) => cloneCard(c)),
turnTeamMeldedAtStart: state.turnTeamMeldedAtStart,
dealer: state.dealer,
handNumber: state.handNumber,
handScores: state.handScores ? state.handScores.map((h) => ({ ...h })) : null,
outPlayer: state.outPlayer,
winnerTeam: state.winnerTeam,
seed: state.seed,
log: state.log.map((e) => ({ ...e })),
_rand: state._rand,
};
}
function team(state, seat) { return state.teams[TEAM_OF_SEAT[seat]]; }
/** Pull any red threes out of a hand into the team's row; returns how many. */
function harvestRedThrees(state, seat) {
const p = state.players[seat];
const t = team(state, seat);
let moved = 0;
p.hand = p.hand.filter((c) => {
if (isRedThree(c)) { t.redThrees.push(c); moved++; return false; }
return true;
});
return moved;
}
function dealHand(state, rand) {
const deck = buildShoe();
shuffle(deck, rand);
for (let seat = 0; seat < PLAYER_COUNT; seat++) {
state.players[seat].hand = deck.splice(0, INITIAL_HAND);
}
state.stock = deck;
// Flip the first non-special card to start the discard pile. Red threes go to
// the bottom of the stock; a wild up-card freezes the pile from the start.
state.discard = [];
state.frozen = false;
let up = state.stock.shift();
while (isRedThree(up)) { state.stock.push(up); up = state.stock.shift(); }
if (isWild(up) || isBlackThree(up)) state.frozen = true;
state.discard.push(up);
// Players harvest red threes dealt to them, drawing replacements.
for (let seat = 0; seat < PLAYER_COUNT; seat++) {
let guard = 0;
while (harvestRedThrees(state, seat) > 0 && state.stock.length > 0) {
if (++guard > 20) break;
state.players[seat].hand.push(state.stock.shift());
}
}
state.phase = 'draw';
state.hasDrawn = false;
state.meldedThisTurn = [];
state.currentPlayer = (state.dealer + 1) % PLAYER_COUNT;
state.turnTeamMeldedAtStart = team(state, state.currentPlayer).hasMelded;
state.handScores = null;
state.outPlayer = null;
}
export function createInitialState({ seed } = {}) {
const rand = seed === undefined ? Math.random : rng(seed);
const players = [];
for (let i = 0; i < PLAYER_COUNT; i++) {
players.push({ seat: i, team: TEAM_OF_SEAT[i], hand: [] });
}
const teams = [0, 1].map(() => ({ melds: [], redThrees: [], hasMelded: false, score: 0 }));
const state = {
players, teams,
stock: [], discard: [], frozen: false,
currentPlayer: 0, phase: 'draw', hasDrawn: false,
meldedThisTurn: [], turnTeamMeldedAtStart: false,
dealer: seed === undefined ? Math.floor(Math.random() * PLAYER_COUNT) : (seed % PLAYER_COUNT),
handNumber: 0,
handScores: null, outPlayer: null, winnerTeam: null,
seed: seed ?? null, log: [], _rand: rand,
};
dealHand(state, rand);
return state;
}
// ── Queries ───────────────────────────────────────────────────────────────────
export function topDiscard(state) {
return state.discard.length ? state.discard[state.discard.length - 1] : null;
}
/** Find a team's existing meld of a rank, or null. */
export function teamMeld(state, seat, rank) {
return team(state, seat).melds.find((m) => m.rank === rank) || null;
}
/**
* Can the seat legally take the whole discard pile right now? Returns a plan
* { rank, naturalIds, wildIds, useMeld } describing the cheapest legal capture,
* or null. The top card itself is always part of the new/extended meld.
*/
export function takePlan(state, seat) {
if (state.phase !== 'draw' || state.hasDrawn) return null;
if (state.currentPlayer !== seat) return null;
const top = topDiscard(state);
if (!top || !isMeldable(top)) return null; // 3s and wilds can't be captured
const hand = state.players[seat].hand;
const t = team(state, seat);
const rank = top.rank;
const naturals = hand.filter((c) => c.rank === rank && !isWild(c));
const wilds = hand.filter((c) => isWild(c));
const existing = teamMeld(state, seat, rank);
// A team that hasn't melded, or a frozen pile, requires two natural matches.
const mustUseNaturals = state.frozen || !t.hasMelded;
if (naturals.length >= 2) {
return { rank, naturalIds: [naturals[0].id, naturals[1].id], wildIds: [], useMeld: !!existing };
}
if (mustUseNaturals) return null;
if (existing) {
// Add the top card to an existing meld (no extra cards needed).
return { rank, naturalIds: [], wildIds: [], useMeld: true };
}
if (naturals.length === 1 && wilds.length >= 1) {
return { rank, naturalIds: [naturals[0].id], wildIds: [wilds[0].id], useMeld: false };
}
return null;
}
// ── Drawing ───────────────────────────────────────────────────────────────────
function drawReplacingRedThrees(state, seat) {
// Draw one card; if it's a red three, harvest it and keep drawing.
let drew = null;
let guard = 0;
do {
if (state.stock.length === 0) return drew; // ran dry mid-replacement
drew = state.stock.shift();
if (isRedThree(drew)) {
team(state, seat).redThrees.push(drew);
drew = null;
}
if (++guard > 30) break;
} while (drew === null);
if (drew) state.players[seat].hand.push(drew);
return drew;
}
export function drawStock(state) {
if (state.phase !== 'draw' || state.hasDrawn) return state;
const next = cloneState(state);
const seat = next.currentPlayer;
if (next.stock.length === 0) { endHand(next, null); return next; }
drawReplacingRedThrees(next, seat);
next.hasDrawn = true;
next.phase = 'meld';
if (next.stock.length === 0 && next.players[seat].hand.length === 0) {
// Drew the last card replacing red threes and somehow emptied — guard.
endHand(next, null);
}
next.log.push({ kind: 'draw', seat });
return next;
}
export function takeDiscard(state, plan) {
if (state.phase !== 'draw' || state.hasDrawn) return state;
const seat = state.currentPlayer;
const p = plan || takePlan(state, seat);
if (!p) return state;
const next = cloneState(state);
const player = next.players[seat];
const t = team(next, seat);
const top = next.discard.pop();
// Build / extend the rank meld with the top card + supplied cards.
let meld = t.melds.find((m) => m.rank === p.rank) || null;
const used = [top];
for (const id of [...p.naturalIds, ...p.wildIds]) {
const idx = player.hand.findIndex((c) => c.id === id);
if (idx >= 0) used.push(player.hand.splice(idx, 1)[0]);
}
if (!meld) {
meld = { rank: p.rank, cards: [] };
t.melds.push(meld);
}
meld.cards.push(...used);
next.meldedThisTurn.push(...used.filter((c) => c.id !== top.id), top);
// The rest of the pile goes into the taker's hand.
player.hand.push(...next.discard.splice(0));
next.discard = [];
next.frozen = false;
next.hasDrawn = true;
next.phase = 'meld';
next.log.push({ kind: 'takePile', seat, rank: p.rank });
// Harvest any red threes that arrived inside the pile.
harvestRedThrees(next, seat);
return next;
}
// ── Melding ───────────────────────────────────────────────────────────────────
/**
* Validate a proposed meld of card ids for the current player (creating a new
* meld of `rank` or extending the team's existing one). Returns null if illegal,
* else { meld, cards } refs into a clone-ready shape. Does NOT mutate.
*/
function validateMeld(state, seat, rank, cardIds) {
const player = state.players[seat];
const cards = cardIds.map((id) => player.hand.find((c) => c.id === id)).filter(Boolean);
if (cards.length !== cardIds.length || cards.length === 0) return null;
// Every card is either a wild or a natural of the target rank.
for (const c of cards) {
if (isWild(c)) continue;
if (c.rank !== rank || !isMeldable(c)) return null;
}
const existing = teamMeld(state, seat, rank);
const naturalsAdded = cards.filter((c) => !isWild(c)).length;
const wildsAdded = cards.filter((c) => isWild(c)).length;
const totalNatural = (existing ? meldNaturals(existing) : 0) + naturalsAdded;
const totalWild = (existing ? meldWilds(existing) : 0) + wildsAdded;
const totalCards = (existing ? existing.cards.length : 0) + cards.length;
if (totalWild > MAX_WILDS_IN_MELD) return null;
if (totalNatural < 2) return null; // never more wilds than naturals base
if (totalWild > totalNatural) return null;
if (!existing && totalCards < MIN_MELD_CARDS) return null;
return { existing, cards };
}
export function meld(state, rank, cardIds) {
if (state.phase !== 'meld') return state;
const seat = state.currentPlayer;
const v = validateMeld(state, seat, rank, cardIds);
if (!v) return state;
const next = cloneState(state);
const player = next.players[seat];
const t = team(next, seat);
let m = t.melds.find((x) => x.rank === rank);
if (!m) { m = { rank, cards: [] }; t.melds.push(m); }
for (const id of cardIds) {
const idx = player.hand.findIndex((c) => c.id === id);
if (idx >= 0) {
const card = player.hand.splice(idx, 1)[0];
m.cards.push(card);
next.meldedThisTurn.push(card);
}
}
next.log.push({ kind: 'meld', seat, rank, count: cardIds.length });
return next;
}
/**
* After melds are laid, has the player met the team's initial-meld minimum?
* Only relevant on the team's first meld of the game.
*/
export function meetsInitialMeld(state, seat) {
const t = team(state, seat);
if (t.hasMelded) return true;
const value = state.meldedThisTurn.reduce((s, c) => s + cardScore(c), 0);
return value >= minimumMeld(t.score);
}
// ── Discard & turn end ─────────────────────────────────────────────────────────
/** Could the seat legally go out if they emptied their hand now? */
export function teamHasCanasta(state, seat) {
return team(state, seat).melds.some(isCanasta);
}
export function discard(state, cardId) {
if (state.phase !== 'meld') return state;
const seat = state.currentPlayer;
const player = state.players[seat];
const idx = player.hand.findIndex((c) => c.id === cardId);
if (idx < 0) return state;
const card = player.hand[idx];
if (isRedThree(card)) return state; // red threes are never discarded
// If the team laid its first melds this turn, the minimum must be satisfied.
const t = team(state, seat);
if (!t.hasMelded && state.meldedThisTurn.length > 0 && !meetsInitialMeld(state, seat)) {
return state; // illegal — would leave an under-value initial meld
}
const goingOut = player.hand.length === 1; // this discard would empty the hand
if (goingOut && !teamHasCanasta(state, seat)) {
// You may not go out without a canasta, but with a single card there is no
// other legal discard. Rather than deadlock, end the hand as a stock-out.
// (The AI never melds itself into this corner.)
const next = cloneState(state);
finalizeMelds(next);
endHand(next, null);
return next;
}
const next = cloneState(state);
const np = next.players[seat];
const ni = np.hand.findIndex((c) => c.id === cardId);
const [dc] = np.hand.splice(ni, 1);
// Mark the team as having melded if it laid anything this turn.
if (next.meldedThisTurn.length > 0) next.teams[next.players[seat].team].hasMelded = true;
next.discard.push(dc);
if (isWild(dc) || isBlackThree(dc)) next.frozen = true;
next.log.push({ kind: 'discard', seat, card: { rank: dc.rank, suit: dc.suit } });
if (np.hand.length === 0) {
finalizeMelds(next);
endHand(next, seat);
return next;
}
// Advance to the next player.
next.currentPlayer = (seat + 1) % PLAYER_COUNT;
next.phase = 'draw';
next.hasDrawn = false;
next.meldedThisTurn = [];
next.turnTeamMeldedAtStart = team(next, next.currentPlayer).hasMelded;
return next;
}
function finalizeMelds(state) {
// Commit hasMelded for any team that laid cards (covers the go-out path).
for (const seat of [0, 1, 2, 3]) {
const t = state.teams[TEAM_OF_SEAT[seat]];
if (t.melds.length > 0) t.hasMelded = true;
}
}
// ── Scoring ─────────────────────────────────────────────────────────────────
export function scoreTeamHand(state, teamId, { outPlayer } = {}) {
const t = state.teams[teamId];
const detail = { meldPoints: 0, naturalCanastas: 0, mixedCanastas: 0, canastaBonus: 0,
redThrees: t.redThrees.length, redThreeBonus: 0, goOut: 0, handPenalty: 0, total: 0 };
for (const m of t.melds) {
detail.meldPoints += meldValue(m);
if (isCanasta(m)) {
if (isNaturalCanasta(m)) { detail.naturalCanastas++; detail.canastaBonus += NATURAL_CANASTA; }
else { detail.mixedCanastas++; detail.canastaBonus += MIXED_CANASTA; }
}
}
// Red threes: positive only if the team melded; all four → 800.
const rt = t.redThrees.length;
let rtBonus = (rt === 4 ? ALL_RED_THREES : rt * RED_THREE);
if (!t.hasMelded) rtBonus = -rtBonus;
detail.redThreeBonus = rtBonus;
// Going-out bonus (concealed if the team had not melded before this turn).
if (outPlayer !== null && TEAM_OF_SEAT[outPlayer] === teamId) {
detail.goOut = state.turnTeamMeldedAtStart ? GO_OUT : CONCEALED_GO_OUT;
}
// Deduct the value of every card still held by the team's players.
for (const seat of SEATS_OF_TEAM[teamId]) {
for (const c of state.players[seat].hand) detail.handPenalty += cardScore(c);
}
detail.total = detail.meldPoints + detail.canastaBonus + detail.redThreeBonus
+ detail.goOut - detail.handPenalty;
return detail;
}
function endHand(state, outPlayer) {
state.outPlayer = outPlayer;
const scores = [0, 1].map((teamId) => scoreTeamHand(state, teamId, { outPlayer }));
for (let teamId = 0; teamId < 2; teamId++) state.teams[teamId].score += scores[teamId].total;
state.handScores = scores;
const reached = state.teams.some((t) => t.score >= WIN_SCORE);
if (reached) {
state.phase = 'gameOver';
const a = state.teams[0].score, b = state.teams[1].score;
state.winnerTeam = a === b ? null : (a > b ? 0 : 1);
} else {
state.phase = 'handOver';
}
state.log.push({ kind: 'handOver', scores: scores.map((s) => s.total), outPlayer });
}
export function startNextHand(state) {
if (state.phase !== 'handOver') return state;
const next = cloneState(state);
next.handNumber += 1;
next.dealer = (next.dealer + 1) % PLAYER_COUNT;
// Per-hand reset of melds / red threes (cumulative score persists).
for (const t of next.teams) { t.melds = []; t.redThrees = []; t.hasMelded = false; }
dealHand(next, next._rand);
return next;
}
export function isGameOver(state) { return state.phase === 'gameOver'; }
// Re-export a couple of internals used by the AI / verify harness.
export { meldValue, isCanasta, isNaturalCanasta, meldNaturals, meldWilds };

View File

@ -0,0 +1,62 @@
# Canasta — Sit With Us, We Play in Pairs
*By Aunt Rosa — who has hosted the Sunday card table for fifty years and never once lost her own deal*
---
Come in, come in — wipe your feet and sit *across* from me. No, not beside — *across*. In Canasta we play in partnerships, and you and I are a team. The two across the table from us? Lovely people. The enemy. We'll be polite and we'll beat them.
## The Goal
First **team** to **5,000 points** wins the match — not in one hand, but over several. You score by laying down *melds* and, above all, by building **canastas**. Help me build ours and we'll be drinking the good coffee by evening.
## The Cards
We play with **two whole decks plus four jokers** — 108 cards. You start with **eleven** in your hand. Some cards are special:
- **Wild cards** — the **jokers** (worth 50) and every **two** (worth 20). A wild can stand in for almost any card in a meld.
- **Red threes** — pure bonus. The moment one lands in your hand it pops onto the table by itself and you draw a replacement. Each is worth **100** (all four together, a glorious **800**) — *but only if our team has melded.* Sit there doing nothing and they count *against* us.
- **Black threes** — stubborn little things. You can't meld them, and tossing one onto the discard pile slams the door so the next player can't grab it.
## Your Turn, Step by Step
### 1. Draw
Either take the top card of the **stock**, or scoop up the *entire* **discard pile** — pile-taking is how fortunes are made. But you may only take the pile if you can use its **top card** right away:
- Hold **two natural cards** matching it and you may always take it.
- If the pile isn't **frozen** and we've already melded, one natural plus a wild will do, or you can add the top card to a meld we already have.
A **frozen** pile (someone discarded a wild onto it — you'll see the ❄ badge) demands two natural matches, no shortcuts. And remember: before our team's *first* meld, you must use two naturals regardless.
### 2. Meld
A **meld** is three or more cards of the same rank laid on the table — and melds belong to the *team*, so I can build on yours and you on mine. A meld may carry up to **three** wild cards, but never more wilds than naturals.
Our **first** meld of the game must reach a minimum point total that climbs as our score grows — **50** to start, then 90, then 120. The game will tell you the number; just select cards and press **Meld**.
### 3. Discard
End your turn by tossing one card onto the discard pile. Select a single card and press **Discard**. (Throw a wild and you *freeze* the pile — sometimes exactly what you want.)
## Canastas — the Whole Point
A **canasta** is a meld of **seven** cards.
- **Natural canasta** — seven cards, no wilds. A beauty. **500** points.
- **Mixed canasta** — seven cards with a wild or two. Still grand. **300** points.
You'll see ours glow gold (natural) or silver (mixed) in the meld area.
## Going Out
To end the hand, a player empties their hand — but **only if our team owns at least one canasta**. Going out earns a **100** bonus (200 if you do it all in one breathtaking turn having never melded before). When the hand ends we tally melds, canastas, red threes and the go-out bonus, then *subtract* the value of every card still stuck in our hands. So don't get caught holding a fistful at the end.
## Rosa's Rules of the Heart
- **Hoard for a canasta.** A meld of four is nice; a canasta is a fortune. Don't break up a near-canasta to start something new.
- **Watch what they want.** If the pile is fat, the player after you may be drooling for it — drop a black three and shut them out.
- **Mind the freeze.** Frozen piles are heavy and hard to take; freeze it when *we're* sitting pretty and they aren't.
- **Red threes love a melded team.** Get a meld down early so our bonus cards count *for* us.
That's the whole song, dear. Watch the table, trust your partner, and build, build, build. Now — your draw. And do try to look like you know what you're doing; they're watching.

View File

@ -0,0 +1,68 @@
// Cribbage — heuristic opponent (skill 1-5). No Phaser, no state mutation.
// Two decisions: which two cards to lay away to the crib, and which card to
// play during the pegging. Both are greedy with skill-scaled noise.
import { cribValue, runRank } from './CribbageData.js';
import { scoreHand, scorePlay } from './CribbageLogic.js';
const clampSkill = (s) => Math.max(1, Math.min(5, s | 0)) || 3;
export function thinkDelay(skill) {
const base = [1100, 950, 820, 700, 580][clampSkill(skill) - 1];
return base + Math.random() * 400;
}
// Rough expected value of the two cards laid into the crib (before the cut).
function cribPotential(a, b) {
let v = 0;
if (a.rank === b.rank) v += 2; // pair
if (cribValue(a) + cribValue(b) === 15) v += 2; // a built-in fifteen
v += [a, b].filter((c) => cribValue(c) === 5).length * 1.5; // fives are gold in the crib
if (Math.abs(runRank(a) - runRank(b)) <= 2 && a.rank !== b.rank) v += 1; // run potential
return v;
}
/**
* Choose two cards to discard to the crib from a six-card hand.
* @returns {Card[]} the two discards
*/
export function chooseDiscard(hand6, isDealer, skill = 3) {
const sk = clampSkill(skill);
const noise = (5 - sk) * 1.4;
let best = null, bestVal = -Infinity;
for (let i = 0; i < hand6.length; i++) {
for (let j = i + 1; j < hand6.length; j++) {
const discard = [hand6[i], hand6[j]];
const keep = hand6.filter((_, k) => k !== i && k !== j);
const handVal = scoreHand(keep, null).total; // static value, no starter
const crib = cribPotential(discard[0], discard[1]) * 0.5;
let val = handVal + (isDealer ? crib : -crib);
val += (Math.random() * 2 - 1) * noise;
if (val > bestVal) { bestVal = val; best = discard; }
}
}
return best;
}
/**
* Choose which legal card to play during the pegging.
* @returns {Card} the card to play
*/
export function choosePlay(legal, pile, count, skill = 3) {
const sk = clampSkill(skill);
const noise = (5 - sk) * 1.2;
let best = null, bestVal = -Infinity;
for (const c of legal) {
const pts = scorePlay([...pile, c]).points;
const newCount = count + cribValue(c);
let val = pts * 4;
if (newCount === 5 || newCount === 21) val -= 2; // hands opponent an easy ten for 15/31
if (pile.length === 0 && cribValue(c) === 5) val -= 3; // never lead a five
if (pile.length === 0 && newCount === 21) val += 0;
val += (Math.random() * 2 - 1) * noise;
if (val > bestVal) { bestVal = val; best = c; }
}
return best;
}

View File

@ -0,0 +1,118 @@
// Cribbage — static data, scoring helpers and board geometry. No Phaser, no
// state: imported by the logic engine, the AI, the Phaser scene and the
// headless verify harness alike.
import { Card, Deck } from '../cards/Deck.js';
export { Card, Deck };
export const ICON_FRAME = 64;
// ── Rules (standard 2-player, race to 121, muggins off) ─────────────────────
export const WIN_SCORE = 121;
export const HAND_SIZE = 6; // cards dealt to each player
export const KEEP = 4; // cards kept after discarding to the crib
export const CRIB = 2; // cards each player lays away to the crib
export const PLAY_LIMIT = 31; // running-count ceiling during the play
// ── Card value helpers ──────────────────────────────────────────────────────
/** Pip value used for fifteens and the running count: A=1, face=10, else rank. */
export function cribValue(card) {
if (card.rank === 'A') return 1;
return Math.min(10, card.value);
}
/** Rank order for runs: ace is always low (A=1 … K=13), no wrap-around. */
export function runRank(card) {
return card.rank === 'A' ? 1 : card.value; // value: T=10,J=11,Q=12,K=13
}
// ── Theme (carved-wood board, brass accents) ────────────────────────────────
export const THEME = {
feltTop: 0x14241b, // dark green baize backdrop
feltBottom: 0x0a160f,
woodLight: 0xb5803f, // maple highlight
woodMid: 0x8a5a2b, // walnut field
woodDark: 0x5a3618, // grain shadow
woodEdge: 0x3a2412, // bevelled rim
grain: 0x70451f, // grain streak
holeDark: 0x241405, // drilled hole shadow
holeRim: 0xc99a55, // lit rim around a hole
inlay: 0xd9b779, // five-hole grouping inlay
brass: 0xc9a227,
brassHi: 0xf0d77a,
pegHuman: 0xc0392b, // crimson pegs (you)
pegHumanHi: 0xf07a6d,
pegAi: 0xe9e1cf, // ivory pegs (opponent)
pegAiHi: 0xffffff,
cardFace: 0xfbf6e7,
cardBack: 0x6b3f1c,
gold: 0xd4a017,
text: 0xf2ead8,
};
// ── Board geometry ──────────────────────────────────────────────────────────
// A traditional 3-street S-curve track. Each player owns one continuous track
// of 120 numbered holes laid out as three "streets" (rows), grouped in fives,
// plus a start hole (index 0) and a final game hole (index 121). The two
// players' tracks run as paired sub-rows inside each street so the colours sit
// side by side, exactly like a physical board.
const STREETS = 3;
const HOLES_PER_STREET = 40; // per player, per street (3 × 40 = 120)
const GROUP = 5; // holes per inlaid grouping
/**
* Build hole coordinates for both players inside a board rectangle.
* Returns:
* tracks: [p0[], p1[]] each an array of {x,y} for indices 0..121
* holes: [{x,y}, ...] every drilled hole (both players, all indices) for rendering
* groupGaps, holeR, etc. handy render metrics
*/
export function buildBoardLayout(rect) {
const { x, y, w, h } = rect;
const padX = w * 0.045;
const usableW = w - padX * 2;
// Hole-to-hole spacing, with an extra gap inserted every GROUP holes.
// width = (N-1)*s + (groups-1)*gap , gap = 0.6*s
const groups = HOLES_PER_STREET / GROUP; // 8
const s = usableW / ((HOLES_PER_STREET - 1) + (groups - 1) * 0.6);
const groupGap = 0.6 * s;
const holeR = Math.max(5, s * 0.26);
// x for the i-th hole (0-based 0..39) within a street, measured left edge.
const colX = (i) => x + padX + i * s + Math.floor(i / GROUP) * groupGap;
// Vertical bands: one street per band, two player sub-rows inside each band.
const bandH = h / STREETS;
const subY = (street, player) => {
const top = y + street * bandH;
return top + bandH * (player === 0 ? 0.34 : 0.66);
};
// For track index n (1..120): which street, and column within (respecting the
// serpentine direction — even streets L→R, odd streets R→L).
const trackPoint = (player, n) => {
const idx = n - 1;
const street = Math.floor(idx / HOLES_PER_STREET);
let within = idx % HOLES_PER_STREET;
if (street % 2 === 1) within = HOLES_PER_STREET - 1 - within;
return { x: colX(within), y: subY(street, player) };
};
const tracks = [[], []];
for (let p = 0; p < 2; p++) {
// index 0 — start hole, just left of hole 1 on the top street
tracks[p][0] = { x: colX(0) - s * 0.9, y: subY(0, p) };
for (let n = 1; n <= 120; n++) tracks[p][n] = trackPoint(p, n);
// index 121 — game hole, just past hole 120 at the end of the bottom street
const last = trackPoint(p, 120);
tracks[p][121] = { x: last.x + s * 0.9, y: last.y };
}
// Flat list of every drilled hole for the board face.
const holes = [];
for (let p = 0; p < 2; p++) for (let n = 0; n <= 121; n++) holes.push(tracks[p][n]);
return { tracks, holes, holeR, s, groupGap };
}

View File

@ -0,0 +1,640 @@
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 { api } from '../../services/api.js';
import { THEME, buildBoardLayout } from './CribbageData.js';
import { CribbageLogic, scoreHand } from './CribbageLogic.js';
import { chooseDiscard, choosePlay, thinkDelay } from './CribbageAI.js';
// ── Layout ──────────────────────────────────────────────────────────────────
const BOARD = { x: 210, y: 44, w: 1500, h: 330 };
const CARD_W = 80;
const CARD_H = 112;
const CARD_R = 8;
const HAND_GAP = 14;
const OPP_HAND_Y = 470;
const PEG_ROW_Y = 628;
const PEG_ROW_X = 470; // left edge of the running play row
const PLAY_GAP = 64;
const PLAYER_HAND_Y = 902;
const STARTER_X = 1430;
const CRIB_X = 1610;
const CENTER_X = 760;
const D = { felt: -2, board: 0, holes: 1, inlay: 2, peg: 6, card: 10, cardText: 11, ui: 30, toast: 50, modal: 70, modalUI: 72 };
const SUIT_RED = '#c0392b';
const SUIT_BLK = '#1a1208';
export default class CribbageGame extends Phaser.Scene {
constructor() { super('CribbageGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'cribbage', name: 'Cribbage' };
this.opponents = data.opponents ?? [];
this.aiSkill = this.opponents[0]?.skill ?? 3;
this.aiName = this.opponents[0]?.name ?? 'Opponent';
this.logic = new CribbageLogic();
this.recorded = false;
this.busy = false;
this.handSprites = [[], []]; // card containers per player
this.pegSprites = []; // cards in the current play row
this.cribSprites = [];
this.starterSprite = null;
this.selected = new Set(); // keys selected for discard
this.pegObjs = [[], []]; // peg game objects per player
}
create() {
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ }
this.buildBackdrop();
this.buildBoard();
this.buildHud();
this.confirmBtn = new Button(this, CENTER_X, 1018, 'Discard to Crib', () => this.onConfirmDiscard(),
{ width: 320, height: 58, fontSize: 24 }).setDepth(D.ui).setVisible(false);
this.nextBtn = new Button(this, CENTER_X, 1018, 'Next Deal', () => this.onNextDeal(),
{ width: 280, height: 58, fontSize: 24 }).setDepth(D.ui).setVisible(false);
this.leaveBtn = new Button(this, GAME_WIDTH - 110, 1042, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 160, height: 50, fontSize: 22 }).setDepth(D.ui);
this.logic.newDeal();
this.beginDiscard();
}
// ── Background & board art ─────────────────────────────────────────────────
buildBackdrop() {
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);
// vignette ring
g.lineStyle(6, 0x000000, 0.25);
g.strokeRoundedRect(16, 16, GAME_WIDTH - 32, GAME_HEIGHT - 32, 22);
}
buildBoard() {
const { x, y, w, h } = BOARD;
const g = this.add.graphics().setDepth(D.board);
// drop shadow
g.fillStyle(0x000000, 0.45);
g.fillRoundedRect(x + 10, y + 14, w, h, 26);
// bevelled outer frame
g.fillStyle(THEME.woodEdge, 1);
g.fillRoundedRect(x - 8, y - 8, w + 16, h + 16, 28);
// wood field with a vertical grain gradient
g.fillGradientStyle(THEME.woodLight, THEME.woodLight, THEME.woodDark, THEME.woodDark, 1);
g.fillRoundedRect(x, y, w, h, 22);
// grain streaks
g.lineStyle(2, THEME.grain, 0.18);
for (let i = 0; i < 26; i++) {
const gy = y + 14 + (i / 26) * (h - 28) + Math.sin(i) * 3;
g.beginPath();
g.moveTo(x + 12, gy);
for (let sx = x + 12; sx <= x + w - 12; sx += 40) g.lineTo(sx, gy + Math.sin(sx * 0.02 + i) * 2.5);
g.strokePath();
}
// inner highlight bevel
g.lineStyle(3, THEME.woodLight, 0.35);
g.strokeRoundedRect(x + 5, y + 5, w - 10, h - 10, 18);
// title plaque
this.add.text(x + w / 2, y + 12, 'CRIBBAGE', {
fontFamily: 'Righteous', fontSize: '22px', color: '#f0d77a',
}).setOrigin(0.5, 0).setDepth(D.inlay).setAlpha(0.85);
// ── holes ──
this.layout = buildBoardLayout(BOARD);
const { tracks, holeR } = this.layout;
const holes = this.add.graphics().setDepth(D.holes);
const inlay = this.add.graphics().setDepth(D.inlay);
for (let p = 0; p < 2; p++) {
// five-hole grouping inlays (faint rounded capsules behind each group of 5)
for (let start = 1; start <= 120; start += 5) {
const a = tracks[p][start], b = tracks[p][Math.min(start + 4, 120)];
inlay.lineStyle(1.5, THEME.inlay, 0.18);
inlay.strokeRoundedRect(
Math.min(a.x, b.x) - holeR - 3, a.y - holeR - 3,
Math.abs(b.x - a.x) + holeR * 2 + 6, holeR * 2 + 6, holeR + 3,
);
}
for (let n = 0; n <= 121; n++) {
const pt = tracks[p][n];
// drilled recess
holes.fillStyle(THEME.holeDark, 1);
holes.fillCircle(pt.x, pt.y + 1, holeR);
// lit rim
holes.lineStyle(1.5, THEME.holeRim, n === 121 ? 0.9 : 0.5);
holes.strokeCircle(pt.x, pt.y, holeR);
}
// accent the start (0) and game (121) holes
inlay.lineStyle(2, THEME.brassHi, 0.8);
inlay.strokeCircle(tracks[p][0].x, tracks[p][0].y, holeR + 3);
inlay.strokeCircle(tracks[p][121].x, tracks[p][121].y, holeR + 3);
}
this.add.text(tracks[0][121].x + 6, (tracks[0][121].y + tracks[1][121].y) / 2, '121', {
fontFamily: 'Righteous', fontSize: '18px', color: '#f0d77a',
}).setOrigin(0, 0.5).setDepth(D.inlay);
// pegs (two per player, start in the start hole)
for (let p = 0; p < 2; p++) {
const col = p === 0 ? THEME.pegHuman : THEME.pegAi;
const hi = p === 0 ? THEME.pegHumanHi : THEME.pegAiHi;
for (let k = 0; k < 2; k++) this.pegObjs[p][k] = this.makePeg(col, hi).setDepth(D.peg);
}
this.placePegs(0, true);
this.placePegs(1, true);
}
makePeg(color, hi) {
const c = this.add.container(0, 0);
const g = this.add.graphics();
g.fillStyle(0x000000, 0.35); g.fillEllipse(1, 3, 16, 8); // shadow
g.fillStyle(color, 1); g.fillCircle(0, -2, 7); // head
g.fillStyle(color, 1); g.fillRoundedRect(-3, -4, 6, 10, 3); // stem
g.fillStyle(hi, 0.9); g.fillCircle(-2, -4, 2.4); // highlight
c.add(g);
return c;
}
placePegs(player, snap = false) {
const { tracks } = this.layout;
const front = this.logic.scores[player];
const back = this.logic.prev[player];
const targets = [
{ x: tracks[player][back].x + (back === front ? -5 : 0), y: tracks[player][back].y },
{ x: tracks[player][front].x + (back === front ? 5 : 0), y: tracks[player][front].y },
];
this.pegObjs[player].forEach((peg, i) => {
if (snap) peg.setPosition(targets[i].x, targets[i].y);
else this.tweens.add({ targets: peg, x: targets[i].x, y: targets[i].y, duration: 380, ease: 'Cubic.easeOut' });
});
}
// ── HUD ────────────────────────────────────────────────────────────────────
buildHud() {
this.add.text(40, OPP_HAND_Y - 70, this.aiName.toUpperCase(), {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.oppScoreText = this.add.text(40, OPP_HAND_Y - 36, '0', {
fontFamily: 'Righteous', fontSize: '40px', color: '#e9e1cf',
}).setOrigin(0, 0.5).setDepth(D.ui);
this.oppDealer = this.add.text(180, OPP_HAND_Y - 70, '◆ deals', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.add.text(40, PLAYER_HAND_Y - 4, 'YOU', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.playerScoreText = this.add.text(40, PLAYER_HAND_Y + 34, '0', {
fontFamily: 'Righteous', fontSize: '40px', color: '#f07a6d',
}).setOrigin(0, 0.5).setDepth(D.ui);
this.playerDealer = this.add.text(150, PLAYER_HAND_Y - 4, '◆ deals', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.countText = this.add.text(CENTER_X, PEG_ROW_Y + 96, '', {
fontFamily: 'Righteous', fontSize: '30px', color: '#f0d77a',
}).setOrigin(0.5).setDepth(D.ui);
this.statusText = this.add.text(CENTER_X, 412, '', {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
this.add.text(STARTER_X, OPP_HAND_Y - 70, 'STARTER', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.cribLabel = this.add.text(CRIB_X, OPP_HAND_Y - 70, 'CRIB', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.refreshScores();
}
refreshScores() {
this.oppScoreText.setText(String(this.logic.scores[1]));
this.playerScoreText.setText(String(this.logic.scores[0]));
this.oppDealer.setVisible(this.logic.dealer === 1);
this.playerDealer.setVisible(this.logic.dealer === 0);
this.cribLabel.setText(this.logic.dealer === 0 ? 'YOUR CRIB' : 'THEIR CRIB');
}
// ── 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(3, THEME.brass, 0.8); g.strokeRoundedRect(x + 5, y + 5, CARD_W - 10, CARD_H - 10, CARD_R - 2);
g.lineStyle(1.5, THEME.brassHi, 0.4); 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: '34px', color: '#f0d77a' }).setOrigin(0.5).setAlpha(0.5));
return;
}
g.fillStyle(THEME.cardFace, 1); g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(2, 0xcc803a, 0.5); 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 + 8, y + 5, card.label, { fontFamily: 'Righteous', fontSize: '22px', color: col }));
container.add(this.add.text(x + 9, y + 31, card.suitSymbol, { fontFamily: 'sans-serif', fontSize: '20px', color: col }));
container.add(this.add.text(0, 5, card.suitSymbol, { fontFamily: 'sans-serif', fontSize: '42px', color: col }).setOrigin(0.5));
container.add(this.add.text(x + CARD_W - 8, y + CARD_H - 5, card.label, { fontFamily: 'Righteous', fontSize: '22px', color: col }).setOrigin(1, 1));
}
makeCard(card, x, y, { faceUp = true } = {}) {
const c = this.add.container(x, y).setDepth(D.card);
c.cardRef = card;
this.drawFace(c, card, faceUp);
return c;
}
setHandInteractive(container, handler) {
container.setSize(CARD_W, CARD_H);
container.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
container.on('pointerdown', handler);
}
handX(count, i) {
const total = count * CARD_W + (count - 1) * HAND_GAP;
return CENTER_X - total / 2 + CARD_W / 2 + i * (CARD_W + HAND_GAP);
}
// ── Discard phase ──────────────────────────────────────────────────────────
beginDiscard() {
this.clearTable();
this.selected.clear();
this.refreshScores();
this.statusText.setText('Choose two cards to lay away to the ' + (this.logic.dealer === 0 ? 'crib (yours)' : 'crib (theirs)'));
this.countText.setText('');
playSound(this, SFX.CARD_SHUFFLE);
// opponent six cards face down
const opp = this.logic.hands[1];
opp.forEach((card, i) => {
const c = this.makeCard(card, this.handX(6, i), OPP_HAND_Y, { faceUp: false });
this.handSprites[1].push(c);
});
// player six cards, selectable
const me = this.logic.hands[0];
me.forEach((card, i) => {
const c = this.makeCard(card, this.handX(6, i), PLAYER_HAND_Y);
c.baseY = PLAYER_HAND_Y;
this.setHandInteractive(c, () => this.toggleDiscard(c));
this.handSprites[0].push(c);
});
// AI picks its discard now (revealed only at the show)
this.aiDiscard = chooseDiscard(this.logic.hands[1], this.logic.dealer === 1, this.aiSkill);
this.confirmBtn.setVisible(true);
this.confirmBtn.setAlpha(0.5);
this.nextBtn.setVisible(false);
}
toggleDiscard(c) {
if (this.busy || this.logic.phase !== 'discard') return;
const key = c.cardRef.key;
if (this.selected.has(key)) {
this.selected.delete(key);
this.tweens.add({ targets: c, y: c.baseY, duration: 120 });
} else {
if (this.selected.size >= 2) return;
this.selected.add(key);
this.tweens.add({ targets: c, y: c.baseY - 22, duration: 120 });
}
playSound(this, SFX.PIECE_CLICK);
this.confirmBtn.setAlpha(this.selected.size === 2 ? 1 : 0.5);
}
async onConfirmDiscard() {
if (this.selected.size !== 2 || this.busy) return;
this.busy = true;
this.confirmBtn.setVisible(false);
const myDiscards = this.logic.hands[0].filter((c) => this.selected.has(c.key));
this.logic.discard(0, myDiscards);
this.logic.discard(1, this.aiDiscard);
playSound(this, SFX.CARD_PLACE);
// animate discards flying to the crib pile, remove kept-card extras
this.animateDiscardsToCrib();
await this.delay(450);
// re-layout kept hands (4 cards)
this.relayoutKeptHands();
await this.delay(200);
this.doCut();
}
animateDiscardsToCrib() {
const toCrib = [];
this.handSprites[0] = this.handSprites[0].filter((c) => {
if (this.selected.has(c.cardRef.key)) { toCrib.push(c); return false; }
return true;
});
// opponent: just drop two face-down sprites toward the crib
const oppGoing = this.handSprites[1].splice(4, 2);
[...toCrib, ...oppGoing].forEach((c, i) => {
c.disableInteractive?.();
this.cribSprites.push(c);
this.tweens.add({
targets: c, x: CRIB_X, y: OPP_HAND_Y, angle: (i - 2) * 6, duration: 400, ease: 'Cubic.easeIn',
onComplete: () => { c.setDepth(D.card); },
});
});
}
relayoutKeptHands() {
this.logic.keep[0].forEach((card, i) => {
const c = this.handSprites[0][i];
if (!c) return;
c.cardRef = card;
this.tweens.add({ targets: c, x: this.handX(4, i), y: PLAYER_HAND_Y, duration: 250 });
c.baseY = PLAYER_HAND_Y;
});
this.handSprites[1].forEach((c, i) => {
// re-key the (still face-down) opponent sprites to the kept cards so the
// pegging phase can locate the right sprite when the AI plays one.
if (this.logic.keep[1][i]) c.cardRef = this.logic.keep[1][i];
this.tweens.add({ targets: c, x: this.handX(4, i), y: OPP_HAND_Y, duration: 250 });
});
}
// ── Cut ────────────────────────────────────────────────────────────────────
async doCut() {
const { starter, heels } = this.logic.cut();
this.starterSprite = this.makeCard(starter, STARTER_X, OPP_HAND_Y, { faceUp: false });
playSound(this, SFX.CARD_SHOW);
await this.delay(300);
// flip
this.tweens.add({ targets: this.starterSprite, scaleX: 0, duration: 140, yoyo: true,
onYoyo: () => { this.starterSprite.removeAll(true); this.drawFace(this.starterSprite, starter, true); } });
await this.delay(360);
if (heels) {
this.floatScore(this.logic.dealer, 2, 'His Heels!');
this.placePegs(this.logic.dealer);
this.refreshScores();
await this.delay(700);
}
if (this.logic.winner !== null) return this.gameOver();
this.beginPlay();
}
// ── The play (pegging) ─────────────────────────────────────────────────────
beginPlay() {
this.busy = false;
this.pegSprites = [];
this.countText.setText('Count: 0');
this.step();
}
async step() {
if (this.logic.winner !== null) return this.gameOver();
if (this.logic.phase !== 'play') return this.beginShow();
const p = this.logic.turn;
const legal = this.logic.legalPlays(p);
if (legal.length === 0) {
this.statusText.setText(p === 0 ? 'You say “Go”' : `${this.aiName} says “Go”`);
await this.delay(650);
const res = this.logic.go(p);
this.applyEvents(res.events);
if (res.reset) await this.resetPegRow();
return this.step();
}
if (p === 0) {
this.statusText.setText('Your play');
this.enableHumanPlay(legal);
return;
}
// AI turn
this.statusText.setText(`${this.aiName} is thinking…`);
await this.delay(thinkDelay(this.aiSkill));
const card = choosePlay(legal, this.logic.pile, this.logic.count, this.aiSkill);
await this.doPlay(1, card);
return this.step();
}
enableHumanPlay(legal) {
const legalKeys = new Set(legal.map((c) => c.key));
this.handSprites[0].forEach((c) => {
const ok = legalKeys.has(c.cardRef.key);
c.setAlpha(ok ? 1 : 0.4);
c.off('pointerdown');
if (ok) {
this.setHandInteractive(c, async () => {
if (this.busy) return;
this.busy = true;
this.handSprites[0].forEach((x) => x.off('pointerdown'));
await this.doPlay(0, c.cardRef);
this.busy = false;
this.step();
});
} else {
c.disableInteractive?.();
}
});
}
async doPlay(player, card) {
// pull the matching sprite out of the hand
const arr = this.handSprites[player];
const idx = arr.findIndex((c) => c.cardRef.key === card.key);
let sprite = idx >= 0 ? arr.splice(idx, 1)[0] : this.makeCard(card, CENTER_X, player === 0 ? PLAYER_HAND_Y : OPP_HAND_Y);
if (!sprite.cardRef) sprite.cardRef = card;
// reveal if it was face down (opponent)
if (player === 1) { sprite.removeAll(true); this.drawFace(sprite, card, true); }
const slot = this.pegSprites.length;
const tx = PEG_ROW_X + slot * PLAY_GAP;
sprite.setDepth(D.card + slot);
this.pegSprites.push(sprite);
playSound(this, SFX.CARD_PLACE);
this.tweens.add({ targets: sprite, x: tx, y: PEG_ROW_Y, angle: 0, alpha: 1, duration: 260, ease: 'Cubic.easeOut' });
await this.delay(280);
const res = this.logic.play(player, card);
this.countText.setText(`Count: ${res.count === 0 ? 31 : res.count}`);
this.applyEvents(res.events);
// a reset means we just hit 31 (or finished) — sweep the row
if (res.count === 0 && !res.ended) await this.resetPegRow();
else await this.delay(220);
}
async resetPegRow() {
this.countText.setText('Count: 0');
const going = this.pegSprites;
this.pegSprites = [];
going.forEach((c) => this.tweens.add({ targets: c, y: PEG_ROW_Y + 120, alpha: 0, duration: 300,
onComplete: () => c.destroy() }));
await this.delay(340);
}
applyEvents(events) {
for (const ev of events || []) {
this.placePegs(ev.player);
this.floatScore(ev.player, ev.points, this.labelFor(ev.reasons));
playSound(this, SFX.PIECE_CLICK);
}
this.refreshScores();
}
labelFor(reasons) {
const map = { '15': 'Fifteen', '31': 'Thirty-one', pair: 'Pair', 'pair-royal': 'Three!', 'double-pair-royal': 'Four!',
run3: 'Run', run4: 'Run', run5: 'Run', run6: 'Run', run7: 'Run', go: 'Go', 'last-card': 'Last card' };
return (reasons || []).map((r) => map[r] || r).join(' + ');
}
floatScore(player, points, label) {
const { tracks } = this.layout;
const pt = tracks[player][this.logic.scores[player]];
const txt = this.add.text(pt.x, pt.y - 18, `+${points}${label ? ' ' + label : ''}`, {
fontFamily: 'Righteous', fontSize: '24px', color: player === 0 ? '#f07a6d' : '#fff4d8',
}).setOrigin(0.5).setDepth(D.toast).setShadow(0, 0, 'rgba(255,207,74,0.85)', 12);
this.tweens.add({ targets: txt, y: pt.y - 64, alpha: 0, duration: 1100, ease: 'Cubic.easeOut',
onComplete: () => txt.destroy() });
}
// ── The show ────────────────────────────────────────────────────────────────
async beginShow() {
this.busy = true;
this.statusText.setText('The Show');
this.countText.setText('');
await this.resetPegRow();
// reveal opponent's kept hand
this.handSprites[1].forEach((c, i) => {
const card = this.logic.keep[1][i];
if (!card) return;
c.cardRef = card;
this.tweens.add({ targets: c, scaleX: 0, duration: 120, yoyo: true,
onYoyo: () => { c.removeAll(true); this.drawFace(c, card, true); } });
});
await this.delay(500);
const nd = this.logic.nonDealer, d = this.logic.dealer;
const order = [
{ player: nd, cards: this.logic.keep[nd], isCrib: false, title: nd === 0 ? 'Your hand' : `${this.aiName}'s hand` },
{ player: d, cards: this.logic.keep[d], isCrib: false, title: d === 0 ? 'Your hand' : `${this.aiName}'s hand` },
{ player: d, cards: this.logic.crib, isCrib: true, title: d === 0 ? 'Your crib' : `${this.aiName}'s crib` },
];
for (const o of order) {
const r = scoreHand(o.cards, this.logic.starter, o.isCrib);
this.showCountPanel(o, r);
await this.delay(700);
const got = this.logic.addPoints(o.player, r.total);
if (got > 0) { this.floatScore(o.player, got, ''); this.placePegs(o.player); }
this.refreshScores();
await this.delay(1100);
this.clearCountPanel();
if (this.logic.winner !== null) break;
}
if (this.logic.winner !== null) return this.gameOver();
this.statusText.setText('');
this.nextBtn.setVisible(true);
this.busy = false;
}
showCountPanel(o, r) {
this.clearCountPanel();
const parts = [];
if (r.fifteens) parts.push(`Fifteens ${r.fifteens}`);
if (r.pairs) parts.push(`Pairs ${r.pairs}`);
if (r.runs) parts.push(`Runs ${r.runs}`);
if (r.flush) parts.push(`Flush ${r.flush}`);
if (r.nobs) parts.push(`Nobs ${r.nobs}`);
const body = parts.length ? parts.join(' ') : 'Nothing';
const w = 560, h = 130, cx = CENTER_X, cy = PEG_ROW_Y - 30;
const g = this.add.graphics().setDepth(D.modal);
g.fillStyle(THEME.woodDark, 0.96); g.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, 14);
g.lineStyle(3, THEME.brass, 0.9); g.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 14);
const t1 = this.add.text(cx, cy - 34, `${o.title}${o.isCrib ? 'CRIB' : 'HAND'}`, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(D.modalUI);
const t2 = this.add.text(cx, cy + 2, body, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.modalUI);
const t3 = this.add.text(cx, cy + 40, `${r.total} point${r.total === 1 ? '' : 's'}`, {
fontFamily: 'Righteous', fontSize: '30px', color: '#f0d77a' }).setOrigin(0.5).setDepth(D.modalUI)
.setShadow(0, 0, 'rgba(255,207,74,0.8)', 12);
this.countPanel = [g, t1, t2, t3];
}
clearCountPanel() {
(this.countPanel || []).forEach((o) => o.destroy());
this.countPanel = null;
}
// ── Round / game transitions ────────────────────────────────────────────────
onNextDeal() {
this.nextBtn.setVisible(false);
this.logic.nextRound();
this.beginDiscard();
}
clearTable() {
[...this.handSprites[0], ...this.handSprites[1], ...this.pegSprites, ...this.cribSprites].forEach((c) => c?.destroy());
this.starterSprite?.destroy();
this.clearCountPanel();
this.handSprites = [[], []];
this.pegSprites = [];
this.cribSprites = [];
this.starterSprite = null;
}
gameOver() {
this.clearCountPanel();
this.nextBtn.setVisible(false);
this.confirmBtn.setVisible(false);
const won = this.logic.winner === 0;
const skunk = this.logic.scores[1 - this.logic.winner] < 91;
playSound(this, won ? SFX.CASINO_WIN : SFX.CASINO_LOSE);
this.postHistory(won ? 'win' : 'loss');
const g = this.add.graphics().setDepth(D.modal);
g.fillStyle(0x000000, 0.7); g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
g.fillStyle(THEME.woodMid, 1); g.fillRoundedRect(GAME_WIDTH / 2 - 360, 360, 720, 360, 24);
g.lineStyle(4, THEME.brass, 1); g.strokeRoundedRect(GAME_WIDTH / 2 - 360, 360, 720, 360, 24);
this.add.text(GAME_WIDTH / 2, 450, won ? 'You Win!' : `${this.aiName} Wins`, {
fontFamily: 'Righteous', fontSize: '60px', color: won ? '#f0d77a' : '#e9e1cf' })
.setOrigin(0.5).setDepth(D.modalUI);
this.add.text(GAME_WIDTH / 2, 530, `${this.logic.scores[0]} ${this.logic.scores[1]}${skunk ? ' (Skunk!)' : ''}`, {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex })
.setOrigin(0.5).setDepth(D.modalUI);
new Button(this, GAME_WIDTH / 2, 650, 'Back to Menu', () => this.scene.start('GameMenu'),
{ width: 300, fontSize: 26 }).setDepth(D.modalUI);
}
async postHistory(result) {
if (this.recorded) return;
this.recorded = true;
try {
await api.post('/history/single-player', {
slug: 'cribbage',
score: this.logic.scores[0],
opponentScores: [this.logic.scores[1]],
result,
});
} catch { /* non-fatal */ }
}
delay(ms) { return new Promise((resolve) => this.time.delayedCall(ms, resolve)); }
}

View File

@ -0,0 +1,251 @@
// Cribbage — pure, deterministic game engine. No Phaser, no timers. The scene
// and the headless harness both drive it through the same granular methods so
// behaviour is identical on screen and under test.
import { Deck, WIN_SCORE, HAND_SIZE, PLAY_LIMIT, cribValue, runRank } from './CribbageData.js';
// ── Standalone scoring (also used by the AI) ────────────────────────────────
function scoreFifteens(cards) {
let count = 0;
const n = cards.length;
for (let mask = 1; mask < (1 << n); mask++) {
let sum = 0;
for (let k = 0; k < n; k++) if (mask & (1 << k)) sum += cribValue(cards[k]);
if (sum === 15) count++;
}
return count * 2;
}
function scorePairs(cards) {
let pts = 0;
for (let a = 0; a < cards.length; a++)
for (let b = a + 1; b < cards.length; b++)
if (cards[a].rank === cards[b].rank) pts += 2;
return pts;
}
function scoreRuns(cards) {
const cnt = new Map();
for (const c of cards) { const r = runRank(c); cnt.set(r, (cnt.get(r) || 0) + 1); }
const ranks = [...cnt.keys()].sort((a, b) => a - b);
let total = 0, i = 0;
while (i < ranks.length) {
let j = i, mult = cnt.get(ranks[i]);
while (j + 1 < ranks.length && ranks[j + 1] === ranks[j] + 1) { j++; mult *= cnt.get(ranks[j]); }
const len = j - i + 1;
if (len >= 3) total += len * mult;
i = j + 1;
}
return total;
}
function scoreFlush(hand4, starter, isCrib) {
const s0 = hand4[0].suit;
const all4 = hand4.every((c) => c.suit === s0);
if (!all4) return 0;
if (starter && starter.suit === s0) return 5;
return isCrib ? 0 : 4; // a 4-card flush never counts in the crib
}
function scoreNobs(hand4, starter) {
if (!starter) return 0;
return hand4.some((c) => c.rank === 'J' && c.suit === starter.suit) ? 1 : 0;
}
/**
* Score a 4-card hand (or crib) with the cut starter.
* @returns {{total, fifteens, pairs, runs, flush, nobs}}
*/
export function scoreHand(hand4, starter, isCrib = false) {
const all5 = starter ? [...hand4, starter] : [...hand4];
const fifteens = scoreFifteens(all5);
const pairs = scorePairs(all5);
const runs = scoreRuns(all5);
const flush = scoreFlush(hand4, starter, isCrib);
const nobs = scoreNobs(hand4, starter);
return { total: fifteens + pairs + runs + flush + nobs, fifteens, pairs, runs, flush, nobs };
}
/**
* Score the just-played card given the pile since the last reset.
* @returns {{points, reasons:[], count}}
*/
export function scorePlay(pile) {
const reasons = [];
let points = 0;
const count = pile.reduce((s, c) => s + cribValue(c), 0);
if (count === 15) { points += 2; reasons.push('15'); }
if (count === 31) { points += 2; reasons.push('31'); }
// Pairs / pair-royal: trailing equal ranks.
const last = pile[pile.length - 1];
let k = 1;
for (let i = pile.length - 2; i >= 0 && pile[i].rank === last.rank; i--) k++;
if (k >= 2) { points += { 2: 2, 3: 6, 4: 12 }[k]; reasons.push(k === 2 ? 'pair' : k === 3 ? 'pair-royal' : 'double-pair-royal'); }
// Runs: longest suffix of the pile that forms consecutive ranks (any order).
for (let len = pile.length; len >= 3; len--) {
const rr = pile.slice(pile.length - len).map(runRank).sort((a, b) => a - b);
let ok = true;
for (let i = 1; i < rr.length; i++) if (rr[i] !== rr[i - 1] + 1) { ok = false; break; }
if (ok) { points += len; reasons.push(`run${len}`); break; }
}
return { points, reasons, count };
}
// ── Game engine ─────────────────────────────────────────────────────────────
export class CribbageLogic {
/** @param {{rng?:()=>number, startDealer?:number}} opts */
constructor(opts = {}) {
this.rng = opts.rng || Math.random;
this.win = WIN_SCORE;
this.dealer = opts.startDealer ?? Math.floor(this.rng() * 2); // 0=human, 1=ai
this.scores = [0, 0];
this.prev = [0, 0]; // previous front-peg position (for leapfrog rendering)
this.winner = null;
this.phase = 'idle'; // idle | discard | play | show | roundover | gameover
}
get nonDealer() { return 1 - this.dealer; }
/** Award points; clamps at the win line and latches the winner. */
addPoints(player, pts) {
if (pts <= 0 || this.winner !== null) return 0;
this.prev[player] = this.scores[player];
this.scores[player] = Math.min(this.win, this.scores[player] + pts);
if (this.scores[player] >= this.win) { this.winner = player; this.phase = 'gameover'; }
return pts;
}
/** Shuffle, deal six to each, ready for discards. */
newDeal() {
const deck = new Deck();
// Fisher-Yates with the injected rng (Deck.shuffle uses Math.random directly).
const a = deck.cards;
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(this.rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
this.deck = deck;
this.hands = [deck.deal(HAND_SIZE), deck.deal(HAND_SIZE)]; // working 6-card hands
this.keep = [null, null]; // 4-card kept hands (set at discard)
this.crib = [];
this.starter = null;
this.toPlay = [null, null]; // unplayed cards during the play
this.pile = []; // cards since last reset
this.count = 0;
this.lastPlayer = null;
this.said = [false, false]; // "go" declared since last reset
this.turn = this.nonDealer; // non-dealer always leads the play
this.phase = 'discard';
return { dealer: this.dealer, hands: this.hands };
}
/** Lay two cards from a player into the crib. Returns true once both have. */
discard(player, cards) {
const set = new Set(cards.map((c) => c.key));
this.keep[player] = this.hands[player].filter((c) => !set.has(c.key));
this.crib.push(...this.hands[player].filter((c) => set.has(c.key)));
return this.keep[0] && this.keep[1];
}
/** Cut the starter from the rest of the deck. Handles "his heels" (+2). */
cut() {
const i = Math.floor(this.rng() * this.deck.remaining);
this.starter = this.deck.cards[i];
this.toPlay = [this.keep[0].slice(), this.keep[1].slice()];
this.phase = 'play';
let heels = 0;
if (this.starter.rank === 'J') heels = this.addPoints(this.dealer, 2);
return { starter: this.starter, heels, dealer: this.dealer };
}
// ── The play (pegging) ────────────────────────────────────────────────────
legalPlays(player) {
return this.toPlay[player].filter((c) => this.count + cribValue(c) <= PLAY_LIMIT);
}
playEnded() { return this.toPlay[0].length === 0 && this.toPlay[1].length === 0; }
/** Play one card. Advances the turn, scores pegs, handles 31/last-card resets. */
play(player, card) {
this.toPlay[player] = this.toPlay[player].filter((c) => c.key !== card.key);
this.pile.push(card);
this.count += cribValue(card);
this.lastPlayer = player;
const sc = scorePlay(this.pile);
const events = [];
if (sc.points) { this.addPoints(player, sc.points); events.push({ player, points: sc.points, reasons: sc.reasons }); }
const ended = this.playEnded();
if (this.count === PLAY_LIMIT) {
this._resetPile();
} else if (ended) {
// Last card of the deal pegs 1 (unless it just made 31, handled above).
const got = this.addPoints(player, 1);
if (got) events.push({ player, points: 1, reasons: ['last-card'] });
}
this.turn = 1 - player;
if (this.playEnded()) this.phase = 'show';
return { events, count: this.count, ended: this.playEnded(), pile: this.pile.slice() };
}
/** Current player cannot play. Passes or, if neither can, pegs the go. */
go(player) {
const opp = 1 - player;
this.said[player] = true;
// Opponent can still play → simply pass the turn to them.
if (this.legalPlays(opp).length > 0 && this.toPlay[opp].length > 0) {
this.turn = opp;
return { pass: true };
}
// Neither can play: last player to lay a card pegs 1 for the go.
const events = [];
if (this.lastPlayer != null) {
const got = this.addPoints(this.lastPlayer, 1);
if (got) events.push({ player: this.lastPlayer, points: 1, reasons: ['go'] });
}
const leader = this.lastPlayer != null ? 1 - this.lastPlayer : opp;
this._resetPile();
this.turn = leader;
if (this.playEnded()) this.phase = 'show';
return { goPoint: true, events, reset: true };
}
_resetPile() {
this.pile = [];
this.count = 0;
this.said = [false, false];
}
// ── The show ────────────────────────────────────────────────────────────
/** Count non-dealer hand, dealer hand, then the crib (dealer's). */
show() {
const results = [];
const order = [
{ player: this.nonDealer, cards: this.keep[this.nonDealer], type: 'hand', isCrib: false },
{ player: this.dealer, cards: this.keep[this.dealer], type: 'hand', isCrib: false },
{ player: this.dealer, cards: this.crib, type: 'crib', isCrib: true },
];
for (const o of order) {
const r = scoreHand(o.cards, this.starter, o.isCrib);
this.addPoints(o.player, r.total);
results.push({ ...o, score: r });
if (this.winner !== null) break;
}
if (this.phase !== 'gameover') this.phase = 'roundover';
return results;
}
/** Pass the deal and prepare the next hand. */
nextRound() {
this.dealer = 1 - this.dealer;
return this.newDeal();
}
}

View File

@ -0,0 +1,64 @@
# Welcome to Cribbage, Friend — Pull Up a Stool
*By Old Murph — lighthouse keeper, pipe smoker, and undefeated champion of the Saturday night pub board*
---
Evening to ya. Mind the cat. Sit down by the fire there — yes, on the good stool — and let me pour you something warm. You see that long wooden board on the table with all the little holes drilled in it? That, my friend, is a cribbage board, and it has settled more arguments out here on the point than the harbourmaster ever has. Three hundred years sailors have played this game. Tonight you learn it.
## The Goal
First one to peg **121 points** wins. You move two little pegs up the board as you score — back peg leapfrogs the front peg each time, so you can always see how many you just earned. Get all the way up one side and back down the other and you're home.
If you beat me before I round the corner at 90, that's a **skunk**, and I'll have to buy the next round. It won't happen. But you can try.
## A Hand, Start to Finish
### 1. The Deal
You each get **six cards**. One of us is the **dealer** — watch for the little "deals" mark by the name. The dealer changes every hand, so it evens out.
### 2. The Crib
Here's the wrinkle that makes cribbage cribbage. You each throw **two cards** away into a little extra hand called the **crib**. The crib belongs to the *dealer*, and it gets counted at the end like a second hand. So when it's your crib, toss in cards that might score together. When it's mine? Don't do me any favours — feed it junk.
Pick your two and hit **Discard to Crib**.
### 3. The Cut
We cut the deck for a **starter card**. It sits face-up and counts for *everybody's* hand and the crib. And if it's a **Jack** — "his heels" — the dealer pegs **2 points** right off the top. Lucky devils.
### 4. The Play (this is where the pegging happens)
Now we take turns laying cards down, calling out a running total. You can't go over **31**. While we play, you score on the spot for:
- **Fifteen** — making the count exactly 15 → **2 points**
- **Thirty-one** — hitting 31 on the nose → **2 points**
- **Pair** — matching the rank just played → **2** (three of a kind is **6**, four is **12**!)
- **Run** — three or more in a row, any order → **1 point per card**
- **Go / Last card** — when the other fella can't play without busting 31, the last to lay a card pegs **1**
Can't play without going over 31? You say **"Go."** When neither of us can, we sweep the cards, reset the count to zero, and carry on until all the cards are spent. (Aces are low and worth 1; all the face cards are worth 10.)
### 5. The Show
Now we count what's in our hands — **non-dealer first** (that matters in a close game!), then the dealer, then the dealer counts the **crib**. Add the starter card to each. Here's the scoring:
- **Fifteens***every* combination of cards adding to 15 → **2 each** (they stack up fast!)
- **Pairs****2** a pair
- **Runs****1 per card**, three or more
- **Flush** — four cards same suit in hand → **4** (five with the starter → **5**; the crib only counts a five-card flush)
- **Nobs** — holding the **Jack of the starter's suit** → **1**
The board counts it all up for you and walks the pegs along — no need to do the arithmetic in your head like we did in the old days.
## A Few Words of Wisdom
- **Fives are gold.** A five plus any ten, jack, queen, or king makes fifteen — and there are sixteen of those big cards in the deck.
- **Keep your runs and pairs together** when you choose what to throw to the crib.
- **Don't lead a five** during the play — you're just handing me a fifteen.
- **Count carefully near 121.** Non-dealer counts first in the show, so a trailing player can still nip in for the win.
That's the whole game. Deceptively simple, isn't it? You'll think you've got it, and then one hand the cards line up just so and you peg a fistful of points and laugh like a fool. The perfect hand is **29** — I've only had it twice in forty years.
Now then. Deal the cards. And don't touch the cat's stool again.

View File

@ -73,6 +73,8 @@ import ZumaGame from './games/zuma/ZumaGame.js';
import BejeweledGame from './games/bejeweled/BejeweledGame.js';
import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js';
import SlotsGame from './games/slots/SlotsGame.js';
import CribbageGame from './games/cribbage/CribbageGame.js';
import CanastaGame from './games/canasta/CanastaGame.js';
const config = {
type: Phaser.AUTO,
@ -159,6 +161,8 @@ const config = {
BejeweledGame,
MiniMotorwaysGame,
SlotsGame,
CribbageGame,
CanastaGame,
],
};

View File

@ -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' };
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' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -88,3 +88,5 @@ registerGame({ slug: 'zuma', name: 'Zuma', category: 'logic', minPlayers: 1, max
registerGame({ slug: 'bejeweled', name: 'Bejeweled Blitz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 61 });
registerGame({ slug: 'minimotorways', name: 'Mini Motorways', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 62 });
registerGame({ slug: 'slots', name: 'Slot Machines', category: 'casino', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 63 });
registerGame({ slug: 'cribbage', name: 'Cribbage', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 64 });
registerGame({ slug: 'canasta', name: 'Canasta', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 65 });

View File

@ -0,0 +1,236 @@
// Headless verification for Canasta.
// node server/scripts/verifyCanasta.js [--games=N]
// Exits non-zero on any failure.
//
// 1. Fixture tests: card values, canasta/red-three/go-out scoring, the initial
// meld minimum, freeze behaviour and discard-pile take legality.
// 2. Self-play: full partnership games driven by the heuristic AI in all four
// seats, asserting invariants (no exceptions, hands have legal sizes, melds
// are well-formed, the match terminates with a winner) over many seeded games.
import {
Card, cardScore, minimumMeld, NATURAL_CANASTA, MIXED_CANASTA, ALL_RED_THREES,
GO_OUT, CONCEALED_GO_OUT, TEAM_OF_SEAT, PLAYER_COUNT, isWild, isRedThree,
} from '../../public/src/games/canasta/CanastaData.js';
import {
createInitialState, drawStock, takeDiscard, meld, discard, startNextHand,
takePlan, scoreTeamHand, teamMeld, isCanasta, meldNaturals, meldWilds,
} from '../../public/src/games/canasta/CanastaLogic.js';
import { chooseDraw, chooseMelds, chooseDiscard } from '../../public/src/games/canasta/CanastaAI.js';
let failures = 0;
function check(name, cond, detail = '') {
if (cond) { console.log(` ok ${name}`); return; }
failures++;
console.error(` FAIL ${name}${detail ? `${detail}` : ''}`);
}
// Base ids well above the 0..107 the engine deck uses, so fixture cards injected
// into a real state never collide with dealt cards.
let _id = 100000;
const C = (rank, suit) => { const c = new Card(rank, suit); c.id = _id++; return c; };
const JK = () => C('JK', 'r');
// Minimal state for scoring fixtures.
function mkState() {
const players = [];
for (let i = 0; i < PLAYER_COUNT; i++) players.push({ seat: i, team: TEAM_OF_SEAT[i], hand: [] });
const teams = [0, 1].map(() => ({ melds: [], redThrees: [], hasMelded: false, score: 0 }));
return { players, teams, turnTeamMeldedAtStart: false };
}
// ── 1. Card values ─────────────────────────────────────────────────────────────
console.log('Card values:');
check('joker = 50', cardScore(C('JK', 'r')) === 50);
check('two = 20', cardScore(C('2', 's')) === 20);
check('ace = 20', cardScore(C('A', 'h')) === 20);
check('king = 10', cardScore(C('K', 'c')) === 10);
check('eight = 10', cardScore(C('8', 'd')) === 10);
check('seven = 5', cardScore(C('7', 's')) === 5);
check('four = 5', cardScore(C('4', 'c')) === 5);
// ── 2. Canasta + red-three + go-out scoring ─────────────────────────────────────
console.log('Scoring fixtures:');
{
const s = mkState();
s.teams[0].hasMelded = true;
// Natural canasta of seven kings: 70 cards + 500 bonus = 570.
s.teams[0].melds = [{ rank: 'K', cards: ['K','K','K','K','K','K','K'].map((r) => C(r, 's')) }];
const d = scoreTeamHand(s, 0, { outPlayer: null });
check('natural canasta cards = 70', d.meldPoints === 70, `got ${d.meldPoints}`);
check('natural canasta bonus = 500', d.canastaBonus === NATURAL_CANASTA, `got ${d.canastaBonus}`);
check('one natural canasta counted', d.naturalCanastas === 1);
}
{
const s = mkState();
s.teams[0].hasMelded = true;
// Mixed canasta: five sixes + two wilds.
s.teams[0].melds = [{ rank: '6', cards: [C('6','s'),C('6','d'),C('6','c'),C('6','h'),C('6','s'), JK(), C('2','c')] }];
const d = scoreTeamHand(s, 0, { outPlayer: null });
check('mixed canasta bonus = 300', d.canastaBonus === MIXED_CANASTA, `got ${d.canastaBonus}`);
check('mixed canasta counted', d.mixedCanastas === 1 && d.naturalCanastas === 0);
}
{
const s = mkState();
s.teams[1].hasMelded = true;
s.teams[1].redThrees = [C('3','h'), C('3','d'), C('3','h'), C('3','d')];
const d = scoreTeamHand(s, 1, { outPlayer: null });
check('all four red threes = 800', d.redThreeBonus === ALL_RED_THREES, `got ${d.redThreeBonus}`);
}
{
const s = mkState(); // team did NOT meld → red threes go negative
s.teams[0].hasMelded = false;
s.teams[0].redThrees = [C('3','h'), C('3','d')];
const d = scoreTeamHand(s, 0, { outPlayer: null });
check('unmelded red threes are negative', d.redThreeBonus === -200, `got ${d.redThreeBonus}`);
}
{
const s = mkState();
s.teams[0].hasMelded = true;
s.turnTeamMeldedAtStart = true;
const normal = scoreTeamHand(s, 0, { outPlayer: 0 });
check('go-out bonus = 100', normal.goOut === GO_OUT, `got ${normal.goOut}`);
s.turnTeamMeldedAtStart = false;
const concealed = scoreTeamHand(s, 0, { outPlayer: 2 });
check('concealed go-out = 200', concealed.goOut === CONCEALED_GO_OUT, `got ${concealed.goOut}`);
}
{
const s = mkState();
s.teams[0].hasMelded = true;
s.teams[0].melds = [{ rank: '5', cards: [C('5','s'),C('5','d'),C('5','c')] }]; // 15 pts
s.players[0].hand = [C('K','s'), C('A','h')]; // 30 pts left in hand
const d = scoreTeamHand(s, 0, { outPlayer: null });
check('hand cards deducted', d.handPenalty === 30 && d.total === 15 - 30, `got total ${d.total}`);
}
// ── 3. Minimum meld thresholds ──────────────────────────────────────────────────
console.log('Minimum meld:');
check('negative score → 15', minimumMeld(-50) === 15);
check('0 → 50', minimumMeld(0) === 50);
check('1495 → 50', minimumMeld(1495) === 50);
check('1500 → 90', minimumMeld(1500) === 90);
check('3000 → 120', minimumMeld(3000) === 120);
// ── 4. Take-pile legality ───────────────────────────────────────────────────────
console.log('Take-pile legality:');
{
const s = createInitialState({ seed: 7 });
const seat = s.currentPlayer;
// Force a known top card and a matching natural pair in hand.
s.discard = [C('9','s')];
s.frozen = false;
s.players[seat].hand = [C('9','d'), C('9','c'), C('K','h'), C('4','s')];
const plan = takePlan(s, seat);
check('two naturals can take an unfrozen pile', !!plan && plan.naturalIds.length === 2);
}
{
const s = createInitialState({ seed: 8 });
const seat = s.currentPlayer;
s.discard = [C('9','s')];
s.frozen = true;
s.players[seat].hand = [C('9','d'), JK(), C('K','h')]; // one natural + wild, but frozen
check('frozen pile needs two naturals (one+wild fails)', takePlan(s, seat) === null);
}
{
const s = createInitialState({ seed: 9 });
const seat = s.currentPlayer;
s.discard = [JK()]; // wild on top can never be captured
s.frozen = true;
s.players[seat].hand = [JK(), C('2','c'), C('K','h')];
check('cannot take a pile topped by a wild', takePlan(s, seat) === null);
}
{
const s = createInitialState({ seed: 10 });
const seat = s.currentPlayer;
const t = s.teams[TEAM_OF_SEAT[seat]];
t.hasMelded = true;
t.melds = [{ rank: '9', cards: [C('9','s'),C('9','d'),C('9','c')] }];
s.discard = [C('9','h')];
s.frozen = false;
s.players[seat].hand = [C('9','c'), C('K','h')]; // one natural + existing meld
const plan = takePlan(s, seat);
check('one natural takes via existing meld when unfrozen', !!plan);
}
// ── 5. Freeze on wild discard (full turn through the engine) ─────────────────────
console.log('Freeze behaviour:');
{
let s = createInitialState({ seed: 3 });
const seat = s.currentPlayer;
s = drawStock(s);
// Give the player a wild to discard.
s.players[seat].hand.push(C('2', 's'));
const wild = s.players[seat].hand[s.players[seat].hand.length - 1];
s = discard(s, wild.id);
check('discarding a wild freezes the pile', s.frozen === true);
}
// ── 6. Self-play ────────────────────────────────────────────────────────────────
const games = Number((process.argv.find((a) => a.startsWith('--games=')) || '').split('=')[1]) || 300;
console.log(`Self-play (${games} games):`);
function meldWellFormed(m) {
const n = meldNaturals(m), w = meldWilds(m);
return n >= 2 && w <= 3 && w <= n;
}
function aiTurn(s) {
const seat = s.currentPlayer;
const skill = 3 + (seat % 2); // alternate 3 / 4
const draw = chooseDraw(s, seat, skill);
s = draw.type === 'take' ? takeDiscard(s, draw.plan) : drawStock(s);
if (s.phase === 'handOver' || s.phase === 'gameOver') return s; // stock-out during draw
if (s.phase === 'draw') s = drawStock(s); // take was rejected → fall back to stock
if (s.phase === 'handOver' || s.phase === 'gameOver') return s;
const { actions } = chooseMelds(s, seat, skill);
for (const a of actions) s = meld(s, a.rank, a.cardIds);
if (s.phase === 'meld') {
const cardId = chooseDiscard(s, seat, skill);
s = discard(s, cardId);
}
return s;
}
let wins = [0, 0], exceptions = 0, maxHands = 0, malformed = 0, badHandSize = 0;
let meldedGames = 0, draws = 0;
for (let g = 1; g <= games; g++) {
try {
let s = createInitialState({ seed: g * 2654435761 });
let turns = 0, hands = 0, sawMeld = false;
while (s.phase !== 'gameOver') {
if (s.phase === 'handOver') {
if (++hands > 400) throw new Error('match did not terminate');
s = startNextHand(s);
continue;
}
if (++turns > 100000) throw new Error('turn loop did not terminate');
const before = s.currentPlayer;
s = aiTurn(s);
// Validate any melds present.
for (const t of s.teams) for (const m of t.melds) {
if (m.cards.length >= 3 && !meldWellFormed(m)) malformed++;
if (t.melds.length) sawMeld = true;
}
// Hand sizes never negative / absurd.
for (const p of s.players) if (p.hand.length < 0 || p.hand.length > 40) badHandSize++;
if (s.currentPlayer === before && s.phase === 'draw') throw new Error('turn failed to advance');
}
if (sawMeld) meldedGames++;
maxHands = Math.max(maxHands, hands);
if (s.winnerTeam === null) draws++;
else wins[s.winnerTeam]++;
} catch (e) {
exceptions++;
if (exceptions <= 5) console.error(` game ${g}: ${e.message}`);
}
}
check('no exceptions during self-play', exceptions === 0, `${exceptions} games threw`);
check('all melds well-formed', malformed === 0, `${malformed} malformed`);
check('hand sizes stay sane', badHandSize === 0, `${badHandSize} bad`);
check('teams meld in most games', meldedGames >= games * 0.9, `${meldedGames}/${games}`);
check('both teams win some games', wins[0] > 0 && wins[1] > 0, `wins ${wins[0]}/${wins[1]}, draws ${draws}`);
console.log(` results: team0 ${wins[0]} / team1 ${wins[1]} / draws ${draws}, longest match ${maxHands} hands`);
console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed.');
process.exit(failures ? 1 : 0);

View File

@ -0,0 +1,128 @@
// Headless verification for Cribbage.
// node server/scripts/verifyCribbage.js [--games=N]
// Exits non-zero on any failure.
//
// 1. Fixture tests: canonical hands (perfect 29, flush, nobs) and pegging
// combinations (fifteen, pairs, runs) score the known values.
// 2. Self-play: full games driven by the heuristic AI on both seats, asserting
// invariants (scores never pass 121 before a win, the play always terminates,
// a winner is reached) over many seeded games.
import { Card } from '../../public/src/games/cribbage/CribbageData.js';
import { CribbageLogic, scoreHand, scorePlay } from '../../public/src/games/cribbage/CribbageLogic.js';
import { chooseDiscard, choosePlay } from '../../public/src/games/cribbage/CribbageAI.js';
let failures = 0;
function check(name, cond, detail = '') {
if (cond) { console.log(` ok ${name}`); return; }
failures++;
console.error(` FAIL ${name}${detail ? `${detail}` : ''}`);
}
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const C = (rank, suit) => new Card(rank, suit);
// ── 1. Scoring fixtures ─────────────────────────────────────────────────────
console.log('Scoring fixtures:');
{
// Perfect 29: J♥ + 5♠ 5♣ 5♦, cut 5♥.
const hand = [C('J', 'h'), C('5', 's'), C('5', 'c'), C('5', 'd')];
const r = scoreHand(hand, C('5', 'h'), false);
check('perfect hand scores 29', r.total === 29, `got ${r.total}`);
check(' → 16 in fifteens', r.fifteens === 16, `got ${r.fifteens}`);
check(' → 12 in pairs', r.pairs === 12, `got ${r.pairs}`);
check(' → 1 for nobs', r.nobs === 1, `got ${r.nobs}`);
}
{
// Four-card hand flush, starter off-suit → 4; on-suit → 5.
const hand = [C('2', 's'), C('4', 's'), C('6', 's'), C('9', 's')];
check('4-card flush, off-suit cut → 4', scoreHand(hand, C('K', 'h')).flush === 4);
check('5-card flush, on-suit cut → 5', scoreHand(hand, C('K', 's')).flush === 5);
}
{
// Crib needs all five suited; a 4-card crib flush does not count.
const crib = [C('2', 's'), C('4', 's'), C('6', 's'), C('9', 's')];
check('crib 4-flush off-suit → 0', scoreHand(crib, C('K', 'h'), true).flush === 0);
check('crib 5-flush on-suit → 5', scoreHand(crib, C('K', 's'), true).flush === 5);
}
{
// Nobs: Jack matching the starter suit.
const hand = [C('J', 'd'), C('3', 's'), C('7', 'c'), C('9', 'h')];
check('nobs when J matches starter suit', scoreHand(hand, C('A', 'd')).nobs === 1);
check('no nobs when J off-suit', scoreHand(hand, C('A', 'c')).nobs === 0);
}
{
// A double run of three with a pair = 8 (run 3×2 + pair 2) plus fifteens.
const r = scoreHand([C('3', 's'), C('4', 's'), C('5', 'c'), C('5', 'd')], C('6', 'h'));
// runs: 3-4-5-6 doubled (two 5s) = 8; pair of 5s = 2; fifteens: (4+5+6),(4+5+6),(5+5+... )
check('double run + pair runs = 8', r.runs === 8, `got ${r.runs}`);
check('double run pair = 2', r.pairs === 2, `got ${r.pairs}`);
}
// ── 2. Pegging fixtures ─────────────────────────────────────────────────────
console.log('Pegging fixtures:');
check('fifteen pegs 2', scorePlay([C('7', 's'), C('8', 'd')]).points === 2);
check('thirty-one pegs 2', scorePlay([C('K', 's'), C('T', 'd'), C('6', 'c'), C('5', 'h')]).points === 2);
check('pair pegs 2', scorePlay([C('4', 's'), C('4', 'd')]).points === 2);
check('pair-royal pegs 6', scorePlay([C('4', 's'), C('4', 'd'), C('4', 'c')]).points === 6);
check('run of 3 pegs 3', scorePlay([C('3', 's'), C('5', 'd'), C('4', 'c')]).points === 3);
check('run of 4 pegs 4', scorePlay([C('3', 's'), C('5', 'd'), C('4', 'c'), C('6', 'h')]).points === 4);
check('broken run scores no run', scorePlay([C('3', 's'), C('5', 'd'), C('8', 'c')]).points === 0);
// ── 3. Self-play ────────────────────────────────────────────────────────────
const games = Number((process.argv.find((a) => a.startsWith('--games=')) || '').split('=')[1]) || 500;
console.log(`Self-play (${games} games):`);
function runPlay(g) {
let guard = 0;
while (g.phase === 'play') {
if (++guard > 200) throw new Error('pegging did not terminate');
const p = g.turn;
const legal = g.legalPlays(p);
if (legal.length === 0) g.go(p);
else g.play(p, choosePlay(legal, g.pile, g.count, 3 + (p % 2)));
}
}
let wins = [0, 0], maxRounds = 0, overflow = false, exceptions = 0;
for (let s = 1; s <= games; s++) {
try {
const g = new CribbageLogic({ rng: mulberry32(s * 2654435761), startDealer: s % 2 });
let rounds = 0;
while (g.winner === null) {
if (++rounds > 300) throw new Error('game did not terminate');
g.newDeal();
for (const p of [0, 1]) g.discard(p, chooseDiscard(g.hands[p], p === g.dealer, p === 0 ? 4 : 3));
g.cut();
if (g.scores[0] > 121 || g.scores[1] > 121) overflow = true;
if (g.winner !== null) break;
runPlay(g);
if (g.scores[0] > 121 || g.scores[1] > 121) overflow = true;
if (g.winner !== null) break;
g.show();
if (g.scores[0] > 121 || g.scores[1] > 121) overflow = true;
if (g.winner !== null) break;
g.dealer = 1 - g.dealer;
}
wins[g.winner]++;
maxRounds = Math.max(maxRounds, rounds);
} catch (e) {
exceptions++;
if (exceptions <= 3) console.error(` game ${s}: ${e.message}`);
}
}
check('no exceptions during self-play', exceptions === 0, `${exceptions} games threw`);
check('scores never exceed 121', !overflow);
check('both seats win some games', wins[0] > 0 && wins[1] > 0, `wins ${wins[0]}/${wins[1]}`);
console.log(` results: human ${wins[0]} / ai ${wins[1]}, longest game ${maxRounds} deals`);
console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed.');
process.exit(failures ? 1 : 0);