264 lines
8.1 KiB
JavaScript
264 lines
8.1 KiB
JavaScript
// 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';
|
|
}
|