379 lines
12 KiB
JavaScript
379 lines
12 KiB
JavaScript
// 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';
|
|
}
|