322 lines
10 KiB
JavaScript
322 lines
10 KiB
JavaScript
// 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';
|
|
}
|