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