// Hearts — pure state engine. No Phaser imports. // // Classic rules: // - Always 4 players, standard 52-card deck, 13 cards each. // - Before each hand players pass 3 cards. Direction cycles by hand number: // left → right → across → hold(no pass), repeating. // - The holder of 2♣ leads the first trick (and must lead the 2♣). // - Must follow the led suit if able; otherwise play any card. // - Hearts may not be *led* until "broken" (a heart has been played to a // trick) — unless the leader holds only hearts. // - No points (hearts or Q♠) may be played on the first trick, unless a // player holds nothing but point cards. // - Highest card of the led suit wins the trick; winner leads the next. // - Scoring: each heart = 1 pt, Q♠ = 13 pts (26 per hand). // - Shooting the moon: if one player takes all 26 points, they score 0 and // every other player gets +26. // - Match runs until any player's total reaches 100; lowest total wins. import { SUITS, RANKS, Card } from '../cards/Deck.js'; export const HAND_SIZE = 13; export const PLAYER_COUNT = 4; export const PASS_COUNT = 3; export const GAME_OVER_SCORE = 100; export const MAX_POINTS = 26; // Pass direction by hand index (0-based), repeating every 4 hands. const PASS_CYCLE = ['left', 'right', 'across', 'hold']; // Mulberry32 — 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]]; } } function buildDeck() { const cards = []; let id = 0; for (const suit of SUITS) { for (const rank of RANKS) { const c = new Card(rank, suit); c.id = id++; cards.push(c); } } return cards; } function cloneCard(c) { const out = new Card(c.rank, c.suit); out.id = c.id; return out; } /** Point value of a single card. */ export function cardPoints(card) { if (card.suit === 'h') return 1; if (card.suit === 's' && card.rank === 'Q') return 13; return 0; } export function isQueenOfSpades(card) { return card.suit === 's' && card.rank === 'Q'; } export function cloneState(state) { return { players: state.players.map((p) => ({ seat: p.seat, hand: p.hand.map(cloneCard), wonCards: p.wonCards.map(cloneCard), roundPoints: p.roundPoints, totalScore: p.totalScore, })), phase: state.phase, handNumber: state.handNumber, passDirection: state.passDirection, pendingPass: state.pendingPass.map((arr) => arr.slice()), passReady: state.passReady.slice(), currentPlayer: state.currentPlayer, leadSuit: state.leadSuit, trick: state.trick.map((t) => ({ seat: t.seat, card: cloneCard(t.card) })), trickNumber: state.trickNumber, heartsBroken: state.heartsBroken, lastTrick: state.lastTrick ? { winnerSeat: state.lastTrick.winnerSeat, plays: state.lastTrick.plays.map((t) => ({ seat: t.seat, card: cloneCard(t.card) })), points: state.lastTrick.points } : null, handScores: state.handScores ? state.handScores.slice() : null, moonShooter: state.moonShooter, winnerSeats: state.winnerSeats.slice(), seed: state.seed, log: state.log.map((e) => ({ ...e })), }; } /** Find the seat holding the 2 of clubs. */ function seatWith2Clubs(players) { for (const p of players) { if (p.hand.some((c) => c.suit === 'c' && c.rank === '2')) return p.seat; } return 0; } /** Deal a fresh hand into the existing state (mutates), resetting per-hand fields. */ function dealHand(state, rand) { const deck = buildDeck(); shuffle(deck, rand); for (let seat = 0; seat < PLAYER_COUNT; seat++) { state.players[seat].hand = deck.splice(0, HAND_SIZE); state.players[seat].wonCards = []; state.players[seat].roundPoints = 0; } state.passDirection = PASS_CYCLE[state.handNumber % PASS_CYCLE.length]; state.pendingPass = [[], [], [], []]; state.passReady = [false, false, false, false]; state.trick = []; state.leadSuit = null; state.trickNumber = 0; state.heartsBroken = false; state.lastTrick = null; state.handScores = null; state.moonShooter = null; if (state.passDirection === 'hold') { // No passing this hand — go straight to play. state.phase = 'playing'; state.currentPlayer = seatWith2Clubs(state.players); } else { state.phase = 'passing'; state.currentPlayer = 0; } } 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, hand: [], wonCards: [], roundPoints: 0, totalScore: 0 }); } const state = { players, phase: 'passing', handNumber: 0, passDirection: 'left', pendingPass: [[], [], [], []], passReady: [false, false, false, false], currentPlayer: 0, leadSuit: null, trick: [], trickNumber: 0, heartsBroken: false, lastTrick: null, handScores: null, moonShooter: null, winnerSeats: [], seed: seed ?? null, log: [], _rand: rand, }; dealHand(state, rand); return state; } // Seat that `seat` passes to, given the current direction. export function passTargetSeat(seat, direction) { switch (direction) { case 'left': return (seat + 1) % PLAYER_COUNT; case 'right': return (seat + PLAYER_COUNT - 1) % PLAYER_COUNT; case 'across': return (seat + 2) % PLAYER_COUNT; default: return seat; } } /** * Record a seat's chosen 3 cards for passing. Does not move cards yet. * Returns a new state. When all four seats are ready, the pass resolves. */ export function selectPass(state, seat, cardIds) { if (state.phase !== 'passing') return state; if (state.passReady[seat]) return state; if (!Array.isArray(cardIds) || cardIds.length !== PASS_COUNT) return state; const hand = state.players[seat].hand; const chosen = cardIds.map((id) => hand.find((c) => c.id === id)).filter(Boolean); if (chosen.length !== PASS_COUNT) return state; const next = cloneState(state); next.pendingPass[seat] = cardIds.slice(); next.passReady[seat] = true; if (next.passReady.every(Boolean)) resolvePass(next); return next; } function resolvePass(state) { const removed = []; // removed[seat] = Card[] for (let seat = 0; seat < PLAYER_COUNT; seat++) { const ids = new Set(state.pendingPass[seat]); const player = state.players[seat]; removed[seat] = player.hand.filter((c) => ids.has(c.id)); player.hand = player.hand.filter((c) => !ids.has(c.id)); } for (let seat = 0; seat < PLAYER_COUNT; seat++) { const target = passTargetSeat(seat, state.passDirection); state.players[target].hand.push(...removed[seat]); } state.phase = 'playing'; state.currentPlayer = seatWith2Clubs(state.players); state.log.push({ kind: 'passResolved', direction: state.passDirection }); } function handHasOnly(hand, predicate) { return hand.length > 0 && hand.every(predicate); } /** * Cards `seat` may legally play right now. Returns Card[] (references into the * player's hand). Empty if it's not their turn / not the playing phase. */ export function legalPlays(state, seat) { if (state.phase !== 'playing') return []; if (state.currentPlayer !== seat) return []; const hand = state.players[seat].hand; if (hand.length === 0) return []; const isFirstTrick = state.trickNumber === 0; const leading = state.trick.length === 0; // First card of the very first trick must be the 2 of clubs. if (isFirstTrick && leading) { const twoClubs = hand.find((c) => c.suit === 'c' && c.rank === '2'); return twoClubs ? [twoClubs] : hand.slice(); } if (leading) { // Leader can't open with hearts until broken, unless only hearts remain. if (!state.heartsBroken) { const nonHearts = hand.filter((c) => c.suit !== 'h'); if (nonHearts.length > 0) return nonHearts; } return hand.slice(); } // Following: must follow the led suit if possible. const sameSuit = hand.filter((c) => c.suit === state.leadSuit); let candidates = sameSuit.length > 0 ? sameSuit : hand.slice(); // No points may be played on the first trick (unless forced). if (isFirstTrick) { const nonPoint = candidates.filter((c) => cardPoints(c) === 0); if (nonPoint.length > 0) candidates = nonPoint; } return candidates; } export function isLegalPlay(state, seat, cardId) { return legalPlays(state, seat).some((c) => c.id === cardId); } /** * Play one card for the current player. Resolves the trick when the fourth * card lands, and scores / re-deals / ends the match as needed. * Returns a new state. */ export function playCard(state, cardId) { if (state.phase !== 'playing') return state; const seat = state.currentPlayer; if (!isLegalPlay(state, seat, cardId)) return state; const next = cloneState(state); const player = next.players[seat]; const idx = player.hand.findIndex((c) => c.id === cardId); const [card] = player.hand.splice(idx, 1); if (next.trick.length === 0) next.leadSuit = card.suit; next.trick.push({ seat, card }); if (card.suit === 'h') next.heartsBroken = true; next.log.push({ kind: 'play', seat, card: { rank: card.rank, suit: card.suit }, leadSuit: next.leadSuit }); if (next.trick.length < PLAYER_COUNT) { next.currentPlayer = (seat + 1) % PLAYER_COUNT; return next; } // Trick complete — determine the winner (high card of the led suit). resolveTrick(next); return next; } function resolveTrick(state) { const lead = state.leadSuit; let winner = state.trick[0]; for (const t of state.trick) { if (t.card.suit === lead && t.card.value > winner.card.value) winner = t; } const points = state.trick.reduce((sum, t) => sum + cardPoints(t.card), 0); const winnerSeat = winner.seat; const wonCards = state.trick.map((t) => t.card); state.players[winnerSeat].wonCards.push(...wonCards); state.players[winnerSeat].roundPoints += points; state.lastTrick = { winnerSeat, plays: state.trick.map((t) => ({ seat: t.seat, card: cloneCard(t.card) })), points, }; state.log.push({ kind: 'trickWon', winnerSeat, points }); state.trick = []; state.leadSuit = null; state.trickNumber += 1; const handDone = state.players.every((p) => p.hand.length === 0); if (handDone) { scoreHand(state); } else { state.currentPlayer = winnerSeat; } } function scoreHand(state) { const roundPoints = state.players.map((p) => p.roundPoints); const shooter = roundPoints.findIndex((pts) => pts === MAX_POINTS); const applied = roundPoints.slice(); if (shooter !== -1) { // Shoot the moon: shooter scores 0, everyone else +26. state.moonShooter = shooter; for (let seat = 0; seat < PLAYER_COUNT; seat++) { applied[seat] = seat === shooter ? 0 : MAX_POINTS; } } else { state.moonShooter = null; } for (let seat = 0; seat < PLAYER_COUNT; seat++) { state.players[seat].totalScore += applied[seat]; } state.handScores = applied; state.log.push({ kind: 'handScored', applied, shooter }); const reached = state.players.some((p) => p.totalScore >= GAME_OVER_SCORE); if (reached) { state.phase = 'gameOver'; const min = Math.min(...state.players.map((p) => p.totalScore)); state.winnerSeats = state.players.filter((p) => p.totalScore === min).map((p) => p.seat); } else { state.phase = 'handOver'; } } /** * Begin the next hand after a 'handOver' pause. Re-deals and sets up passing. * Returns a new state. */ export function startNextHand(state) { if (state.phase !== 'handOver') return state; const next = cloneState(state); next._rand = state._rand ?? Math.random; next.handNumber += 1; dealHand(next, next._rand); return next; } export function isGameOver(state) { return state.phase === 'gameOver'; }