fertig-classic-games/src/games/hearts/HeartsLogic.js

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';
}