// Old Maid — pure state engine. No Phaser imports. // // Rules variant: // - Standard 52-card deck PLUS one special unmatched "Old Maid" card (53 total). // - Deal every card round-robin to 4 players. Each player immediately discards // every pair (two cards of equal rank). The Old Maid card has its own sentinel // rank ('OM') and can never pair. // - On your turn you draw one card (blind) from the next active seat clockwise, // add it to your hand, and discard a pair if the draw completes one. Turn then // passes to that neighbor. // - A player whose hand empties is "safe" and is removed from the rotation. // - Play continues until exactly one card remains in play — the Old Maid — and // its holder LOSES. Everyone else is safe. import { SUITS, RANKS, Card } from '../cards/Deck.js'; export const OLD_MAID_RANK = 'OM'; // Mulberry32 — seedable PRNG (mirrors GoFishLogic). 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, seed) { const rand = seed === undefined ? Math.random : rng(seed); for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } } /** The special unmatched card. Not a real playing card, so we mint a plain object. */ function makeOldMaidCard(id) { return { rank: OLD_MAID_RANK, suit: null, value: 0, key: 'OM', isOldMaid: true, id }; } 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); } } cards.push(makeOldMaidCard(id++)); return cards; } function cloneCard(c) { if (c.isOldMaid) return makeOldMaidCard(c.id); const out = new Card(c.rank, c.suit); out.id = c.id; return out; } export function cloneState(state) { return { players: state.players.map((p) => ({ seat: p.seat, hand: p.hand.map(cloneCard), discardPairs: p.discardPairs, safe: p.safe, finishOrder: p.finishOrder, })), currentPlayer: state.currentPlayer, phase: state.phase, lastDraw: state.lastDraw ? { ...state.lastDraw } : null, loserSeat: state.loserSeat, log: state.log.map((e) => ({ ...e })), turnCount: state.turnCount, finishCounter: state.finishCounter, seed: state.seed, }; } export function createInitialState({ playerCount = 4, seed } = {}) { if (playerCount < 2 || playerCount > 4) { throw new Error(`Old Maid supports 2..4 players, got ${playerCount}`); } const deck = buildDeck(); shuffle(deck, seed); const players = []; for (let i = 0; i < playerCount; i++) { players.push({ seat: i, hand: [], discardPairs: 0, safe: false, finishOrder: null }); } // Round-robin deal of the whole deck. for (let i = 0; i < deck.length; i++) { players[i % playerCount].hand.push(deck[i]); } const state = { players, currentPlayer: 0, phase: 'play', lastDraw: null, loserSeat: -1, log: [], turnCount: 0, finishCounter: 0, seed: seed ?? null, initialDealPairs: [], }; // Discard any pairs dealt into the opening hands. for (const p of state.players) { const { count, pairedCards } = discardPairs(p, state); if (count > 0) state.initialDealPairs.push({ seat: p.seat, pairedCards }); markSafeIfEmpty(state, p.seat); } // First active seat takes the opening turn. if (!isActive(state, state.currentPlayer)) advanceTurn(state); checkGameOver(state); return state; } /** * Repeatedly remove any 2 cards of the same rank from the player's hand. The * Old Maid card (rank 'OM') never pairs. Returns { count, pairedCards } where * pairedCards holds clones of every removed card for animation purposes. */ export function discardPairs(player, state) { let collected = 0; const pairedCards = []; while (true) { const byRank = new Map(); let foundRank = null; for (const c of player.hand) { if (c.rank === OLD_MAID_RANK) continue; const list = byRank.get(c.rank) ?? []; list.push(c); byRank.set(c.rank, list); if (list.length >= 2) { foundRank = c.rank; break; } } if (!foundRank) break; const group = byRank.get(foundRank).slice(0, 2); pairedCards.push(...group.map(cloneCard)); const idsToRemove = new Set(group.map((c) => c.id)); player.hand = player.hand.filter((c) => !idsToRemove.has(c.id)); player.discardPairs += 1; collected += 1; if (state) state.log.push({ kind: 'pair', seat: player.seat, rank: foundRank }); } return { count: collected, pairedCards }; } function isActive(state, seat) { const p = state.players[seat]; return p && !p.safe && p.hand.length > 0; } function markSafeIfEmpty(state, seat) { const p = state.players[seat]; if (!p.safe && p.hand.length === 0) { p.safe = true; p.finishOrder = state.finishCounter++; state.log.push({ kind: 'safe', seat }); } } /** The seat the given player draws from: next active seat clockwise. */ export function drawTargetSeat(state, seat) { const N = state.players.length; let next = (seat + 1) % N; let safety = N; while (safety-- > 0) { if (next !== seat && isActive(state, next)) return next; next = (next + 1) % N; } return -1; } export function canDraw(state, seat) { if (state.phase !== 'play') return false; if (state.currentPlayer !== seat) return false; if (!isActive(state, seat)) return false; return drawTargetSeat(state, seat) !== -1; } /** Cards the current player may draw (the whole target hand — draws are blind). */ export function legalDraws(state, seat) { if (!canDraw(state, seat)) return []; const target = drawTargetSeat(state, seat); return state.players[target].hand.map((c) => c.id); } /** * Draw card `cardId` from the current player's draw target into their hand, * discard a resulting pair, and advance the turn. Returns a new state plus a * `lastDraw` description for the UI to animate. */ export function applyDraw(state, drawerSeat, cardId) { if (state.phase !== 'play') return state; if (state.currentPlayer !== drawerSeat) return state; const targetSeat = drawTargetSeat(state, drawerSeat); if (targetSeat === -1) return state; const next = cloneState(state); const drawer = next.players[drawerSeat]; const target = next.players[targetSeat]; const idx = target.hand.findIndex((c) => c.id === cardId); if (idx === -1) return state; // not a card the target holds const [card] = target.hand.splice(idx, 1); drawer.hand.push(card); next.log.push({ kind: 'draw', drawerSeat, fromSeat: targetSeat, cardId: card.id, isOldMaid: !!card.isOldMaid }); const { count: pairs, pairedCards } = discardPairs(drawer, next); next.lastDraw = { drawerSeat, fromSeat: targetSeat, card: cloneCard(card), paired: pairs > 0, pairedCards, }; // Target may have emptied; drawer may have emptied by pairing. markSafeIfEmpty(next, targetSeat); markSafeIfEmpty(next, drawerSeat); advanceTurn(next); checkGameOver(next); return next; } function advanceTurn(state) { state.turnCount += 1; const N = state.players.length; let next = (state.currentPlayer + 1) % N; let safety = N + 1; while (safety-- > 0) { if (isActive(state, next)) { state.currentPlayer = next; return; } next = (next + 1) % N; } // No active player can act — game over detected separately. } function checkGameOver(state) { if (state.phase !== 'play') return; const holders = state.players.filter((p) => p.hand.length > 0); // Game ends when a single player is left holding cards (the lone Old Maid), // or when no draw is possible (defensive: fewer than 2 active seats). const activeSeats = state.players.filter((_, s) => isActive(state, s)); if (holders.length <= 1 || activeSeats.length < 2) { state.phase = 'gameOver'; const loser = holders.find((p) => p.hand.some((c) => c.isOldMaid)); state.loserSeat = loser ? loser.seat : (holders[0]?.seat ?? -1); } } export function isGameOver(state) { return state.phase === 'gameOver'; }