feat: add Uno card game support
- Import and register UnoGame in the frontend client - Add Uno to the game slug dispatch mapping in GameRoomScene - Register Uno in the server game registry with card game configuration
This commit is contained in:
parent
cc75ff1862
commit
3d5c49d325
|
|
@ -0,0 +1,133 @@
|
|||
// Uno AI — pure decision function. Mirrors the gofish/GoFishAI.js shape:
|
||||
// one stateless function per decision, no scene state.
|
||||
//
|
||||
// Returns one of:
|
||||
// { kind: 'play', cardId, chosenColor? }
|
||||
// { kind: 'draw' }
|
||||
// { kind: 'playDrawn' } — after a draw, when the drawn card is playable
|
||||
// { kind: 'passDrawn' } — keep the drawn card, end turn
|
||||
// { kind: 'chooseColor', color } — phase === 'choosingColor'
|
||||
// { kind: 'challenge', doChallenge: bool } — phase === 'challengeWindow'
|
||||
|
||||
import { UNO_COLORS } from './UnoDeck.js';
|
||||
import { legalPlays, isLegalPlay } from './UnoLogic.js';
|
||||
|
||||
const KIND_PRIORITY_LATE = { wild4: 9, draw2: 8, skip: 7, reverse: 6, wild: 5, number: 1 };
|
||||
const KIND_PRIORITY_EARLY = { number: 5, reverse: 4, skip: 3, draw2: 2, wild: 1, wild4: 0 };
|
||||
|
||||
function colorTally(hand) {
|
||||
const tally = { r: 0, y: 0, g: 0, b: 0 };
|
||||
for (const c of hand) if (c.color !== 'w') tally[c.color] += 1;
|
||||
return tally;
|
||||
}
|
||||
|
||||
/** Pick the color the AI has the most of (ties broken by deck order). */
|
||||
function bestColor(hand) {
|
||||
const t = colorTally(hand);
|
||||
let best = UNO_COLORS[0];
|
||||
for (const c of UNO_COLORS) if (t[c] > t[best]) best = c;
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a candidate play. Higher is better. Considers:
|
||||
* - Punish dangerous opponents (next opponent with small hand → favor action cards)
|
||||
* - Save high-value cards for late game otherwise
|
||||
* - Prefer playing cards whose color matches the rest of our hand
|
||||
* - Never voluntarily play Wild +4 when a non-wild play exists
|
||||
*/
|
||||
function scoreCandidate(state, seat, card, hand) {
|
||||
const N = state.players.length;
|
||||
const nextSeat = ((seat + state.direction) % N + N) % N;
|
||||
const nextHandSize = state.players[nextSeat].hand.length;
|
||||
const dangerous = nextHandSize <= 2;
|
||||
const ourHandSize = hand.length;
|
||||
const earlyGame = ourHandSize >= 5;
|
||||
|
||||
let score;
|
||||
if (dangerous) {
|
||||
score = (KIND_PRIORITY_LATE[card.kind] ?? 0) * 10;
|
||||
} else if (earlyGame) {
|
||||
score = (KIND_PRIORITY_EARLY[card.kind] ?? 0) * 10;
|
||||
} else {
|
||||
// Mid-game: neutral preference for non-wild plays.
|
||||
score = card.kind === 'number' ? 30 : (card.kind === 'wild4' ? 5 : 25);
|
||||
}
|
||||
|
||||
// Bonus: playing this card leaves us with more of our dominant color in hand.
|
||||
if (card.color !== 'w') {
|
||||
const tally = colorTally(hand);
|
||||
score += tally[card.color]; // staying in our strongest color is good
|
||||
}
|
||||
|
||||
// Penalty: blow a Wild +4 unless it's a strong move (dangerous opponent
|
||||
// OR we're stuck with this Wild +4 long-term).
|
||||
if (card.kind === 'wild4' && !dangerous) score -= 25;
|
||||
|
||||
// Small randomization for variety.
|
||||
score += Math.random() * 0.5;
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the single best action for the current phase.
|
||||
*/
|
||||
export function chooseAction(state, seat) {
|
||||
if (state.phase === 'choosingColor') {
|
||||
return { kind: 'chooseColor', color: bestColor(state.players[seat].hand) };
|
||||
}
|
||||
|
||||
if (state.phase === 'challengeWindow') {
|
||||
// We are the challenger. Without perfect information we challenge only
|
||||
// when there's reasonable suspicion. Heuristics:
|
||||
// - The +4 player has many cards (likely had a color match).
|
||||
// - Our own hand is small (the gamble is lower-risk).
|
||||
const pw = state.pendingWild4;
|
||||
if (!pw) return { kind: 'challenge', doChallenge: false };
|
||||
const playerHandSize = state.players[pw.playerSeat].hand.length;
|
||||
const ourHandSize = state.players[seat].hand.length;
|
||||
const suspicious = playerHandSize >= 4;
|
||||
const lowRisk = ourHandSize <= 3;
|
||||
const doChallenge = suspicious && (lowRisk || Math.random() < 0.4);
|
||||
return { kind: 'challenge', doChallenge };
|
||||
}
|
||||
|
||||
if (state.phase === 'mustPlayDrawn') {
|
||||
// We just drew. Pass unless the drawn card is genuinely playable and
|
||||
// playing it is better than holding it for later.
|
||||
const me = state.players[seat];
|
||||
const drawn = state.pendingDrawn;
|
||||
if (!drawn) return { kind: 'passDrawn' };
|
||||
if (!isLegalPlay(state, drawn, me.hand)) return { kind: 'passDrawn' };
|
||||
// Always play numbers / actions when we can. For wilds, play them only if
|
||||
// we have no other play (we already drew, so we're not blowing tempo).
|
||||
return { kind: 'playDrawn' };
|
||||
}
|
||||
|
||||
if (state.phase !== 'play') return { kind: 'draw' };
|
||||
|
||||
const me = state.players[seat];
|
||||
const legalIds = legalPlays(state, seat);
|
||||
if (legalIds.length === 0) return { kind: 'draw' };
|
||||
|
||||
let bestCard = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const id of legalIds) {
|
||||
const card = me.hand.find((c) => c.id === id);
|
||||
if (!card) continue;
|
||||
const s = scoreCandidate(state, seat, card, me.hand);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestCard = card;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestCard) return { kind: 'draw' };
|
||||
const result = { kind: 'play', cardId: bestCard.id };
|
||||
if (bestCard.color === 'w') {
|
||||
// Pre-compute the color we'll choose so the scene can animate atomically.
|
||||
// The engine still calls applyChooseColor separately.
|
||||
result.chosenColor = bestColor(me.hand.filter((c) => c.id !== bestCard.id));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
// Uno deck. 108 cards:
|
||||
// - For each of 4 colors (r/y/g/b): one 0, two each of 1–9, two Skip,
|
||||
// two Reverse, two Draw 2 = 25 × 4 = 100.
|
||||
// - Plus 4 Wild and 4 Wild Draw 4 = 108.
|
||||
|
||||
export const UNO_COLORS = ['r', 'y', 'g', 'b']; // wild cards use 'w'
|
||||
export const UNO_KINDS = ['number', 'skip', 'reverse', 'draw2', 'wild', 'wild4'];
|
||||
|
||||
export class UnoCard {
|
||||
constructor({ color, kind, value = null }) {
|
||||
this.color = color; // 'r' | 'y' | 'g' | 'b' | 'w'
|
||||
this.kind = kind; // see UNO_KINDS
|
||||
this.value = value; // 0..9 for kind='number', else null
|
||||
this.chosenColor = null; // set on wilds after they are played
|
||||
this.id = null; // assigned by buildDeck()
|
||||
}
|
||||
|
||||
/** Mattel scoring (reserved for future multi-round play). */
|
||||
get points() {
|
||||
if (this.kind === 'number') return this.value;
|
||||
if (this.kind === 'wild' || this.kind === 'wild4') return 50;
|
||||
return 20;
|
||||
}
|
||||
|
||||
/** Short human label used on the card face. */
|
||||
get label() {
|
||||
switch (this.kind) {
|
||||
case 'number': return String(this.value);
|
||||
case 'skip': return '⊘';
|
||||
case 'reverse': return '⇄';
|
||||
case 'draw2': return '+2';
|
||||
case 'wild': return '★';
|
||||
case 'wild4': return '+4';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** True if this is a Wild or Wild Draw 4 (color === 'w'). */
|
||||
get isWild() {
|
||||
return this.color === 'w';
|
||||
}
|
||||
|
||||
/**
|
||||
* The color the card matches against when on top of the discard pile.
|
||||
* For wilds, this is whatever color the player chose; until chosen, null.
|
||||
*/
|
||||
get effectiveColor() {
|
||||
return this.isWild ? this.chosenColor : this.color;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDeck() {
|
||||
const cards = [];
|
||||
let id = 0;
|
||||
const push = (spec) => {
|
||||
const c = new UnoCard(spec);
|
||||
c.id = id++;
|
||||
cards.push(c);
|
||||
};
|
||||
|
||||
for (const color of UNO_COLORS) {
|
||||
push({ color, kind: 'number', value: 0 });
|
||||
for (let v = 1; v <= 9; v++) {
|
||||
push({ color, kind: 'number', value: v });
|
||||
push({ color, kind: 'number', value: v });
|
||||
}
|
||||
for (let i = 0; i < 2; i++) push({ color, kind: 'skip' });
|
||||
for (let i = 0; i < 2; i++) push({ color, kind: 'reverse' });
|
||||
for (let i = 0; i < 2; i++) push({ color, kind: 'draw2' });
|
||||
}
|
||||
for (let i = 0; i < 4; i++) push({ color: 'w', kind: 'wild' });
|
||||
for (let i = 0; i < 4; i++) push({ color: 'w', kind: 'wild4' });
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function cloneCard(c) {
|
||||
const out = new UnoCard({ color: c.color, kind: c.kind, value: c.value });
|
||||
out.id = c.id;
|
||||
out.chosenColor = c.chosenColor;
|
||||
return out;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,446 @@
|
|||
// 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';
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import SkipBoGame from './games/skipbo/SkipBoGame.js';
|
|||
import Phase10Game from './games/phase10/Phase10Game.js';
|
||||
import ChineseCheckersGame from './games/chinesecheckers/ChineseCheckersGame.js';
|
||||
import GoFishGame from './games/gofish/GoFishGame.js';
|
||||
import UnoGame from './games/uno/UnoGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -53,6 +54,7 @@ const config = {
|
|||
Phase10Game,
|
||||
ChineseCheckersGame,
|
||||
GoFishGame,
|
||||
UnoGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -34,3 +34,4 @@ registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: tru
|
|||
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
|
||||
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5, multiplayerOnly: false });
|
||||
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
|
||||
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
|
||||
|
|
|
|||
Loading…
Reference in New Issue