fertig-classic-games/public/src/games/uno/UnoLogic.js

447 lines
15 KiB
JavaScript

// Uno — pure state engine. No Phaser imports.
//
// Mattel rules (official):
// - 108-card deck; 7 cards dealt to each of 2..4 players.
// - On your turn, play a card matching the top discard's color, number, or
// action; or play a Wild; or draw one card from the draw pile. If the drawn
// card is playable you may play it immediately; otherwise the turn passes.
// - Action cards: Skip (next player loses turn), Reverse (flip direction;
// in a 2-player game it acts like a Skip), Draw 2 (next player draws 2 and
// loses turn), Wild (choose color), Wild Draw 4 (choose color; next player
// draws 4 and loses turn — but may challenge first).
// - Wild Draw 4 is only legal if you have no card matching the active color.
// If challenged and the challenge succeeds, the player who played it draws
// the 4 and play continues normally; if the challenge fails, the challenger
// draws 6 instead and loses their turn.
// - No stacking, no jump-in, no 7-0 swap.
// - First player to empty their hand wins.
import { buildDeck, cloneCard, UNO_COLORS } from './UnoDeck.js';
export const HAND_DEAL = 7;
// ── PRNG (Mulberry32) — mirrors GoFishLogic for seedable shuffles. ──────────
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]];
}
}
// ── State helpers ───────────────────────────────────────────────────────────
export function cloneState(state) {
return {
players: state.players.map((p) => ({
seat: p.seat,
hand: p.hand.map(cloneCard),
})),
drawPile: state.drawPile.map(cloneCard),
discardPile: state.discardPile.map(cloneCard),
currentPlayer: state.currentPlayer,
direction: state.direction,
currentColor: state.currentColor,
phase: state.phase,
pendingWild4: state.pendingWild4 ? { ...state.pendingWild4 } : null,
pendingDrawn: state.pendingDrawn ? cloneCard(state.pendingDrawn) : null,
lastAction: state.lastAction ? { ...state.lastAction } : null,
log: state.log.map((e) => ({ ...e })),
winnerSeat: state.winnerSeat,
seed: state.seed,
turnCount: state.turnCount,
};
}
function topDiscard(state) {
return state.discardPile[state.discardPile.length - 1];
}
function nextSeat(state, fromSeat = state.currentPlayer, hops = 1) {
const N = state.players.length;
let s = fromSeat;
for (let i = 0; i < hops; i++) {
s = ((s + state.direction) % N + N) % N;
}
return s;
}
function advanceTurn(state, hops = 1) {
state.currentPlayer = nextSeat(state, state.currentPlayer, hops);
state.turnCount += 1;
}
/**
* If the draw pile is empty, take all but the top discard, reset wilds, shuffle,
* and make it the new draw pile. No-op if the draw pile still has cards.
*/
function reshuffleIfNeeded(state) {
if (state.drawPile.length > 0) return;
if (state.discardPile.length <= 1) return; // shouldn't happen mid-game
const top = state.discardPile.pop();
const recycled = state.discardPile;
for (const c of recycled) {
if (c.color === 'w') c.chosenColor = null;
}
shuffle(recycled);
state.drawPile = recycled;
state.discardPile = [top];
state.log.push({ kind: 'reshuffle', count: recycled.length });
}
function drawN(state, seat, n) {
const drawn = [];
for (let i = 0; i < n; i++) {
reshuffleIfNeeded(state);
if (state.drawPile.length === 0) break; // pathological — both piles dry
const c = state.drawPile.pop();
state.players[seat].hand.push(c);
drawn.push(c);
}
return drawn;
}
// ── Legality ────────────────────────────────────────────────────────────────
/**
* Whether `card` may be played on top of the current discard given
* state.currentColor. Wilds are always legal except Wild +4, which is only
* legal when the player has no card matching state.currentColor (other wilds
* don't block it).
*/
export function isLegalPlay(state, card, playerHand) {
if (card.color === 'w') {
if (card.kind === 'wild4') {
// Only legal when the player has no card matching the active color
// (wilds in hand don't count as a matching color).
return !playerHand.some((c) => c.color === state.currentColor);
}
return true;
}
if (card.color === state.currentColor) return true;
const top = topDiscard(state);
// Match by kind when colors differ. For number cards: same value.
if (card.kind === 'number' && top.kind === 'number' && card.value === top.value) return true;
if (card.kind !== 'number' && card.kind === top.kind) return true;
return false;
}
/** Returns the list of card ids the seat could legally play right now. */
export function legalPlays(state, seat) {
if (state.phase !== 'play') return [];
if (state.currentPlayer !== seat) return [];
const hand = state.players[seat].hand;
return hand.filter((c) => isLegalPlay(state, c, hand)).map((c) => c.id);
}
/** True if the seat could legally play any card (used before forcing a draw). */
export function hasAnyLegalPlay(state, seat) {
return legalPlays(state, seat).length > 0;
}
// ── Initial state ───────────────────────────────────────────────────────────
export function createInitialState({ playerCount = 4, seed } = {}) {
if (playerCount < 2 || playerCount > 4) {
throw new Error(`Uno 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: deck.splice(0, HAND_DEAL) });
}
// Flip top discard, re-flipping if it's a Wild +4 (Mattel rule).
let top = deck.pop();
while (top.kind === 'wild4') {
deck.unshift(top);
shuffle(deck);
top = deck.pop();
}
const state = {
players,
drawPile: deck,
discardPile: [top],
currentPlayer: 0,
direction: 1,
currentColor: top.color === 'w' ? null : top.color,
phase: 'play',
pendingWild4: null,
pendingDrawn: null,
lastAction: null,
log: [{ kind: 'start', topCard: cloneCard(top) }],
winnerSeat: null,
seed: seed ?? null,
turnCount: 0,
};
// Apply starting-card effects (the first player has not yet acted).
if (top.kind === 'wild') {
// First player must choose a color before play begins.
state.phase = 'choosingColor';
state.lastAction = { kind: 'startingWild' };
} else if (top.kind === 'skip') {
state.log.push({ kind: 'startSkip', seat: state.currentPlayer });
advanceTurn(state, 1);
} else if (top.kind === 'reverse') {
state.direction = -1;
state.log.push({ kind: 'startReverse' });
if (playerCount === 2) {
// 2-player reverse acts as a skip — first player loses their turn.
advanceTurn(state, 1);
} else {
// Direction reversed; the "first" player becomes the one before seat 0.
state.currentPlayer = nextSeat(state, 0, 1);
}
} else if (top.kind === 'draw2') {
const drawn = drawN(state, state.currentPlayer, 2);
state.log.push({ kind: 'startDraw2', seat: state.currentPlayer, count: drawn.length });
advanceTurn(state, 1);
}
return state;
}
// ── Actions ─────────────────────────────────────────────────────────────────
/**
* Play `cardId` from `seat`'s hand. For wilds, the engine transitions to
* 'choosingColor' phase — call applyChooseColor next. Returns the same state
* (unchanged) if the action is illegal.
*/
export function applyPlay(state, seat, cardId) {
if (state.phase !== 'play') return state;
if (state.currentPlayer !== seat) return state;
const player = state.players[seat];
const idx = player.hand.findIndex((c) => c.id === cardId);
if (idx === -1) return state;
const card = player.hand[idx];
if (!isLegalPlay(state, card, player.hand)) return state;
const next = cloneState(state);
const me = next.players[seat];
// Snapshot whether the player had a color match BEFORE removing the card,
// for Wild +4 challenge resolution.
const hadColorMatch = me.hand.some(
(c) => c.id !== cardId && c.color === next.currentColor,
);
const played = me.hand.splice(idx, 1)[0];
played.chosenColor = null;
next.discardPile.push(played);
next.lastAction = { kind: 'play', seat, card: cloneCard(played) };
next.log.push({ kind: 'play', seat, cardId: played.id });
// Win check — playing your last card wins immediately.
if (me.hand.length === 0) {
next.winnerSeat = seat;
next.phase = 'gameOver';
next.log.push({ kind: 'win', seat });
return next;
}
// Resolve effects.
if (played.kind === 'wild') {
next.phase = 'choosingColor';
return next;
}
if (played.kind === 'wild4') {
next.phase = 'choosingColor';
next.pendingWild4 = {
playerSeat: seat,
challengerSeat: nextSeat(next, seat, 1),
hadColorMatch,
};
return next;
}
if (played.kind === 'skip') {
next.currentColor = played.color;
const skipped = nextSeat(next, seat, 1);
next.log.push({ kind: 'skip', seat: skipped });
advanceTurn(next, 2);
return next;
}
if (played.kind === 'reverse') {
next.currentColor = played.color;
next.direction = -next.direction;
next.log.push({ kind: 'reverse' });
if (next.players.length === 2) {
// 2-player Reverse acts as Skip — same player goes again. With only two
// seats, flipping direction alone still hands the turn to the opponent,
// so we advance by 2 to wrap back to the same seat.
advanceTurn(next, 2);
} else {
advanceTurn(next, 1);
}
return next;
}
if (played.kind === 'draw2') {
next.currentColor = played.color;
const target = nextSeat(next, seat, 1);
const drawn = drawN(next, target, 2);
next.log.push({ kind: 'draw2', seat: target, count: drawn.length });
advanceTurn(next, 2);
return next;
}
// Number card.
next.currentColor = played.color;
advanceTurn(next, 1);
return next;
}
/**
* Choose the color after playing a Wild or Wild +4 (or after the starting Wild
* top-discard). Returns the same state if not currently in 'choosingColor'.
*/
export function applyChooseColor(state, color) {
if (state.phase !== 'choosingColor') return state;
if (!UNO_COLORS.includes(color)) return state;
const next = cloneState(state);
next.currentColor = color;
const top = topDiscard(next);
if (top.color === 'w') top.chosenColor = color;
next.log.push({ kind: 'chooseColor', color });
// Resolve the pending follow-up.
if (next.pendingWild4) {
next.phase = 'challengeWindow';
next.lastAction = { kind: 'wild4Pending', color };
return next;
}
// After a regular Wild: advance turn.
// After a starting Wild (top discard was wild at game start): the same seat 0
// gets the first play — do NOT advance.
const wasStartingWild = next.lastAction?.kind === 'startingWild';
next.phase = 'play';
if (!wasStartingWild) advanceTurn(next, 1);
next.lastAction = { kind: 'chooseColor', color };
return next;
}
/**
* The challenger either accepts the Wild +4 or challenges it. Resolves the
* draw and turn order accordingly.
*/
export function applyChallengeWild4(state, doChallenge) {
if (state.phase !== 'challengeWindow') return state;
const next = cloneState(state);
const pw = next.pendingWild4;
if (!pw) return state;
if (!doChallenge) {
// Accept: challenger draws 4 and is skipped.
const drawn = drawN(next, pw.challengerSeat, 4);
next.log.push({ kind: 'wild4Accept', seat: pw.challengerSeat, count: drawn.length });
next.lastAction = {
kind: 'wild4Resolve',
result: 'accept',
drawSeat: pw.challengerSeat,
drawCount: drawn.length,
};
// Move past the player who played the +4 and past the now-skipped challenger.
next.currentPlayer = pw.playerSeat;
advanceTurn(next, 2);
next.pendingWild4 = null;
next.phase = 'play';
return next;
}
if (pw.hadColorMatch) {
// Successful challenge: WD4 player draws 4, play continues normally.
const drawn = drawN(next, pw.playerSeat, 4);
next.log.push({ kind: 'wild4ChallengeWin', seat: pw.playerSeat, count: drawn.length });
next.lastAction = {
kind: 'wild4Resolve',
result: 'challengeWin',
drawSeat: pw.playerSeat,
drawCount: drawn.length,
};
next.currentPlayer = pw.challengerSeat;
next.turnCount += 1;
} else {
// Failed challenge: challenger draws 6 and is skipped.
const drawn = drawN(next, pw.challengerSeat, 6);
next.log.push({ kind: 'wild4ChallengeLose', seat: pw.challengerSeat, count: drawn.length });
next.lastAction = {
kind: 'wild4Resolve',
result: 'challengeLose',
drawSeat: pw.challengerSeat,
drawCount: drawn.length,
};
next.currentPlayer = pw.playerSeat;
advanceTurn(next, 2);
}
next.pendingWild4 = null;
next.phase = 'play';
return next;
}
/**
* Draw a single card from the draw pile. If the drawn card is playable the
* state transitions to 'mustPlayDrawn' and the UI prompts the player to keep
* or play it. Otherwise the turn advances automatically.
*/
export function applyDraw(state, seat) {
if (state.phase !== 'play') return state;
if (state.currentPlayer !== seat) return state;
const next = cloneState(state);
const drawn = drawN(next, seat, 1);
if (drawn.length === 0) {
// Both piles dry — pathological. Just advance the turn.
advanceTurn(next, 1);
return next;
}
const card = drawn[0];
next.lastAction = { kind: 'draw', seat, card: cloneCard(card) };
next.log.push({ kind: 'draw', seat, count: 1 });
if (isLegalPlay(next, card, next.players[seat].hand)) {
next.phase = 'mustPlayDrawn';
next.pendingDrawn = cloneCard(card);
return next;
}
advanceTurn(next, 1);
return next;
}
/**
* After drawing, the player elects to play the drawn card. Internally this is
* just applyPlay (with the same card id), but it transitions from
* 'mustPlayDrawn' back to 'play' first.
*/
export function applyPlayDrawn(state, seat) {
if (state.phase !== 'mustPlayDrawn') return state;
if (state.currentPlayer !== seat) return state;
if (!state.pendingDrawn) return state;
const cardId = state.pendingDrawn.id;
const intermediate = cloneState(state);
intermediate.phase = 'play';
intermediate.pendingDrawn = null;
return applyPlay(intermediate, seat, cardId);
}
/** After drawing a playable card, elect to keep it and pass. */
export function applyPassAfterDraw(state, seat) {
if (state.phase !== 'mustPlayDrawn') return state;
if (state.currentPlayer !== seat) return state;
const next = cloneState(state);
next.phase = 'play';
next.pendingDrawn = null;
advanceTurn(next, 1);
return next;
}
export function isGameOver(state) {
return state.phase === 'gameOver';
}