447 lines
15 KiB
JavaScript
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';
|
|
}
|