fertig-classic-games/src/games/oldmaid/OldMaidLogic.js

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