// Go Fish — pure state engine. No Phaser imports. // // Pairs variant rules: // - Standard 52-card deck. 4 players, 7 cards each. Remainder is the pool. // - On your turn, pick an opponent and ask for a rank you hold. // - If they hold any of that rank, they hand over ALL cards of that rank; // you then check your hand for pairs (any 2 cards of the same rank are // immediately scored as a pair). You then ask again. // - If they don't, "Go Fish": draw 1 from the pool. If the drawn card matches // the asked rank ("lucky fish"), keep going. Otherwise the turn passes. // - If your hand goes empty mid-game and the pool has cards, you draw up to // 5 to refill. If the pool is empty you sit out for the rest of the game. // - Game ends when the pool is empty AND every player has ≤ 1 card (no more // pairs possible). Most pairs wins; ties allowed. import { SUITS, RANKS, Card } from '../cards/Deck.js'; export const HAND_DEAL = 7; export const REFILL_TARGET = 5; // Mulberry32 — seedable PRNG (mirrors Phase10). 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]]; } } 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); } } return cards; } function cloneCard(c) { 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), pairs: p.pairs, pairedRanks: p.pairedRanks.slice(), sittingOut: p.sittingOut, })), pool: state.pool.map(cloneCard), currentPlayer: state.currentPlayer, phase: state.phase, lastAsk: state.lastAsk ? { ...state.lastAsk } : null, log: state.log.map((e) => ({ ...e })), winnerSeats: state.winnerSeats.slice(), seed: state.seed, turnCount: state.turnCount, }; } export function createInitialState({ playerCount = 4, seed } = {}) { if (playerCount < 2 || playerCount > 4) { throw new Error(`Go Fish 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), pairs: 0, pairedRanks: [], sittingOut: false, }); } const state = { players, pool: deck, currentPlayer: 0, phase: 'play', lastAsk: null, log: [], winnerSeats: [], seed: seed ?? null, turnCount: 0, }; // Any starting pairs are scored immediately. for (const p of state.players) collectPairs(p, state); // Edge: if a player was dealt all duplicates and now has 0 cards, refill. for (const p of state.players) ensureHasCards(state, p.seat); checkGameOver(state); return state; } export function legalRanksToAsk(state, seat) { const set = new Set(); for (const c of state.players[seat].hand) set.add(c.rank); return [...set]; } export function canAsk(state, seat) { if (state.phase !== 'play') return false; if (state.currentPlayer !== seat) return false; if (state.players[seat].sittingOut) return false; return state.players[seat].hand.length > 0; } /** * Ask `targetSeat` for `rank`. The asker (currentPlayer) must hold at least * one card of that rank. Returns a new state plus a description of what * happened so the UI can animate it. */ export function applyAsk(state, targetSeat, rank) { if (state.phase !== 'play') return state; const askerSeat = state.currentPlayer; if (targetSeat === askerSeat) return state; const next = cloneState(state); const asker = next.players[askerSeat]; const target = next.players[targetSeat]; if (!asker || !target) return state; if (target.sittingOut) return state; if (!asker.hand.some((c) => c.rank === rank)) return state; // Transfer all of `rank` from target to asker. const matches = target.hand.filter((c) => c.rank === rank); if (matches.length > 0) { target.hand = target.hand.filter((c) => c.rank !== rank); asker.hand.push(...matches); const { count: newPairs, pairedCards } = collectPairs(asker, next); next.lastAsk = { askerSeat, targetSeat, rank, result: 'catch', cardsTransferred: matches.map(cloneCard), drawnCard: null, newPairs, pairedCards, }; next.log.push({ kind: 'ask', askerSeat, targetSeat, rank, result: 'catch', count: matches.length }); // Asker may have emptied target's hand — refill them now (before asker's next ask). ensureHasCards(next, targetSeat); // Asker may have emptied own hand by pairing — refill them too. ensureHasCards(next, askerSeat); // If asker still has no cards or can't ask, advance turn. if (!canAsk(next, askerSeat)) advanceTurn(next); checkGameOver(next); return next; } // Miss — Go Fish. next.log.push({ kind: 'ask', askerSeat, targetSeat, rank, result: 'fish', count: 0 }); if (next.pool.length > 0) { // Pause — player must interactively pick a card from the scattered pool. next.phase = 'pick'; next.lastAsk = { askerSeat, targetSeat, rank, result: 'fish', cardsTransferred: [], drawnCard: null, newPairs: 0, pairedCards: [], }; return next; } // Pool is empty — no card to draw, advance turn immediately. next.lastAsk = { askerSeat, targetSeat, rank, result: 'fish', cardsTransferred: [], drawnCard: null, newPairs: 0, pairedCards: [], }; ensureHasCards(next, askerSeat); advanceTurn(next); checkGameOver(next); return next; } /** * Complete the interactive "Go Fish" draw. `cardId` is the id of the card the * player (or AI) chose from the scattered pool. Only valid when phase === 'pick'. */ export function applyFishPick(state, cardId) { if (state.phase !== 'pick') return state; const { askerSeat, targetSeat, rank } = state.lastAsk; const next = cloneState(state); const poolIdx = next.pool.findIndex((c) => c.id === cardId); if (poolIdx === -1) return state; const [drawnCard] = next.pool.splice(poolIdx, 1); const asker = next.players[askerSeat]; asker.hand.push(drawnCard); const lucky = drawnCard.rank === rank; const { count: newPairs, pairedCards } = collectPairs(asker, next); next.lastAsk = { askerSeat, targetSeat, rank, result: lucky ? 'lucky' : 'fish', cardsTransferred: [], drawnCard: cloneCard(drawnCard), newPairs, pairedCards, }; if (lucky) next.log.push({ kind: 'lucky', askerSeat, rank }); next.phase = 'play'; ensureHasCards(next, askerSeat); if (!lucky || !canAsk(next, askerSeat)) advanceTurn(next); checkGameOver(next); return next; } /** * Repeatedly remove any 2 cards of the same rank from the player's hand and * increment their pair count. Returns { count, pairedCards } where pairedCards * holds clones of each removed card for animation purposes. */ function collectPairs(player, state) { let collected = 0; const pairedCards = []; while (true) { const byRank = new Map(); let foundRank = null; for (const c of player.hand) { 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 pair = byRank.get(foundRank); pairedCards.push(cloneCard(pair[0]), cloneCard(pair[1])); const idsToRemove = new Set([pair[0].id, pair[1].id]); player.hand = player.hand.filter((c) => !idsToRemove.has(c.id)); player.pairs += 1; player.pairedRanks.push(foundRank); collected += 1; if (state) state.log.push({ kind: 'pair', seat: player.seat, rank: foundRank }); } return { count: collected, pairedCards }; } /** * If the player's hand is empty, draw up to REFILL_TARGET from the pool. * If still empty afterward, mark them as sitting out. */ function ensureHasCards(state, seat) { const player = state.players[seat]; if (player.sittingOut) return; if (player.hand.length > 0) return; while (player.hand.length < REFILL_TARGET && state.pool.length > 0) { player.hand.push(state.pool.pop()); } if (player.hand.length > 0) { state.log.push({ kind: 'refill', seat, count: player.hand.length }); collectPairs(player, state); } if (player.hand.length === 0) { player.sittingOut = true; state.log.push({ kind: 'sitout', seat }); } } function advanceTurn(state) { state.turnCount += 1; const N = state.players.length; let next = (state.currentPlayer + 1) % N; let safety = N + 1; while (safety-- > 0) { const p = state.players[next]; if (!p.sittingOut && p.hand.length > 0) { state.currentPlayer = next; return; } next = (next + 1) % N; } // No-one can play — game over will be detected separately. } function checkGameOver(state) { if (state.phase !== 'play') return; // A player can make progress if (a) they have ≥1 card AND the pool has ≥1 // card (they can fish) OR (b) the pool is empty but they share a rank with // another player they could ask for. Otherwise no progress is possible. const anyoneCanAct = state.players.some((p) => { if (p.sittingOut) return false; if (p.hand.length === 0) return false; if (state.pool.length > 0) return true; return state.players.some( (q) => q !== p && !q.sittingOut && q.hand.some((c) => p.hand.some((d) => d.rank === c.rank)), ); }); if (!anyoneCanAct) { state.phase = 'gameOver'; const max = Math.max(...state.players.map((p) => p.pairs)); state.winnerSeats = state.players.filter((p) => p.pairs === max).map((p) => p.seat); } } export function isGameOver(state) { return state.phase === 'gameOver'; }