fertig-classic-games/public/src/games/gofish/GoFishLogic.js

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