diff --git a/src/lib/minigames/poker/actions.ts b/src/lib/minigames/poker/actions.ts new file mode 100644 index 0000000..19e44a7 --- /dev/null +++ b/src/lib/minigames/poker/actions.ts @@ -0,0 +1,146 @@ +/** + * Player action application for No-Limit Hold'em betting rounds. + * Pure: validates legality, produces the next state, and auto-advances the + * hand whenever the action completes a betting round or ends it. + */ + +import { + commitChips, + getLegalActions, + isRoundComplete, + livePlayers, + nextToAct, +} from "./betting"; +import { advanceState, awardFoldOut } from "./engine"; +import type { + PokerActionKind, + PokerSeatId, + PokerTableState, +} from "./types"; + +export type PokerActionResult = + | { readonly success: true; readonly state: PokerTableState } + | { readonly success: false; readonly error: string }; + +function failure(error: string): PokerActionResult { + return { success: false, error }; +} + +/** + * Apply a betting action for `seatId`. Bet and raise amounts are the player's + * TOTAL commitment for the current street ("raise to X"); a bet/raise equal + * to the player's max is an all-in. Calling always calls the maximum the + * stack allows. On success the state is auto-advanced (next street, showdown, + * or next hand) whenever the action completes a round or ends the hand. + */ +export function applyAction( + state: PokerTableState, + seatId: PokerSeatId, + action: PokerActionKind, + amount?: number, +): PokerActionResult { + if (state.phase !== "betting") { + return failure("The hand is not accepting actions right now."); + } + if (state.toActSeat !== seatId) { + return failure("It is not that seat's turn to act."); + } + const player = state.players[seatId]; + if (!player) return failure("No player occupies that seat."); + if (player.folded) return failure("That player has already folded."); + if (player.allIn) return failure("That player is all-in and cannot act."); + if (player.eliminated) return failure("That player is eliminated."); + + let updated = player; + let currentBet = state.currentBet; + let minRaise = state.minRaise; + let lastFullRaiseBet = state.lastFullRaiseBet; + + switch (action) { + case "fold": + updated = { ...player, folded: true, hasActed: true }; + break; + case "check": + if (state.currentBet !== player.committed) { + return failure("Cannot check: there is a bet to call."); + } + updated = { ...player, hasActed: true }; + break; + case "call": { + if (state.currentBet <= player.committed) { + return failure("Cannot call: there is no bet to call."); + } + updated = commitChips(player, Math.min(state.currentBet - player.committed, player.stack)); + break; + } + case "bet": + case "raise": { + const legal = getLegalActions(state, seatId); + if (action === "bet" && !legal.canBet) { + return failure("Cannot bet: there is already a bet this street; raise instead."); + } + if (action === "raise" && !legal.canRaise) { + return failure("Raising is not available right now."); + } + if (amount === undefined) { + return failure(`A ${action} requires an amount (total commitment for this street).`); + } + if (!Number.isInteger(amount) || amount <= player.committed) { + return failure("The amount must be an integer above your current commitment."); + } + if (amount > legal.maxBetTo) { + return failure("The amount exceeds your stack."); + } + if (action === "raise" && amount <= state.currentBet) { + return failure("A raise must exceed the current bet."); + } + const isAllIn = amount === legal.maxBetTo; + if (action === "bet") { + if (!isAllIn && amount < legal.minBetTo) { + return failure(`The minimum bet is ${legal.minBetTo}.`); + } + // Any opening bet sets the raise increment for the street. + minRaise = Math.max(amount - player.committed, state.minRaise); + lastFullRaiseBet = amount; + } else { + const increment = amount - state.currentBet; + if (!isAllIn && increment < state.minRaise) { + return failure(`The minimum raise is to ${legal.minRaiseTo}.`); + } + if (increment >= state.minRaise) { + // Full raise (including an all-in that happens to be full-sized): + // resets the minimum raise and reopens the betting. + minRaise = increment; + lastFullRaiseBet = amount; + } + // A short all-in raise leaves minRaise and lastFullRaiseBet alone so + // players who already acted cannot re-raise. + } + currentBet = amount; + updated = commitChips(player, amount - player.committed); + break; + } + default: { + const exhaustive: never = action; + return failure(`Unknown action: ${String(exhaustive)}`); + } + } + + const players = state.players.map((p, index) => (index === seatId ? updated : p)); + let next: PokerTableState = { + ...state, + players, + currentBet, + minRaise, + lastFullRaiseBet, + }; + + if (livePlayers(next).length === 1) { + return { success: true, state: awardFoldOut(next) }; + } + if (isRoundComplete(next)) { + return { success: true, state: advanceState(next) }; + } + next = { ...next, toActSeat: nextToAct(next, seatId) }; + return { success: true, state: next }; +} diff --git a/src/lib/minigames/poker/betting.ts b/src/lib/minigames/poker/betting.ts new file mode 100644 index 0000000..be76142 --- /dev/null +++ b/src/lib/minigames/poker/betting.ts @@ -0,0 +1,187 @@ +/** + * Betting legality for No-Limit Hold'em: whose turn it is, what actions are + * available, min-raise rules, and the short-all-in "does not reopen betting" + * rule. Pure functions over the table state. + */ + +import { BIG_BLIND } from "./constants"; +import type { + PokerLegalActions, + PokerPlayerState, + PokerSeatId, + PokerTableState, +} from "./types"; + +/** A player who can still take actions in the current betting round. */ +export function canAct(player: PokerPlayerState): boolean { + return !player.eliminated && !player.folded && !player.allIn; +} + +/** Players still eligible to win chips in the current hand. */ +export function livePlayers(state: PokerTableState): PokerPlayerState[] { + return state.players.filter((player) => !player.eliminated && !player.folded); +} + +/** Players who can still take actions in the current betting round. */ +export function actingPlayers(state: PokerTableState): PokerPlayerState[] { + return state.players.filter((player) => canAct(player)); +} + +/** + * First seat after `afterSeat` (wrapping) that can act, or null when nobody + * can. Skips folded, all-in, and eliminated players. + */ +export function nextActorFrom( + players: readonly PokerPlayerState[], + afterSeat: PokerSeatId, +): PokerSeatId | null { + const count = players.length; + for (let step = 1; step <= count; step += 1) { + const seat = (afterSeat + step) % count; + const player = players[seat]; + if (player && canAct(player)) return seat; + } + return null; +} + +export function nextToAct(state: PokerTableState, afterSeat: PokerSeatId): PokerSeatId | null { + return nextActorFrom(state.players, afterSeat); +} + +/** + * First to act preflop. Heads-up quirk: the button (who posts the small + * blind) acts first. Multi-way: the seat after the big blind. + */ +export function preflopFirstToAct( + players: readonly PokerPlayerState[], + buttonSeat: PokerSeatId, + bigBlindSeat: PokerSeatId, + headsUp: boolean, +): PokerSeatId | null { + if (headsUp) { + const button = players[buttonSeat]; + if (button && canAct(button)) return buttonSeat; + } + return nextActorFrom(players, bigBlindSeat); +} + +/** First to act postflop: the first live seat left of the button. */ +export function postflopFirstToAct(state: PokerTableState): PokerSeatId | null { + return nextToAct(state, state.buttonSeat); +} + +/** + * A betting round is complete when every player who can act has acted and + * matched the current bet. (All-in players are exempt: they have nothing + * left to match with.) + */ +export function isRoundComplete(state: PokerTableState): boolean { + return actingPlayers(state).every( + (player) => player.hasActed && player.committed === state.currentBet, + ); +} + +const NO_ACTIONS: Omit = { + canFold: false, + canCheck: false, + canCall: false, + callAmount: 0, + canBet: false, + minBetTo: 0, + maxBetTo: 0, + canRaise: false, + minRaiseTo: 0, + maxRaiseTo: 0, +}; + +/** + * Legal actions for `seatId` right now. All bet/raise amounts are expressed + * as the player's TOTAL commitment for the current street ("raise to X"). + */ +export function getLegalActions(state: PokerTableState, seatId: PokerSeatId): PokerLegalActions { + const player = state.players[seatId]; + if ( + !player || + state.phase !== "betting" || + state.toActSeat !== seatId || + !canAct(player) + ) { + return { seatId, ...NO_ACTIONS }; + } + + const bigBlind = state.config.bigBlind; + const { stack, committed } = player; + const maxTo = committed + stack; + const callAmount = Math.min(state.currentBet - committed, stack); + const canCheck = state.currentBet === committed; + const canCall = state.currentBet > committed; + const canBet = state.currentBet === 0 && stack > 0; + const minBetTo = canBet ? Math.min(committed + bigBlind, maxTo) : 0; + + // A short all-in raise (below min-raise) must not reopen the betting for + // players who already acted: they may only call or fold. A player may + // raise when the current bet came from a full raise, or when they have + // not acted yet this street. + const raiseUnlocked = state.currentBet === state.lastFullRaiseBet || !player.hasActed; + const canRaise = + state.currentBet > 0 && stack > 0 && maxTo > state.currentBet && raiseUnlocked; + const minRaiseTo = canRaise ? Math.min(state.currentBet + state.minRaise, maxTo) : 0; + + return { + seatId, + canFold: true, + canCheck, + canCall, + callAmount: canCall ? callAmount : 0, + canBet, + minBetTo, + maxBetTo: maxTo, + canRaise, + minRaiseTo, + maxRaiseTo: maxTo, + }; +} + +/** Total chips committed on the current street across all players. */ +export function sumCommitted(state: PokerTableState): number { + return state.players.reduce((total, player) => total + player.committed, 0); +} + +/** Move chips from a player's stack into their current-street commitment. */ +export function commitChips(player: PokerPlayerState, pay: number): PokerPlayerState { + const stack = player.stack - pay; + return { + ...player, + stack, + committed: player.committed + pay, + totalCommitted: player.totalCommitted + pay, + allIn: player.allIn || stack === 0, + hasActed: true, + }; +} + +/** + * Post a blind: commits chips but does NOT count as having acted, so the + * player keeps their preflop option to check or raise. + */ +export function postBlind(player: PokerPlayerState, blind: number): PokerPlayerState { + const pay = Math.min(blind, player.stack); + const stack = player.stack - pay; + return { + ...player, + stack, + committed: pay, + totalCommitted: pay, + allIn: stack === 0, + hasActed: false, + }; +} + +/** Clear street-level betting state after a round completes. */ +export function collectBets(players: readonly PokerPlayerState[]): PokerPlayerState[] { + return players.map((player) => ({ + ...player, + committed: 0, + hasActed: false, + })); +} diff --git a/src/lib/minigames/poker/constants.ts b/src/lib/minigames/poker/constants.ts new file mode 100644 index 0000000..a8bf493 --- /dev/null +++ b/src/lib/minigames/poker/constants.ts @@ -0,0 +1,24 @@ +/** + * Tunable constants for the No-Limit Texas Hold'em poker engine. + * Pure data, no imports: safe for client and server. + */ + +/** Play chips each player starts a game with. */ +export const STARTING_STACK = 1000; + +export const SMALL_BLIND = 10; + +export const BIG_BLIND = 20; + +/** + * How long a player has to act, in milliseconds. The engine itself never + * enforces this (it is pure and synchronous); the future table-registry + * wrapper reads it to run turn timers and auto-fold stale players. + */ +export const TURN_TIMEOUT_MS = 30_000; + +/** Supported player count: heads-up through 4-handed. */ +export const MIN_PLAYERS = 2; +export const MAX_PLAYERS = 4; + +export const MAX_TABLE_NAME_LENGTH = 48; diff --git a/src/lib/minigames/poker/deck.ts b/src/lib/minigames/poker/deck.ts new file mode 100644 index 0000000..a92514c --- /dev/null +++ b/src/lib/minigames/poker/deck.ts @@ -0,0 +1,60 @@ +/** + * Deck creation and deterministic shuffling for the poker engine. + * Pure: randomness is always injected; no module-level state. + */ + +import type { Card, Rank, Suit } from "./types"; + +const SUITS: readonly Suit[] = ["s", "h", "d", "c"]; +const MIN_RANK: Rank = 2; +const MAX_RANK: Rank = 14; + +/** A fresh, ordered 52-card deck (order is deterministic before shuffling). */ +export function createDeck(): Card[] { + const deck: Card[] = []; + for (const suit of SUITS) { + for (let rank = MIN_RANK; rank <= MAX_RANK; rank += 1) { + deck.push({ rank: rank as Rank, suit }); + } + } + return deck; +} + +/** + * Small, fast seeded PRNG (mulberry32). Used so a whole multi-hand game can + * be replayed from a single integer seed stored in the table state. + */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0; + 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; + }; +} + +/** Fisher-Yates shuffle; returns a new array, never mutates the input. */ +export function shuffleDeck(cards: readonly Card[], rng: () => number): Card[] { + const deck = [...cards]; + for (let i = deck.length - 1; i > 0; i -= 1) { + const j = Math.floor(rng() * (i + 1)); + const tmp = deck[i]; + deck[i] = deck[j]; + deck[j] = tmp; + } + return deck; +} + +const GOLDEN_RATIO_32 = 0x9e3779b9; + +/** Per-hand seed derived from the table's base seed and the hand number. */ +export function handSeed(baseSeed: number, handNumber: number): number { + return (baseSeed + Math.imul(handNumber, GOLDEN_RATIO_32)) >>> 0; +} + +/** The exact shuffled deck used for a given hand of a given game seed. */ +export function createShuffledDeck(baseSeed: number, handNumber: number): Card[] { + return shuffleDeck(createDeck(), mulberry32(handSeed(baseSeed, handNumber))); +} diff --git a/src/lib/minigames/poker/engine.ts b/src/lib/minigames/poker/engine.ts new file mode 100644 index 0000000..c5c9e76 --- /dev/null +++ b/src/lib/minigames/poker/engine.ts @@ -0,0 +1,235 @@ +/** + * Core table lifecycle: table creation and state progression across streets + * and hands. Action application lives in actions.ts; hand setup in + * handSetup.ts. + * + * Pure and server-authoritative: every function takes a state and returns a + * new state (never mutates), all randomness flows from the table seed, and + * nothing here imports Payload, Next.js, or process. A later wrapper will + * hold states in a registry, run turn timers, and expose views. + */ + +import { + collectBets, + isRoundComplete, + livePlayers, + postflopFirstToAct, + sumCommitted, +} from "./betting"; +import { BIG_BLIND, MAX_PLAYERS, MIN_PLAYERS, SMALL_BLIND, STARTING_STACK, TURN_TIMEOUT_MS } from "./constants"; +import { startHand } from "./handSetup"; +import { resolveShowdown } from "./showdown"; +import type { + PokerPlayerShowdownResult, + PokerPlayerState, + PokerSeatId, + PokerStreet, + PokerTableConfig, + PokerTableState, + PokerGamePlacement, + ShowdownOutcome, +} from "./types"; + +export interface PokerPlayerInit { + readonly name: string; + /** Overrides the table's starting stack for this seat (for tests/scenarios). */ + readonly stack?: number; +} + +export interface CreateTableOptions { + readonly startingStack?: number; + readonly smallBlind?: number; + readonly bigBlind?: number; +} + +/** Award the whole pot to the last live player without a showdown. */ +export function awardFoldOut(state: PokerTableState): PokerTableState { + const live = livePlayers(state); + const winner = live[0]; + if (!winner) return state; + const potAmount = state.collectedPot + sumCommitted(state); + const collected = collectBets(state.players); + const players = collected.map((player) => + player.seatId === winner.seatId ? { ...player, stack: player.stack + potAmount } : player, + ); + const results: PokerPlayerShowdownResult[] = state.players + .filter((player) => !player.eliminated) + .map((player) => { + const isWinner = player.seatId === winner.seatId; + const chipsWon = isWinner ? potAmount : 0; + return { + seatId: player.seatId, + handRank: null, + bestHand: null, + chipsWon, + netChange: chipsWon - player.totalCommitted, + placement: isWinner ? 1 : 2, + }; + }); + const showdown: ShowdownOutcome = { + pots: [ + { + potIndex: 0, + amount: potAmount, + winnerSeats: [winner.seatId], + eligibleSeats: [winner.seatId], + }, + ], + results, + }; + return { + ...state, + players, + collectedPot: 0, + phase: "hand-complete", + toActSeat: null, + showdown, + }; +} + +/** Resolve a showdown and credit each winner's chipsWon to their stack. */ +function resolveAndPay(state: PokerTableState): PokerTableState { + const outcome = resolveShowdown(state); + const winnings = new Map(); + for (const result of outcome.results) winnings.set(result.seatId, result.chipsWon); + const players = state.players.map((player) => ({ + ...player, + stack: player.stack + (winnings.get(player.seatId) ?? 0), + })); + return { + ...state, + players, + collectedPot: 0, + phase: "showdown", + toActSeat: null, + showdown: outcome, + }; +} + +/** Deal the remaining community cards and resolve the showdown. */ +function runOutAndShowdown(state: PokerTableState): PokerTableState { + let community = [...state.communityCards]; + let deck = [...state.deck]; + while (community.length < 5) { + const toDeal = community.length === 0 ? 3 : 1; + community = [...community, ...deck.slice(0, toDeal)]; + deck = deck.slice(toDeal); + } + const withBoard: PokerTableState = { + ...state, + communityCards: community, + deck, + street: "river", + toActSeat: null, + }; + return resolveAndPay(withBoard); +} + +const STREET_ORDER: readonly PokerStreet[] = ["preflop", "flop", "turn", "river"]; + +/** + * Progress the state whenever it can progress on its own: + * - mid-betting-round: no-op until the round completes; + * - round complete: collect bets, deal the next street (or run the board out + * when betting is impossible), or resolve the showdown on the river; + * - hand over: start the next hand, or finish the game when one player holds + * every chip. + * Idempotent: calling it on a resting state returns that state unchanged. + */ +export function advanceState(state: PokerTableState): PokerTableState { + if (state.phase === "game-complete") return state; + if (state.phase === "showdown" || state.phase === "hand-complete") { + return startHand(state); + } + if (!isRoundComplete(state)) return state; + + const collected = state.collectedPot + sumCommitted(state); + const next: PokerTableState = { + ...state, + players: collectBets(state.players), + collectedPot: collected, + currentBet: 0, + minRaise: state.config.bigBlind, + lastFullRaiseBet: 0, + toActSeat: null, + }; + + const live = livePlayers(next); + if (live.length <= 1) return awardFoldOut(next); + + const actors = live.filter((player) => !player.allIn); + if (actors.length <= 1) return runOutAndShowdown(next); + + if (next.street === "river") { + return resolveAndPay(next); + } + + const streetIndex = STREET_ORDER.indexOf(next.street); + const street = STREET_ORDER[streetIndex + 1] ?? "river"; + const cardsToDeal = street === "flop" ? 3 : 1; + return { + ...next, + street, + communityCards: [...next.communityCards, ...next.deck.slice(0, cardsToDeal)], + deck: next.deck.slice(cardsToDeal), + toActSeat: postflopFirstToAct(next), + }; +} + +export function createTableState( + players: readonly PokerPlayerInit[], + options: CreateTableOptions = {}, + rng: () => number = Math.random, +): PokerTableState { + if (players.length < MIN_PLAYERS || players.length > MAX_PLAYERS) { + throw new Error(`Poker tables support ${MIN_PLAYERS} to ${MAX_PLAYERS} players.`); + } + const startingStack = options.startingStack ?? STARTING_STACK; + const smallBlind = options.smallBlind ?? SMALL_BLIND; + const bigBlind = options.bigBlind ?? BIG_BLIND; + if (smallBlind <= 0 || bigBlind < smallBlind) { + throw new Error("Blinds must be positive and the big blind must be at least the small blind."); + } + const config: PokerTableConfig = { + startingStack, + smallBlind, + bigBlind, + turnTimeoutMs: TURN_TIMEOUT_MS, + }; + const seed = Math.floor(rng() * 0x100000000) >>> 0; + const seated: PokerPlayerState[] = players.map((player, index) => ({ + seatId: index, + name: player.name, + stack: player.stack ?? startingStack, + committed: 0, + totalCommitted: 0, + holeCards: [], + folded: true, + allIn: false, + hasActed: false, + eliminated: false, + })); + const initial: PokerTableState = { + config, + seed, + handNumber: 0, + players: seated, + buttonSeat: seated.length - 1, + smallBlindSeat: 0, + bigBlindSeat: 1, + street: "preflop", + phase: "hand-complete", + collectedPot: 0, + communityCards: [], + deck: [], + currentBet: 0, + minRaise: bigBlind, + lastFullRaiseBet: 0, + toActSeat: null, + showdown: null, + finalPlacements: null, + }; + // Hand 1 starts immediately: the "hand-complete" resting phase makes + // advanceState deal it, which also moves the button to the first seat. + return advanceState(initial); +} diff --git a/src/lib/minigames/poker/evaluator.ts b/src/lib/minigames/poker/evaluator.ts new file mode 100644 index 0000000..bf94367 --- /dev/null +++ b/src/lib/minigames/poker/evaluator.ts @@ -0,0 +1,133 @@ +/** + * 5-to-7 card hand evaluation for No-Limit Texas Hold'em. + * + * Pure and suit-independent: two hands with identical ranks tie regardless of + * suits (flushes included, since a flush's tiebreakers are ranks only). + */ + +import type { Card, HandRank } from "./types"; + +export const HAND_CATEGORIES = { + HIGH_CARD: 0, + PAIR: 1, + TWO_PAIR: 2, + TRIPS: 3, + STRAIGHT: 4, + FLUSH: 5, + FULL_HOUSE: 6, + QUADS: 7, + STRAIGHT_FLUSH: 8, +} as const; + +export const HAND_CATEGORY_NAMES: readonly string[] = [ + "high card", + "pair", + "two pair", + "three of a kind", + "straight", + "flush", + "full house", + "four of a kind", + "straight flush", +]; + +export interface PokerHandEvaluation { + readonly rank: HandRank; + /** The exact 5 cards that produced the rank (best 5 of the input). */ + readonly best: readonly Card[]; +} + +/** Positive when `a` beats `b`, negative when `b` beats `a`, 0 on a tie. */ +export function compareHandRanks(a: HandRank, b: HandRank): number { + if (a.category !== b.category) return a.category - b.category; + const length = Math.max(a.tiebreakers.length, b.tiebreakers.length); + for (let i = 0; i < length; i += 1) { + const av = a.tiebreakers[i] ?? 0; + const bv = b.tiebreakers[i] ?? 0; + if (av !== bv) return av - bv; + } + return 0; +} + +/** + * Score exactly 5 cards into a HandRank. Internal helper; assumes the input + * length (the public entry point validates it). + */ +function score5(cards: readonly Card[]): HandRank { + const ranks = cards.map((card) => card.rank).sort((x, y) => y - x); + const isFlush = cards.every((card) => card.suit === cards[0].suit); + + const counts = new Map(); + for (const rank of ranks) counts.set(rank, (counts.get(rank) ?? 0) + 1); + // Rank groups sorted by count descending, then rank descending, so + // groups[0] is always the most valuable matched set. + const groups = Array.from(counts.entries()).sort((x, y) => y[1] - x[1] || y[0] - x[0]); + const distinct = Array.from(counts.keys()).sort((x, y) => y - x); + + let straightHigh: number | null = null; + if (distinct.length === 5) { + const top = distinct[0]; + const second = distinct[1]; + const bottom = distinct[4]; + if (top !== undefined && second !== undefined && bottom !== undefined) { + if (top - bottom === 4) { + straightHigh = top; + } else if (top === 14 && second === 5) { + // Wheel: A-2-3-4-5 plays as a five-high straight. + straightHigh = 5; + } + } + } + + const first = groups[0]; + if (!first) throw new Error("score5 requires exactly five cards."); + const second = groups[1]; + const firstRank = first[0]; + const firstCount = first[1]; + const secondRank = second?.[0] ?? 0; + const secondCount = second?.[1] ?? 0; + const groupRanksFrom = (skip: number): number[] => groups.slice(skip).map((group) => group[0]); + + if (straightHigh !== null && isFlush) return { category: 8, tiebreakers: [straightHigh] }; + if (firstCount === 4) return { category: 7, tiebreakers: [firstRank, secondRank] }; + if (firstCount === 3 && secondCount === 2) { + return { category: 6, tiebreakers: [firstRank, secondRank] }; + } + if (isFlush) return { category: 5, tiebreakers: ranks }; + if (straightHigh !== null) return { category: 4, tiebreakers: [straightHigh] }; + if (firstCount === 3) return { category: 3, tiebreakers: [firstRank, ...groupRanksFrom(1)] }; + if (firstCount === 2 && secondCount === 2) { + return { category: 2, tiebreakers: [firstRank, secondRank, ...groupRanksFrom(2)] }; + } + if (firstCount === 2) return { category: 1, tiebreakers: [firstRank, ...groupRanksFrom(1)] }; + return { category: 0, tiebreakers: ranks }; +} + +/** + * Evaluate the best 5-card hand out of 5, 6, or 7 cards by scoring every + * 5-card combination (at most C(7,5) = 21) and keeping the maximum. + */ +export function evaluateHand(cards: readonly Card[]): PokerHandEvaluation { + if (cards.length < 5 || cards.length > 7) { + throw new Error(`evaluateHand expects 5 to 7 cards, received ${cards.length}.`); + } + let best: PokerHandEvaluation | null = null; + const n = cards.length; + for (let a = 0; a < n - 4; a += 1) { + for (let b = a + 1; b < n - 3; b += 1) { + for (let c = b + 1; c < n - 2; c += 1) { + for (let d = c + 1; d < n - 1; d += 1) { + for (let e = d + 1; e < n; e += 1) { + const hand = [cards[a], cards[b], cards[c], cards[d], cards[e]]; + const rank = score5(hand); + if (!best || compareHandRanks(rank, best.rank) > 0) { + best = { rank, best: hand }; + } + } + } + } + } + } + if (!best) throw new Error("evaluateHand failed to score any 5-card combination."); + return best; +} diff --git a/src/lib/minigames/poker/handHint.ts b/src/lib/minigames/poker/handHint.ts new file mode 100644 index 0000000..cf55198 --- /dev/null +++ b/src/lib/minigames/poker/handHint.ts @@ -0,0 +1,97 @@ +import { HAND_CATEGORIES, HAND_CATEGORY_NAMES, evaluateHand } from "./evaluator"; +import type { Card, Rank } from "./types"; + +export interface HandStrengthHint { + readonly label: string; + readonly bestCardKeys: string[]; +} + +const RANK_NAMES: Record = { + 2: "Deuce", + 3: "Three", + 4: "Four", + 5: "Five", + 6: "Six", + 7: "Seven", + 8: "Eight", + 9: "Nine", + 10: "Ten", + 11: "Jack", + 12: "Queen", + 13: "King", + 14: "Ace", +}; + +function cardKey(card: Card): string { + return `${card.rank}-${card.suit}`; +} + +function rankName(rank: number): string { + return RANK_NAMES[rank as Rank] ?? "Unknown"; +} + +function pluralRankName(rank: number): string { + const name = rankName(rank); + return name === "Six" ? "Sixes" : `${name}s`; +} + +function highCardLabel(rank: number): string { + return `${rankName(rank)} high`; +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function postflopLabel(category: number, tiebreakers: readonly number[]): string { + const first = tiebreakers[0] ?? 0; + switch (category) { + case HAND_CATEGORIES.HIGH_CARD: + return `High card, ${highCardLabel(first)}`; + case HAND_CATEGORIES.PAIR: + return `Pair of ${pluralRankName(first)}`; + case HAND_CATEGORIES.TWO_PAIR: + return `Two pair, ${pluralRankName(first)} and ${pluralRankName(tiebreakers[1] ?? 0)}`; + case HAND_CATEGORIES.TRIPS: + return `Three of a kind, ${pluralRankName(first)}`; + case HAND_CATEGORIES.STRAIGHT: + return `Straight, ${highCardLabel(first)}`; + case HAND_CATEGORIES.FLUSH: + return `Flush, ${highCardLabel(first)}`; + case HAND_CATEGORIES.FULL_HOUSE: + return `Full house, ${pluralRankName(first)} over ${pluralRankName(tiebreakers[1] ?? 0)}`; + case HAND_CATEGORIES.QUADS: + return `Four of a kind, ${pluralRankName(first)}`; + case HAND_CATEGORIES.STRAIGHT_FLUSH: + return `Straight flush, ${highCardLabel(first)}`; + default: + return capitalize(HAND_CATEGORY_NAMES[category] ?? "unknown hand"); + } +} + +export function describeHandStrength( + holeCards: readonly Card[], + communityCards: readonly Card[], +): HandStrengthHint | null { + if (holeCards.length === 0) return null; + + if (communityCards.length === 0) { + const highCard = holeCards.reduce((best, card) => (card.rank > best.rank ? card : best)); + const pair = holeCards.length >= 2 && holeCards[0]?.rank === holeCards[1]?.rank; + return { + label: pair + ? `Pair of ${pluralRankName(highCard.rank)}` + : `High card ${rankName(highCard.rank)}`, + bestCardKeys: pair ? holeCards.map(cardKey) : [cardKey(highCard)], + }; + } + + const combined = [...holeCards, ...communityCards]; + if (combined.length < 5 || combined.length > 7) return null; + + const evaluation = evaluateHand(combined); + return { + label: postflopLabel(evaluation.rank.category, evaluation.rank.tiebreakers), + bestCardKeys: evaluation.best.map(cardKey), + }; +} diff --git a/src/lib/minigames/poker/handSetup.ts b/src/lib/minigames/poker/handSetup.ts new file mode 100644 index 0000000..155e334 --- /dev/null +++ b/src/lib/minigames/poker/handSetup.ts @@ -0,0 +1,153 @@ +/** + * Hand setup and teardown: dealing, blind posting, button movement, and + * final game placement. Pure functions over the table state. + */ + +import { postBlind, preflopFirstToAct } from "./betting"; +import { createShuffledDeck } from "./deck"; +import type { + Card, + PokerGamePlacement, + PokerPlayerState, + PokerSeatId, + PokerTableState, +} from "./types"; + +export function nextEligibleSeat( + players: readonly PokerPlayerState[], + afterSeat: PokerSeatId, +): PokerSeatId { + const count = players.length; + for (let step = 1; step <= count; step += 1) { + const seat = (afterSeat + step) % count; + const player = players[seat]; + if (player && !player.eliminated && player.stack > 0) return seat; + } + return afterSeat; +} + +function dealHoleCards( + players: readonly PokerPlayerState[], + buttonSeat: PokerSeatId, + deck: readonly Card[], +): { players: PokerPlayerState[]; deck: readonly Card[] } { + const holeCards = new Map(); + let deckIndex = 0; + // Deal starts left of the button, two cards per eligible player. + for (let round = 0; round < 2; round += 1) { + let seat = buttonSeat; + for (let step = 0; step < players.length; step += 1) { + seat = (seat + 1) % players.length; + const player = players[seat]; + if (!player || player.eliminated || player.stack <= 0) continue; + const card = deck[deckIndex]; + deckIndex += 1; + const hand = holeCards.get(seat) ?? []; + if (card) hand.push(card); + holeCards.set(seat, hand); + } + } + return { + players: players.map((player) => ({ + ...player, + holeCards: holeCards.get(player.seatId) ?? [], + })), + deck: deck.slice(deckIndex), + }; +} + +function finishGame(state: PokerTableState): PokerTableState { + const players = state.players.map((player) => + player.stack <= 0 ? { ...player, eliminated: true } : player, + ); + const ranked = [...players].sort((a, b) => b.stack - a.stack || a.seatId - b.seatId); + const placements = new Map(); + let current = 0; + let previousStack = -1; + ranked.forEach((player, index) => { + if (player.stack !== previousStack) { + current = index + 1; + previousStack = player.stack; + } + placements.set(player.seatId, current); + }); + const finalPlacements: PokerGamePlacement[] = players.map((player) => ({ + seatId: player.seatId, + placement: placements.get(player.seatId) ?? 0, + })); + return { + ...state, + players, + phase: "game-complete", + toActSeat: null, + finalPlacements, + }; +} + +/** Start the next hand: move the button, post blinds, deal, set preflop action. */ +export function startHand(state: PokerTableState): PokerTableState { + const eligible = state.players.filter((player) => player.stack > 0); + if (eligible.length <= 1) return finishGame(state); + + const handNumber = state.handNumber + 1; + const buttonSeat = nextEligibleSeat(state.players, state.buttonSeat); + const headsUp = eligible.length === 2; + const smallBlindSeat = headsUp ? buttonSeat : nextEligibleSeat(state.players, buttonSeat); + const bigBlindSeat = nextEligibleSeat(state.players, smallBlindSeat); + + const reset = state.players.map((player) => { + if (player.stack <= 0) { + return { + ...player, + eliminated: true, + folded: true, + allIn: false, + committed: 0, + totalCommitted: 0, + hasActed: false, + holeCards: [], + }; + } + return { + ...player, + folded: false, + allIn: false, + committed: 0, + totalCommitted: 0, + hasActed: false, + holeCards: [], + }; + }); + + const dealt = dealHoleCards(reset, buttonSeat, createShuffledDeck(state.seed, handNumber)); + const afterBlinds = dealt.players.map((player) => { + if (player.seatId === smallBlindSeat) return postBlind(player, state.config.smallBlind); + if (player.seatId === bigBlindSeat) return postBlind(player, state.config.bigBlind); + return player; + }); + + const smallBlindPosted = afterBlinds[smallBlindSeat]?.committed ?? 0; + const bigBlindPosted = afterBlinds[bigBlindSeat]?.committed ?? 0; + const currentBet = Math.max(smallBlindPosted, bigBlindPosted); + const toActSeat = preflopFirstToAct(afterBlinds, buttonSeat, bigBlindSeat, headsUp); + + return { + ...state, + handNumber, + players: afterBlinds, + buttonSeat, + smallBlindSeat, + bigBlindSeat, + street: "preflop", + phase: "betting", + collectedPot: 0, + communityCards: [], + deck: dealt.deck, + currentBet, + minRaise: state.config.bigBlind, + lastFullRaiseBet: currentBet, + toActSeat, + showdown: null, + finalPlacements: null, + }; +} diff --git a/src/lib/minigames/poker/index.ts b/src/lib/minigames/poker/index.ts new file mode 100644 index 0000000..e8f2a12 --- /dev/null +++ b/src/lib/minigames/poker/index.ts @@ -0,0 +1,17 @@ +/** + * Public API of the pure No-Limit Texas Hold'em poker engine. + * Everything exported here is client-safe: no Payload, Next.js, process, or + * database imports, and no module-level mutable state. + */ + +export * from "./constants"; +export * from "./types"; +export * from "./deck"; +export * from "./evaluator"; +export * from "./pots"; +export * from "./betting"; +export * from "./handSetup"; +export * from "./showdown"; +export * from "./views"; +export * from "./engine"; +export * from "./actions"; diff --git a/src/lib/minigames/poker/pots.ts b/src/lib/minigames/poker/pots.ts new file mode 100644 index 0000000..c1bc18b --- /dev/null +++ b/src/lib/minigames/poker/pots.ts @@ -0,0 +1,62 @@ +/** + * Layered side-pot construction from per-player hand contributions. + * + * Pure math over `totalCommitted`: pot boundaries are the distinct + * contribution levels of players who are still eligible to win (not folded). + * Folded players' chips fill the pots up to their contribution level; any + * dead money a folder contributed above the highest live level rolls into + * the top pot. + */ + +import type { PokerPlayerState, PokerSeatId } from "./types"; + +export interface PokerSidePot { + readonly amount: number; + /** Seats whose hands compete for this pot (non-folded, contribution >= level). */ + readonly eligibleSeats: readonly PokerSeatId[]; +} + +export function buildSidePots(players: readonly PokerPlayerState[]): PokerSidePot[] { + const contributors = players.filter((player) => player.totalCommitted > 0); + if (contributors.length === 0) return []; + const live = contributors.filter((player) => !player.folded); + const levels = Array.from(new Set(live.map((player) => player.totalCommitted))).sort( + (a, b) => a - b, + ); + + const pots: PokerSidePot[] = []; + let previousLevel = 0; + for (const level of levels) { + let amount = 0; + for (const player of contributors) { + amount += Math.min(player.totalCommitted, level) - Math.min(player.totalCommitted, previousLevel); + } + if (amount > 0) { + const eligibleSeats = live + .filter((player) => player.totalCommitted >= level) + .map((player) => player.seatId); + pots.push({ amount, eligibleSeats }); + } + previousLevel = level; + } + + // Dead money above the highest live level (a folder who put in more than + // every live player's all-in) belongs to the top pot's winners. + let excess = 0; + for (const player of contributors) { + excess += player.totalCommitted - Math.min(player.totalCommitted, previousLevel); + } + if (excess > 0) { + const top = pots[pots.length - 1]; + if (top) { + pots[pots.length - 1] = { + amount: top.amount + excess, + eligibleSeats: top.eligibleSeats, + }; + } else { + pots.push({ amount: excess, eligibleSeats: live.map((player) => player.seatId) }); + } + } + + return pots; +} diff --git a/src/lib/minigames/poker/showdown.ts b/src/lib/minigames/poker/showdown.ts new file mode 100644 index 0000000..2242fe3 --- /dev/null +++ b/src/lib/minigames/poker/showdown.ts @@ -0,0 +1,116 @@ +/** + * Showdown resolution: evaluate live hands, layer side pots, award each pot + * to the best eligible hand (splitting ties), and produce per-player + * outcomes with placements, chips won, and net change. + */ + +import { compareHandRanks, evaluateHand, type PokerHandEvaluation } from "./evaluator"; +import { buildSidePots } from "./pots"; +import type { + PokerPlayerShowdownResult, + PokerPotResult, + PokerSeatId, + PokerTableState, + ShowdownOutcome, +} from "./types"; + +/** Odd-chip ordering: seats are paid starting left of the button. */ +function oddChipOrder( + seats: readonly PokerSeatId[], + buttonSeat: PokerSeatId, + tableSize: number, +): PokerSeatId[] { + return [...seats].sort( + (a, b) => + ((a - buttonSeat - 1 + tableSize * 2) % tableSize) - + ((b - buttonSeat - 1 + tableSize * 2) % tableSize), + ); +} + +export function resolveShowdown(state: PokerTableState): ShowdownOutcome { + const inHand = state.players.filter((player) => !player.eliminated); + const live = inHand.filter((player) => !player.folded); + + const evaluations = new Map(); + for (const player of live) { + evaluations.set( + player.seatId, + evaluateHand([...player.holeCards, ...state.communityCards]), + ); + } + + const pots = buildSidePots(state.players); + const winnings = new Map(); + const potResults: PokerPotResult[] = []; + + pots.forEach((pot, potIndex) => { + let best: PokerHandEvaluation | null = null; + for (const seat of pot.eligibleSeats) { + const evaluation = evaluations.get(seat); + if (evaluation && (!best || compareHandRanks(evaluation.rank, best.rank) > 0)) { + best = evaluation; + } + } + const winners = best + ? pot.eligibleSeats.filter((seat) => { + const evaluation = evaluations.get(seat); + return evaluation !== undefined && compareHandRanks(evaluation.rank, best.rank) === 0; + }) + : []; + + const ordered = oddChipOrder(winners, state.buttonSeat, state.players.length); + const share = ordered.length > 0 ? Math.floor(pot.amount / ordered.length) : 0; + let remainder = ordered.length > 0 ? pot.amount - share * ordered.length : 0; + for (const seat of ordered) { + let won = share; + if (remainder > 0) { + won += 1; + remainder -= 1; + } + winnings.set(seat, (winnings.get(seat) ?? 0) + won); + } + potResults.push({ + potIndex, + amount: pot.amount, + winnerSeats: ordered, + eligibleSeats: pot.eligibleSeats, + }); + }); + + // Showdown placements: live hands ranked best first, ties sharing the + // better placement; folded players rank below every live hand. + const liveOrdered = [...live].sort((a, b) => { + const ea = evaluations.get(a.seatId); + const eb = evaluations.get(b.seatId); + if (!ea || !eb) return 0; + return compareHandRanks(eb.rank, ea.rank); + }); + const placements = new Map(); + let currentPlacement = 0; + let previous: PokerHandEvaluation | null = null; + liveOrdered.forEach((player, index) => { + const evaluation = evaluations.get(player.seatId); + if (!evaluation) return; + if (!previous || compareHandRanks(evaluation.rank, previous.rank) !== 0) { + currentPlacement = index + 1; + } + placements.set(player.seatId, currentPlacement); + previous = evaluation; + }); + const foldedPlacement = live.length + 1; + + const results: PokerPlayerShowdownResult[] = inHand.map((player) => { + const evaluation = evaluations.get(player.seatId) ?? null; + const chipsWon = winnings.get(player.seatId) ?? 0; + return { + seatId: player.seatId, + handRank: evaluation?.rank ?? null, + bestHand: evaluation?.best ?? null, + chipsWon, + netChange: chipsWon - player.totalCommitted, + placement: player.folded ? foldedPlacement : (placements.get(player.seatId) ?? foldedPlacement), + }; + }); + + return { pots: potResults, results }; +} diff --git a/src/lib/minigames/poker/stats.ts b/src/lib/minigames/poker/stats.ts new file mode 100644 index 0000000..0e62d0b --- /dev/null +++ b/src/lib/minigames/poker/stats.ts @@ -0,0 +1,174 @@ +/** + * Poker XP + profile-stats awarding (Payload-dependent, server-only). + * Signatures below are the pinned contract consumed by the table registry; + * the database-backed body of awardPokerGameResult is filled in by the + * stats implementation pass. + */ + +import type { Payload } from "payload"; +import { broadcastToUser } from "@/lib/realtime/bus"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; + +export interface PokerPlacementResult { + userId: number; + /** 1 = winner. */ + placement: number; + /** Chip total the player finished the game with. */ + finalChips: number; + /** Hands this player won outright during the game. */ + handsWon: number; +} + +export interface PokerStats { + gamesPlayed: number; + wins: number; + secondPlaces: number; + thirdPlaces: number; + fourthPlaces: number; + handsWon: number; + bestScore: number; + xpEarned: number; +} + +/** + * XP per placement, scaled by table size: + * 1st = 40x, 2nd = 20x, 3rd = 10x, 4th = 5x (times playerCount). + * Tunable here only; the registry and UI must not hardcode XP values. + */ +export function xpForPlacement(placement: number, playerCount: number): number { + const multiplier = + placement === 1 ? 40 : placement === 2 ? 20 : placement === 3 ? 10 : 5; + return multiplier * playerCount; +} + +/** + * Defensive reader for a profile's progression.minigames.poker stats. + * Accepts anything (profiles may predate the poker group) and returns a + * fully-populated PokerStats with zeros for missing values. + */ +export function readPokerStats(profile: unknown): PokerStats { + const poker = + (profile as { progression?: { minigames?: { poker?: Partial } } }) + ?.progression?.minigames?.poker ?? {}; + return { + gamesPlayed: poker.gamesPlayed ?? 0, + wins: poker.wins ?? 0, + secondPlaces: poker.secondPlaces ?? 0, + thirdPlaces: poker.thirdPlaces ?? 0, + fourthPlaces: poker.fourthPlaces ?? 0, + handsWon: poker.handsWon ?? 0, + bestScore: poker.bestScore ?? 0, + xpEarned: poker.xpEarned ?? 0, + }; +} + +/** + * Pure stat transition for one finished sit-and-go entry: bumps gamesPlayed, + * the placement counter matching the result's placement, accumulates hands + * won, keeps the best chip total, and adds the placement XP. + */ +export function computePokerStatsDelta( + current: PokerStats, + result: PokerPlacementResult, + playerCount: number, +): PokerStats { + const placement = result.placement; + return { + gamesPlayed: current.gamesPlayed + 1, + wins: current.wins + (placement === 1 ? 1 : 0), + secondPlaces: current.secondPlaces + (placement === 2 ? 1 : 0), + thirdPlaces: current.thirdPlaces + (placement === 3 ? 1 : 0), + fourthPlaces: current.fourthPlaces + (placement === 4 ? 1 : 0), + handsWon: current.handsWon + result.handsWon, + bestScore: Math.max(current.bestScore, result.finalChips), + xpEarned: current.xpEarned + xpForPlacement(placement, playerCount), + }; +} + +/** + * Awards XP and updates progression.minigames.poker for every player in a + * completed sit-and-go. Emits the profile-update SSE push and a single + * game event log entry (EventTypes.MinigamePoker). Never throws: per-user + * failures are logged and skipped so one broken profile cannot block the + * table. + */ +export async function awardPokerGameResult( + payload: Payload, + results: PokerPlacementResult[], +): Promise<{ xpByUser: Record }> { + if (results.length === 0) { + return { xpByUser: {} }; + } + + const playerCount = results.length; + const xpByUser: Record = {}; + + for (const result of results) { + const xp = xpForPlacement(result.placement, playerCount); + xpByUser[result.userId] = xp; + + try { + const profileRes = await payload.find({ + collection: "profiles", + where: { user: { equals: result.userId } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const profile = profileRes.docs[0]; + if (!profile) { + payload.logger.error(`Poker stats: no profile found for user ${result.userId}`); + continue; + } + + const current = readPokerStats(profile); + const next = computePokerStatsDelta(current, result, playerCount); + const progression = (profile.progression ?? {}) as Record; + const minigames = (progression.minigames ?? {}) as Record; + + await payload.update({ + collection: "profiles", + id: profile.id, + data: { + progression: { + ...progression, + minigames: { + ...minigames, + poker: next, + }, + }, + }, + overrideAccess: true, + depth: 0, + }); + + broadcastToUser("profile-update", result.userId, { + reason: "xp", + xpGained: xp, + profileId: profile.id, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + payload.logger.error( + `Poker stats: failed to award result for user ${result.userId}: ${message}`, + ); + } + } + + const winner = results.find((r) => r.placement === 1) ?? results[0]; + await emitGameEvent(payload, { + type: EventTypes.MinigamePoker, + message: `finished a poker sit-and-go (1st of ${playerCount})`, + actor: winner.userId, + targetCollection: "users", + targetId: winner.userId, + data: { + placements: results, + xpByUser, + playerCount, + }, + }); + + return { xpByUser }; +} diff --git a/src/lib/minigames/poker/tables.ts b/src/lib/minigames/poker/tables.ts new file mode 100644 index 0000000..b98dd39 --- /dev/null +++ b/src/lib/minigames/poker/tables.ts @@ -0,0 +1,579 @@ +/** + * Server-side multiplayer registry for the poker minigame. + * + * Holds live table records in a module-level Map (same pattern as + * src/lib/realtime/bus.ts). SINGLE-INSTANCE ONLY: table state lives in + * process memory, so this will not work across multiple server instances; + * the SSE pipeline it pushes through has the same limitation. + * + * The pure engine (./engine, ./actions) never touches timers or I/O. This + * wrapper owns turn timeouts, next-hand pauses, lobby broadcasts, and the + * per-user snapshot pushes. Seat map invariant: seatMap is padded to + * MAX_PLAYERS slots with trailing nulls, so engine player i always + * corresponds to seatMap[i] (appends never shift existing indices). + * + * allow: SIZE_OK: single cohesive registry module; the task pins this exact + * file path as the shared server-side surface for three parallel agents, so + * it cannot be split without breaking the ownership contract. + */ + +import { getPayload } from "payload"; +import type { Payload } from "payload"; +import { broadcast, broadcastToUser } from "@/lib/realtime/bus"; +import { notifyUser } from "@/lib/notifications"; +import { applyAction } from "./actions"; +import { getLegalActions } from "./betting"; +import { + MAX_PLAYERS, + MAX_TABLE_NAME_LENGTH, + MIN_PLAYERS, + STARTING_STACK, + TURN_TIMEOUT_MS, +} from "./constants"; +import { advanceState, createTableState } from "./engine"; +import { awardPokerGameResult, type PokerPlacementResult } from "./stats"; +import type { + PokerActionKind, + PokerPlayerState, + PokerSeatId, + PokerTableState, + PrivatePlayerView, +} from "./types"; +import { buildPrivateView } from "./views"; + +/** Maximum number of live tables; table creation rejects beyond this. */ +export const MAX_TABLES = 20; + +/** Pause after a hand result before the next hand auto-starts. */ +export const NEXT_HAND_DELAY_MS = 6000; + +/** A table with no client interaction for this long is swept as abandoned. */ +export const TABLE_EXPIRY_MS = 5 * 60_000; + +const TABLE_SWEEP_INTERVAL_MS = 30_000; + +export interface PokerTableSummary { + tableId: string; + name: string | null; + hostName: string; + seatedCount: number; + maxSeats: number; + inGame: boolean; + createdAt: string; +} + +export interface PokerSnapshot { + tableId: string; + name: string | null; + version: number; + /** ISO timestamp when the current turn auto-acts; null when no live turn. */ + turnDeadline: string | null; + /** ISO timestamp when the next hand auto-starts after a hand result pause. */ + nextHandAt: string | null; + /** Full table state plus the viewer's private cards and legal actions; null when not seated. */ + you: PrivatePlayerView | null; + /** seatId to userId (null = empty seat); lets the UI map names and hosts. */ + seats: (number | null)[]; + hostSeatId: number | null; +} + +interface PendingJoin { + userId: number; + name: string; +} + +interface PokerTableRecord { + id: string; + name: string | null; + version: number; + hostUserId: number | null; + /** Engine seat order, padded to MAX_PLAYERS with trailing nulls. */ + seatMap: (number | null)[]; + state: PokerTableState | null; + inGame: boolean; + handsWonThisGame: Record; + /** handNumber already counted into handsWonThisGame (prevents double counts). */ + countedHandNumber: number | null; + turnTimer: ReturnType | null; + /** Version the pending turn timer was scheduled for (stale timers bail). */ + turnTimerVersion: number | null; + turnDeadlineAt: string | null; + nextHandTimer: ReturnType | null; + nextHandAt: string | null; + pendingJoins: PendingJoin[]; + awayUsers: Set; + /** Users who left mid-game; kept in seatMap so the UI can show "left". */ + leftUsers: Set; + userNames: Map; + createdAt: string; + /** Last client interaction (poll, action, join/leave/start); drives expiry. */ + lastActivityAt: number; +} + +// Anchored on globalThis because Next.js dev gives each client session its +// own copy of server-action modules; a plain module-level Map would fork per +// session and tables would vanish between users. Single-process still. +type PokerTablesStore = Map; +interface PokerGlobalRegistry { + __pokerTables?: PokerTablesStore; +} +const globalRegistry = globalThis as typeof globalThis & PokerGlobalRegistry; +const tables: PokerTablesStore = (globalRegistry.__pokerTables ??= new Map()); + +function isSeatedAt(record: PokerTableRecord, userId: number): boolean { + return record.seatMap.includes(userId) && !record.leftUsers.has(userId); +} + +function seatedCountOf(record: PokerTableRecord): number { + let count = 0; + for (const id of record.seatMap) { + if (id !== null && !record.leftUsers.has(id)) count += 1; + } + return count; +} + +function clearTurnTimer(record: PokerTableRecord): void { + if (record.turnTimer) clearTimeout(record.turnTimer); + record.turnTimer = null; + record.turnTimerVersion = null; + record.turnDeadlineAt = null; +} + +/** + * Lobby mutations (join/leave) bump the version without touching game state; + * re-key the pending turn timer so it stays valid instead of going stale. + */ +function rekeyTurnTimer(record: PokerTableRecord): void { + if (record.turnTimer !== null) record.turnTimerVersion = record.version; +} + +function scheduleTurnTimer(record: PokerTableRecord): void { + clearTurnTimer(record); + const state = record.state; + if (!state || state.phase !== "betting" || state.toActSeat === null) return; + record.turnTimerVersion = record.version; + record.turnDeadlineAt = new Date(Date.now() + TURN_TIMEOUT_MS).toISOString(); + record.turnTimer = setTimeout(() => onTurnTimeout(record), TURN_TIMEOUT_MS); +} + +/** Turn timeout: auto check/fold for whoever is acting (away or disconnected). */ +function onTurnTimeout(record: PokerTableRecord): void { + const scheduledVersion = record.turnTimerVersion; + record.turnTimer = null; + record.turnTimerVersion = null; + record.turnDeadlineAt = null; + if (scheduledVersion === null || scheduledVersion !== record.version) return; + const state = record.state; + if (!state || state.phase !== "betting" || state.toActSeat === null) return; + const seat = state.toActSeat; + const legal = getLegalActions(state, seat); + const result = applyAction(state, seat, legal.canCheck ? "check" : "fold"); + if (!result.success) return; + record.state = result.state; + record.version += 1; + afterMutation(record); +} + +function scheduleNextHand(record: PokerTableRecord): void { + if (record.nextHandTimer) clearTimeout(record.nextHandTimer); + record.nextHandAt = new Date(Date.now() + NEXT_HAND_DELAY_MS).toISOString(); + record.nextHandTimer = setTimeout(() => onNextHandTimeout(record), NEXT_HAND_DELAY_MS); +} + +function clearNextHandTimer(record: PokerTableRecord): void { + if (record.nextHandTimer) clearTimeout(record.nextHandTimer); + record.nextHandTimer = null; + record.nextHandAt = null; +} + +/** Next-hand pause over: apply pending joins, drop leavers, deal the next hand. */ +function onNextHandTimeout(record: PokerTableRecord): void { + record.nextHandTimer = null; + const current = record.state; + if (!current) return; + let next = current; + for (const pending of record.pendingJoins) { + next = { + ...next, + players: [...next.players, createFreshPlayer(next.players.length, pending.name)], + }; + } + record.pendingJoins = []; + next = eliminateLeavers(next, record); + record.state = advanceState(next); + record.nextHandAt = null; + record.version += 1; + afterMutation(record); +} + +function createFreshPlayer(seatId: PokerSeatId, name: string): PokerPlayerState { + return { + seatId, + name, + stack: STARTING_STACK, + committed: 0, + totalCommitted: 0, + holeCards: [], + folded: true, + allIn: false, + hasActed: false, + eliminated: false, + }; +} + +/** Mid-game leavers forfeit their stack and sit out from the next hand on. */ +function eliminateLeavers(state: PokerTableState, record: PokerTableRecord): PokerTableState { + let changed = false; + const players = state.players.map((player, index) => { + const userId = record.seatMap[index]; + if (userId === null || !record.leftUsers.has(userId) || player.eliminated) return player; + changed = true; + return { + ...player, + eliminated: true, + folded: true, + stack: 0, + committed: 0, + totalCommitted: 0, + holeCards: [], + }; + }); + return changed ? { ...state, players } : state; +} + +/** Count outright hand winners once per hand (both fold-outs and showdowns). */ +function countHandWins(record: PokerTableRecord): void { + const state = record.state; + if (!state || !state.showdown) return; + if (state.phase === "betting") return; + if (record.countedHandNumber === state.handNumber) return; + record.countedHandNumber = state.handNumber; + for (const result of state.showdown.results) { + if (result.netChange <= 0) continue; + const userId = record.seatMap[result.seatId]; + if (userId === null) continue; + record.handsWonThisGame[userId] = (record.handsWonThisGame[userId] ?? 0) + 1; + } +} + +/** Fire-and-forget XP/stats award on game completion; never blocks the table. */ +async function awardGameResults(record: PokerTableRecord): Promise { + const state = record.state; + if (!state || !state.finalPlacements) return; + const results: PokerPlacementResult[] = []; + for (const placement of state.finalPlacements) { + const userId = record.seatMap[placement.seatId]; + const player = state.players[placement.seatId]; + if (userId === null || !player) continue; + results.push({ + userId, + placement: placement.placement, + finalChips: player.stack, + handsWon: record.handsWonThisGame[userId] ?? 0, + }); + } + try { + const config = (await import("@payload-config")).default; + const payload = await getPayload({ config }); + try { + await awardPokerGameResult(payload, results); + } catch (error) { + payload.logger.error(`[poker] Failed to award game results for table ${record.id}: ${error}`); + } + } catch { + // Payload unavailable in this runtime; skip awarding. + } +} + +/** Shared post-mutation pipeline: timers, hand-win counting, award, push. */ +function afterMutation(record: PokerTableRecord): void { + countHandWins(record); + const state = record.state; + if (state && state.phase === "game-complete") { + clearTurnTimer(record); + clearNextHandTimer(record); + record.inGame = false; + void awardGameResults(record); + } else if (state && (state.phase === "hand-complete" || state.phase === "showdown")) { + clearTurnTimer(record); + scheduleNextHand(record); + } else { + scheduleTurnTimer(record); + } + pushTable(record); +} + +function buildSnapshot(record: PokerTableRecord, userId: number): PokerSnapshot { + const seatIndex = record.seatMap.indexOf(userId); + const seated = seatIndex >= 0 && !record.leftUsers.has(userId); + const state = record.state; + const inEngine = seated && state !== null && seatIndex < state.players.length; + const you = state && inEngine ? buildPrivateView(state, seatIndex) : null; + const hostSeat = record.hostUserId !== null ? record.seatMap.indexOf(record.hostUserId) : -1; + return { + tableId: record.id, + name: record.name, + version: record.version, + turnDeadline: record.turnDeadlineAt, + nextHandAt: record.nextHandAt, + you, + seats: [...record.seatMap], + hostSeatId: hostSeat >= 0 ? hostSeat : null, + }; +} + +/** Push a per-user snapshot to every seated user, then refresh the lobby. */ +function pushTable(record: PokerTableRecord): void { + for (const userId of record.seatMap) { + if (userId === null || record.leftUsers.has(userId)) continue; + broadcastToUser("poker-state", userId, { + tableId: record.id, + snapshot: buildSnapshot(record, userId), + }); + } + broadcast("poker-tables", { tables: listSummaries() }); +} + +function disposeTable(record: PokerTableRecord): void { + clearTurnTimer(record); + clearNextHandTimer(record); + tables.delete(record.id); + broadcast("poker-tables", { tables: listSummaries() }); +} + +export function forceExpireTable(tableId: string, payload: Payload): void { + const record = tables.get(tableId); + if (!record) throw new Error("Table not found."); + + for (const userId of record.seatMap) { + if (userId === null || record.leftUsers.has(userId)) continue; + + broadcastToUser("poker-terminated", userId, { + tableId: record.id, + reason: "force-ended", + }); + + try { + void notifyUser(payload, { + userId, + type: "poker:force-ended", + title: "Poker game ended", + message: "An administrator ended your poker game.", + link: "/minigames/poker", + }); + } catch (error) { + payload.logger.error( + `[poker] Failed to notify user ${userId} for table ${record.id}: ${error}`, + ); + } + } + + disposeTable(record); +} + +/** Tables with nobody seated (everyone left) are removed immediately. */ +function scheduleDisposalIfEmpty(record: PokerTableRecord): void { + if (seatedCountOf(record) > 0) return; + disposeTable(record); +} + +function promoteHost(record: PokerTableRecord): void { + const nextHost = record.seatMap.find((id) => id !== null && !record.leftUsers.has(id)); + record.hostUserId = nextHost ?? null; +} + +export function createTable(userId: number, displayName: string): string { + if (tables.size >= MAX_TABLES) { + throw new Error("The maximum number of poker tables exists. Try again later."); + } + const seatMap: (number | null)[] = [userId]; + while (seatMap.length < MAX_PLAYERS) seatMap.push(null); + const record: PokerTableRecord = { + id: crypto.randomUUID(), + name: null, + version: 1, + hostUserId: userId, + seatMap, + state: null, + inGame: false, + handsWonThisGame: {}, + countedHandNumber: null, + turnTimer: null, + turnTimerVersion: null, + turnDeadlineAt: null, + nextHandTimer: null, + nextHandAt: null, + pendingJoins: [], + awayUsers: new Set(), + leftUsers: new Set(), + userNames: new Map([[userId, displayName]]), + createdAt: new Date().toISOString(), + lastActivityAt: Date.now(), + }; + tables.set(record.id, record); + pushTable(record); + return record.id; +} + +export function listSummaries(): PokerTableSummary[] { + return [...tables.values()].map((record) => ({ + tableId: record.id, + name: record.name, + hostName: + record.hostUserId !== null + ? (record.userNames.get(record.hostUserId) ?? "Unknown") + : "Unknown", + seatedCount: seatedCountOf(record), + maxSeats: MAX_PLAYERS, + inGame: record.inGame, + createdAt: record.createdAt, + })); +} + +export function getSnapshotFor(tableId: string, userId: number): PokerSnapshot { + const record = tables.get(tableId); + if (!record) throw new Error("Table not found."); + record.lastActivityAt = Date.now(); + return buildSnapshot(record, userId); +} + +export function joinTable(tableId: string, userId: number, displayName: string): PokerSnapshot { + const record = tables.get(tableId); + if (!record) throw new Error("Table not found."); + if (record.leftUsers.has(userId)) { + throw new Error("You already left this table. You can rejoin when a new game starts."); + } + if (isSeatedAt(record, userId)) return buildSnapshot(record, userId); + for (const other of tables.values()) { + if (other.id !== record.id && isSeatedAt(other, userId)) { + throw new Error("You are already seated at another table. Leave it first."); + } + } + const slot = record.seatMap.indexOf(null); + if (slot < 0) throw new Error("The table is full."); + record.seatMap[slot] = userId; + record.userNames.set(userId, displayName); + record.lastActivityAt = Date.now(); + if (record.inGame) record.pendingJoins.push({ userId, name: displayName }); + scheduleDisposalIfEmpty(record); + record.version += 1; + rekeyTurnTimer(record); + pushTable(record); + return buildSnapshot(record, userId); +} + +export function leaveTable(tableId: string, userId: number): void { + const record = tables.get(tableId); + if (!record) throw new Error("Table not found."); + if (!isSeatedAt(record, userId)) throw new Error("You are not seated at this table."); + if (record.inGame) { + // Mid-game: keep the seat visible, forfeit the stack at the next hand. + record.leftUsers.add(userId); + record.awayUsers.add(userId); + } else { + const seatIndex = record.seatMap.indexOf(userId); + record.seatMap = record.seatMap.filter((_, index) => index !== seatIndex); + while (record.seatMap.length < MAX_PLAYERS) record.seatMap.push(null); + record.pendingJoins = record.pendingJoins.filter((pending) => pending.userId !== userId); + } + if (record.hostUserId === userId) promoteHost(record); + record.lastActivityAt = Date.now(); + record.version += 1; + rekeyTurnTimer(record); + scheduleDisposalIfEmpty(record); + pushTable(record); +} + +export function startGame(tableId: string, userId: number): PokerSnapshot { + const record = tables.get(tableId); + if (!record) throw new Error("Table not found."); + if (record.hostUserId !== userId) throw new Error("Only the host can start the game."); + const seated = record.seatMap.filter( + (id): id is number => id !== null && !record.leftUsers.has(id), + ); + if (seated.length < MIN_PLAYERS) { + throw new Error("At least two seated players are needed to start the game."); + } + // Compact the seat map for the new game: leavers are out, order preserved. + // Pad a COPY: aliasing `seated` here once spawned phantom "Player null" seats. + const padded: (number | null)[] = [...seated]; + while (padded.length < MAX_PLAYERS) padded.push(null); + record.seatMap = padded; + record.leftUsers.clear(); + record.awayUsers.clear(); + record.pendingJoins = []; + record.handsWonThisGame = {}; + record.countedHandNumber = null; + record.nextHandAt = null; + if (record.nextHandTimer) { + clearTimeout(record.nextHandTimer); + record.nextHandTimer = null; + } + record.state = createTableState( + seated.map((id) => ({ name: record.userNames.get(id) ?? `Player ${id}` })), + {}, + undefined, + ); + record.inGame = true; + record.lastActivityAt = Date.now(); + record.version += 1; + afterMutation(record); + return buildSnapshot(record, userId); +} + +export function renameTable(tableId: string, userId: number, rawName: string): PokerSnapshot { + const record = tables.get(tableId); + if (!record) throw new Error("Table not found."); + if (record.hostUserId !== userId) throw new Error("Only the host can rename the table."); + const name = rawName.trim().replace(/\s+/g, " ").slice(0, MAX_TABLE_NAME_LENGTH).trim(); + record.name = name.length > 0 ? name : null; + record.lastActivityAt = Date.now(); + record.version += 1; + pushTable(record); + return buildSnapshot(record, userId); +} + +export function applyPlayerAction( + tableId: string, + userId: number, + kind: PokerActionKind, + amount?: number, +): PokerSnapshot { + const record = tables.get(tableId); + if (!record) throw new Error("Table not found."); + const state = record.state; + if (!state) throw new Error("The game has not started yet."); + if (record.leftUsers.has(userId)) throw new Error("You are not seated at this table."); + const seatIndex = record.seatMap.indexOf(userId); + if (seatIndex < 0) throw new Error("You are not seated at this table."); + if (seatIndex >= state.players.length) { + throw new Error("You will join the game at the next hand."); + } + const result = applyAction(state, seatIndex, kind, amount); + if (!result.success) throw new Error(result.error); + record.state = result.state; + record.lastActivityAt = Date.now(); + record.version += 1; + afterMutation(record); + return buildSnapshot(record, userId); +} + +/** + * Sweeps tables whose last client interaction is older than TABLE_EXPIRY_MS: + * tabs closed without leaving, abandoned waiting rooms, gone games. Safe to + * run from several module copies in dev because the store is shared via + * globalThis and disposal is idempotent. + */ +function sweepExpiredTables(): void { + const now = Date.now(); + let expired = false; + for (const record of tables.values()) { + if (now - record.lastActivityAt <= TABLE_EXPIRY_MS) continue; + disposeTable(record); + expired = true; + } + if (expired) broadcast("poker-tables", { tables: listSummaries() }); +} + +const sweepInterval = setInterval(sweepExpiredTables, TABLE_SWEEP_INTERVAL_MS); +sweepInterval.unref?.(); diff --git a/src/lib/minigames/poker/types.ts b/src/lib/minigames/poker/types.ts new file mode 100644 index 0000000..651f806 --- /dev/null +++ b/src/lib/minigames/poker/types.ts @@ -0,0 +1,194 @@ +/** + * Shared types for the No-Limit Texas Hold'em poker engine. + * + * Every type is plain JSON-serializable data: the whole table state can be + * stored in a registry Map, sent over SSE, and rebuilt on the client without + * any transformation. No Payload, Next.js, or process imports here. + */ + +export type Suit = "s" | "h" | "d" | "c"; + +/** 2 through 10, then 11 = jack, 12 = queen, 13 = king, 14 = ace. */ +export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14; + +export interface Card { + readonly rank: Rank; + readonly suit: Suit; +} + +/** Seat index into `PokerTableState.players`; seatId === array index. */ +export type PokerSeatId = number; + +export type PokerActionKind = "fold" | "check" | "call" | "bet" | "raise"; + +export type PokerStreet = "preflop" | "flop" | "turn" | "river"; + +/** + * Lifecycle of the table: + * - "betting": a hand is in progress and waiting for actions. + * - "showdown": the hand ended with a showdown; results are attached. + * - "hand-complete": the hand ended without a showdown (fold out); results + * are attached but hole cards stay hidden. + * - "game-complete": one player holds every chip; placements are attached. + */ +export type PokerPhase = "betting" | "showdown" | "hand-complete" | "game-complete"; + +export interface PokerTableConfig { + readonly startingStack: number; + readonly smallBlind: number; + readonly bigBlind: number; + readonly turnTimeoutMs: number; +} + +export interface PokerPlayerState { + readonly seatId: PokerSeatId; + readonly name: string; + /** Chips not yet committed; deducted immediately as bets are made. */ + readonly stack: number; + /** Chips committed to the current betting street. */ + readonly committed: number; + /** Chips committed to the whole hand so far; drives side-pot layering. */ + readonly totalCommitted: number; + readonly holeCards: readonly Card[]; + readonly folded: boolean; + readonly allIn: boolean; + /** Whether the player has acted during the current betting street. */ + readonly hasActed: boolean; + /** Busted players sit out; set when their stack reaches 0. */ + readonly eliminated: boolean; +} + +export interface PokerTableState { + readonly config: PokerTableConfig; + /** + * Base seed for deck shuffling. Each hand derives its own seed from this + * (see deck.ts), so a game is fully reproducible from the seed alone. + */ + readonly seed: number; + /** 1-based hand counter; 0 means no hand has started yet. */ + readonly handNumber: number; + readonly players: readonly PokerPlayerState[]; + readonly buttonSeat: PokerSeatId; + readonly smallBlindSeat: PokerSeatId; + readonly bigBlindSeat: PokerSeatId; + readonly street: PokerStreet; + readonly phase: PokerPhase; + /** Chips collected from completed streets; live bets live in `committed`. */ + readonly collectedPot: number; + readonly communityCards: readonly Card[]; + /** Remaining deck, top of deck first. */ + readonly deck: readonly Card[]; + /** Highest total commitment on the current street. */ + readonly currentBet: number; + /** Minimum raise increment (size of the last full raise, at least one BB). */ + readonly minRaise: number; + /** + * currentBet as of the last full raise (or the street's opening bet level). + * A short all-in raise raises currentBet without touching this, which is + * what prevents the raise from reopening action for players who acted. + */ + readonly lastFullRaiseBet: number; + readonly toActSeat: PokerSeatId | null; + readonly showdown: ShowdownOutcome | null; + /** Set once the game completes: tournament placement per seat. */ + readonly finalPlacements: readonly PokerGamePlacement[] | null; +} + +/** Comparison result of a 5-card poker hand, category first then kickers. */ +export interface HandRank { + /** 0 = high card through 8 = straight flush (royal is an ace-high SF). */ + readonly category: number; + /** Descending ranks used to break ties within the category. */ + readonly tiebreakers: readonly number[]; +} + +export interface PokerPotResult { + /** 0 is the main pot, higher indexes are side pots. */ + readonly potIndex: number; + readonly amount: number; + readonly winnerSeats: readonly PokerSeatId[]; + /** Seats whose hands competed for this pot (useful for side-pot labels). */ + readonly eligibleSeats: readonly PokerSeatId[]; +} + +export interface PokerPlayerShowdownResult { + readonly seatId: PokerSeatId; + /** Null when the player folded (or the hand ended without a showdown). */ + readonly handRank: HandRank | null; + /** The best 5 cards actually played; null when folded. */ + readonly bestHand: readonly Card[] | null; + readonly chipsWon: number; + /** chipsWon minus what the player put in this hand. */ + readonly netChange: number; + /** 1 = best hand at showdown; folded players rank below all live hands. */ + readonly placement: number; +} + +export interface ShowdownOutcome { + readonly pots: readonly PokerPotResult[]; + /** One entry per player dealt into the hand (eliminated players excluded). */ + readonly results: readonly PokerPlayerShowdownResult[]; +} + +export interface PokerGamePlacement { + readonly seatId: PokerSeatId; + readonly placement: number; +} + +/** What the acting player is allowed to do right now. */ +export interface PokerLegalActions { + readonly seatId: PokerSeatId; + readonly canFold: boolean; + readonly canCheck: boolean; + readonly canCall: boolean; + /** Chips needed to call (capped at the player's stack). */ + readonly callAmount: number; + readonly canBet: boolean; + /** Total street commitment required to open the betting. */ + readonly minBetTo: number; + readonly maxBetTo: number; + readonly canRaise: boolean; + /** Total street commitment required for a legal (non-all-in) raise. */ + readonly minRaiseTo: number; + readonly maxRaiseTo: number; +} + +export interface PublicPlayerView { + readonly seatId: PokerSeatId; + readonly name: string; + readonly stack: number; + readonly committed: number; + readonly totalCommitted: number; + readonly folded: boolean; + readonly allIn: boolean; + readonly hasActed: boolean; + readonly eliminated: boolean; + /** Populated only for the viewer or for live hands at showdown. */ + readonly holeCards: readonly Card[] | null; + readonly cardCount: number; +} + +export interface PublicTableView { + readonly players: readonly PublicPlayerView[]; + readonly buttonSeat: PokerSeatId; + readonly smallBlindSeat: PokerSeatId; + readonly bigBlindSeat: PokerSeatId; + readonly street: PokerStreet; + readonly phase: PokerPhase; + /** Collected pot plus live street bets. */ + readonly potTotal: number; + readonly communityCards: readonly Card[]; + readonly currentBet: number; + readonly toActSeat: PokerSeatId | null; + readonly handNumber: number; + readonly config: PokerTableConfig; + readonly showdown: ShowdownOutcome | null; + readonly finalPlacements: readonly PokerGamePlacement[] | null; +} + +export interface PrivatePlayerView extends PublicTableView { + readonly viewerSeat: PokerSeatId; + readonly yourHoleCards: readonly Card[]; + readonly legalActions: PokerLegalActions; + readonly yourTurn: boolean; +} diff --git a/src/lib/minigames/poker/views.ts b/src/lib/minigames/poker/views.ts new file mode 100644 index 0000000..2c4e7d7 --- /dev/null +++ b/src/lib/minigames/poker/views.ts @@ -0,0 +1,85 @@ +/** + * Projection helpers that turn the full table state into safe views. + * + * The full state (including every hole card and the deck order) must never + * leave the server; clients only ever receive these views. Hole cards are + * hidden except for the viewer's own seats and for live hands at showdown. + */ + +import { getLegalActions } from "./betting"; +import type { + Card, + PokerSeatId, + PokerTableState, + PrivatePlayerView, + PublicPlayerView, + PublicTableView, +} from "./types"; + +function sumCommitted(state: PokerTableState): number { + return state.players.reduce((total, player) => total + player.committed, 0); +} + +function projectPlayer( + state: PokerTableState, + player: PokerTableState["players"][number], + viewerSeat: PokerSeatId | null, +): PublicPlayerView { + const isViewer = viewerSeat !== null && player.seatId === viewerSeat; + const reveal = (state.phase === "showdown" && !player.folded) || isViewer; + const holeCards: readonly Card[] | null = reveal && player.holeCards.length > 0 + ? player.holeCards + : null; + return { + seatId: player.seatId, + name: player.name, + stack: player.stack, + committed: player.committed, + totalCommitted: player.totalCommitted, + folded: player.folded, + allIn: player.allIn, + hasActed: player.hasActed, + eliminated: player.eliminated, + holeCards, + cardCount: player.holeCards.length, + }; +} + +/** + * Public view of the table. Pass `viewerSeat` to also reveal that seat's own + * hole cards; pass null for a fully spectator-safe view. + */ +export function buildPublicView( + state: PokerTableState, + viewerSeat: PokerSeatId | null, +): PublicTableView { + return { + players: state.players.map((player) => projectPlayer(state, player, viewerSeat)), + buttonSeat: state.buttonSeat, + smallBlindSeat: state.smallBlindSeat, + bigBlindSeat: state.bigBlindSeat, + street: state.street, + phase: state.phase, + potTotal: state.collectedPot + sumCommitted(state), + communityCards: state.communityCards, + currentBet: state.currentBet, + toActSeat: state.toActSeat, + handNumber: state.handNumber, + config: state.config, + showdown: state.showdown, + finalPlacements: state.finalPlacements, + }; +} + +/** Everything one seated player is allowed to know, including their options. */ +export function buildPrivateView(state: PokerTableState, seatId: PokerSeatId): PrivatePlayerView { + const viewer = state.players[seatId]; + const publicView = buildPublicView(state, seatId); + return { + ...publicView, + viewerSeat: seatId, + yourHoleCards: viewer?.holeCards ?? [], + legalActions: getLegalActions(state, seatId), + yourTurn: state.phase === "betting" && state.toActSeat === seatId, + }; +} diff --git a/tests/int/poker-engine.int.spec.ts b/tests/int/poker-engine.int.spec.ts new file mode 100644 index 0000000..e26b58c --- /dev/null +++ b/tests/int/poker-engine.int.spec.ts @@ -0,0 +1,764 @@ +/** + * Pure engine tests for the No-Limit Texas Hold'em rules engine. + * + * These are intentionally database-free: no Payload, no Next.js imports, no + * setup or teardown. Everything runs against the pure modules under + * src/lib/minigames/poker with injected, deterministic randomness. + */ + +import { describe, expect, it } from "vitest"; +import { + BIG_BLIND, + SMALL_BLIND, + STARTING_STACK, + TURN_TIMEOUT_MS, + applyAction, + advanceState, + buildPrivateView, + buildPublicView, + compareHandRanks, + createDeck, + createShuffledDeck, + createTableState, + evaluateHand, + getLegalActions, + mulberry32, + resolveShowdown, + type Card, + type PokerActionResult, + type PokerSeatId, + type PokerTableState, + type Rank, + type Suit, +} from "@/lib/minigames/poker"; + +const c = (rank: Rank, suit: Suit): Card => ({ rank, suit }); + +function makeTable( + names: string[], + stacks?: number[], + seed = 1, +): PokerTableState { + const players = names.map((name, index) => ({ + name, + stack: stacks?.[index], + })); + return createTableState(players, {}, mulberry32(seed)); +} + +function act( + state: PokerTableState, + seatId: PokerSeatId, + action: Parameters[2], + amount?: number, +): PokerTableState { + const result = applyAction(state, seatId, action, amount); + if (!result.success) throw new Error(`Unexpected action failure: ${result.error}`); + return result.state; +} + +function expectFailure( + state: PokerTableState, + seatId: PokerSeatId, + action: Parameters[2], + amount?: number, + messagePart?: string, +): void { + const result = applyAction(state, seatId, action, amount); + expect(result.success).toBe(false); + if (!result.success && messagePart) { + expect(result.error).toContain(messagePart); + } +} + +/** Minimal hand-built river state for direct showdown resolution tests. */ +function showdownState( + players: ReadonlyArray<{ hole: Card[]; committed: number; folded?: boolean }>, + community: Card[], + buttonSeat = 0, +): PokerTableState { + const totalCommitted = players.reduce((sum, p) => sum + p.committed, 0); + return { + config: { startingStack: 1000, smallBlind: 10, bigBlind: 20, turnTimeoutMs: TURN_TIMEOUT_MS }, + seed: 0, + handNumber: 1, + players: players.map((p, index) => ({ + seatId: index, + name: `P${index}`, + stack: 1000 - p.committed, + committed: 0, + totalCommitted: p.committed, + holeCards: p.hole, + folded: p.folded ?? false, + allIn: false, + hasActed: true, + eliminated: false, + })), + buttonSeat, + smallBlindSeat: 1, + bigBlindSeat: 2, + street: "river", + phase: "betting", + collectedPot: totalCommitted, + communityCards: community, + deck: [], + currentBet: 0, + minRaise: 20, + lastFullRaiseBet: 0, + toActSeat: null, + showdown: null, + finalPlacements: null, + }; +} + +/** + * Deterministic driver: check when free, raise to 100 (5 BB) when raising is + * legal, call moderate bets, fold to oversized ones. Chips move fast enough + * to finish the game while still spanning many hands. + */ +function driveGame(seed: number, playerCount: number): PokerTableState { + const totalChips = playerCount * STARTING_STACK; + let state = createTableState( + Array.from({ length: playerCount }, (_, index) => ({ name: `Player${index + 1}` })), + {}, + mulberry32(seed), + ); + let steps = 0; + while (state.phase !== "game-complete") { + steps += 1; + if (steps > 50000) throw new Error("Game did not terminate within the step budget."); + if (state.phase === "betting" && state.toActSeat !== null) { + const seat = state.toActSeat; + const legal = getLegalActions(state, seat); + let result: PokerActionResult; + if (legal.canCheck) { + result = applyAction(state, seat, "check"); + } else if (legal.canRaise && state.currentBet < 60) { + const target = Math.min(Math.max(legal.minRaiseTo, 100), legal.maxRaiseTo); + result = applyAction(state, seat, "raise", target); + } else if (legal.canCall && legal.callAmount <= 300) { + result = applyAction(state, seat, "call"); + } else { + result = applyAction(state, seat, "fold"); + } + if (!result.success) throw new Error(`Driver action failed: ${result.error}`); + state = result.state; + } else { + state = advanceState(state); + } + if (state.phase === "showdown" || state.phase === "hand-complete") { + const stackSum = state.players.reduce((sum, player) => sum + player.stack, 0); + expect(stackSum).toBe(totalChips); + } + } + return state; +} + +describe("deck", () => { + it("creates a 52-card deck with no duplicates", () => { + const deck = createDeck(); + expect(deck).toHaveLength(52); + const keys = new Set(deck.map((card) => `${card.rank}${card.suit}`)); + expect(keys.size).toBe(52); + }); + + it("shuffles deterministically for a given seed and hand number", () => { + const a = createShuffledDeck(1234, 1); + const b = createShuffledDeck(1234, 1); + const otherHand = createShuffledDeck(1234, 2); + const otherSeed = createShuffledDeck(1235, 1); + expect(a).toEqual(b); + expect(a).not.toEqual(otherHand); + expect(a).not.toEqual(otherSeed); + expect(a).toHaveLength(52); + expect(new Set(a.map((card) => `${card.rank}${card.suit}`)).size).toBe(52); + }); +}); + +describe("hand evaluator", () => { + it("recognizes a royal flush as an ace-high straight flush", () => { + const evaluation = evaluateHand([ + c(14, "s"), c(13, "s"), c(12, "s"), c(11, "s"), c(10, "s"), + ]); + expect(evaluation.rank.category).toBe(8); + expect(evaluation.rank.tiebreakers).toEqual([14]); + }); + + it("recognizes a wheel straight flush as five high", () => { + const evaluation = evaluateHand([c(14, "h"), c(2, "h"), c(3, "h"), c(4, "h"), c(5, "h")]); + expect(evaluation.rank.category).toBe(8); + expect(evaluation.rank.tiebreakers).toEqual([5]); + }); + + it("finds the best 5 of 7 for a straight flush over a flush and a pair", () => { + const evaluation = evaluateHand([ + c(9, "h"), c(10, "h"), c(11, "h"), c(12, "h"), c(13, "h"), c(14, "h"), c(2, "c"), + ]); + expect(evaluation.rank.category).toBe(8); + expect(evaluation.rank.tiebreakers).toEqual([14]); + expect(evaluation.best).toHaveLength(5); + }); + + it("scores quads with the best kicker from seven cards", () => { + const evaluation = evaluateHand([ + c(9, "c"), c(9, "d"), c(9, "h"), c(9, "s"), c(14, "c"), c(2, "d"), c(3, "c"), + ]); + expect(evaluation.rank.category).toBe(7); + expect(evaluation.rank.tiebreakers).toEqual([9, 14]); + }); + + it("scores a full house choosing the highest trips and pair", () => { + const evaluation = evaluateHand([ + c(9, "c"), c(9, "d"), c(9, "h"), c(14, "c"), c(14, "d"), c(5, "d"), c(5, "c"), + ]); + expect(evaluation.rank.category).toBe(6); + expect(evaluation.rank.tiebreakers).toEqual([9, 14]); + }); + + it("picks the best five cards for a flush from seven", () => { + const evaluation = evaluateHand([ + c(14, "h"), c(9, "h"), c(5, "h"), c(2, "h"), c(11, "h"), c(3, "c"), c(3, "d"), + ]); + expect(evaluation.rank.category).toBe(5); + expect(evaluation.rank.tiebreakers).toEqual([14, 11, 9, 5, 2]); + }); + + it("finds a straight in seven cards that also contain a pair", () => { + const evaluation = evaluateHand([ + c(5, "c"), c(6, "d"), c(7, "h"), c(8, "s"), c(9, "c"), c(9, "d"), c(2, "c"), + ]); + expect(evaluation.rank.category).toBe(4); + expect(evaluation.rank.tiebreakers).toEqual([9]); + }); + + it("scores the wheel as a five-high straight, not ace high", () => { + const evaluation = evaluateHand([ + c(14, "c"), c(2, "d"), c(3, "h"), c(4, "s"), c(5, "c"), c(13, "h"), c(13, "d"), + ]); + expect(evaluation.rank.category).toBe(4); + expect(evaluation.rank.tiebreakers).toEqual([5]); + }); + + it("breaks two pair ties with the fifth card kicker", () => { + const queenKicker = evaluateHand([ + c(14, "h"), c(14, "d"), c(13, "c"), c(13, "d"), c(12, "h"), c(2, "c"), c(3, "d"), + ]); + const jackKicker = evaluateHand([ + c(14, "s"), c(14, "c"), c(13, "h"), c(13, "s"), c(11, "h"), c(2, "d"), c(3, "c"), + ]); + expect(queenKicker.rank.category).toBe(2); + expect(compareHandRanks(queenKicker.rank, jackKicker.rank)).toBeGreaterThan(0); + }); + + it("breaks one pair ties through all three kickers in order", () => { + const queenKicker = evaluateHand([c(9, "h"), c(9, "d"), c(14, "c"), c(13, "c"), c(12, "c")]); + const jackKicker = evaluateHand([c(9, "s"), c(9, "c"), c(14, "d"), c(13, "d"), c(11, "d")]); + expect(compareHandRanks(queenKicker.rank, jackKicker.rank)).toBeGreaterThan(0); + }); + + it("ties identical ranks across suits, flushes included", () => { + const spades = evaluateHand([c(14, "s"), c(13, "s"), c(12, "s"), c(11, "s"), c(9, "s")]); + const hearts = evaluateHand([c(14, "h"), c(13, "h"), c(12, "h"), c(11, "h"), c(9, "h")]); + expect(compareHandRanks(spades.rank, hearts.rank)).toBe(0); + }); + + it("orders categories from high card to straight flush", () => { + const high = evaluateHand([c(14, "c"), c(13, "d"), c(11, "h"), c(9, "s"), c(4, "c")]); + const pair = evaluateHand([c(4, "h"), c(4, "d"), c(14, "c"), c(13, "d"), c(11, "h")]); + const twoPair = evaluateHand([c(14, "h"), c(14, "d"), c(13, "c"), c(13, "d"), c(11, "h")]); + const trips = evaluateHand([c(14, "h"), c(14, "d"), c(14, "c"), c(13, "d"), c(11, "h")]); + const straight = evaluateHand([c(14, "c"), c(13, "d"), c(12, "h"), c(11, "s"), c(10, "c")]); + const flush = evaluateHand([c(14, "c"), c(12, "c"), c(10, "c"), c(8, "c"), c(6, "c")]); + const fullHouse = evaluateHand([c(3, "h"), c(3, "d"), c(3, "c"), c(2, "d"), c(2, "h")]); + const quads = evaluateHand([c(3, "h"), c(3, "d"), c(3, "c"), c(3, "s"), c(2, "h")]); + const straightFlush = evaluateHand([c(9, "c"), c(8, "c"), c(7, "c"), c(6, "c"), c(5, "c")]); + const ordered = [high, pair, twoPair, trips, straight, flush, fullHouse, quads, straightFlush]; + for (let i = 0; i < ordered.length - 1; i += 1) { + const lower = ordered[i]; + const higher = ordered[i + 1]; + expect(lower && higher && compareHandRanks(lower.rank, higher.rank)).toBeLessThan(0); + } + }); + + it("rejects inputs that are not five to seven cards", () => { + expect(() => evaluateHand([c(2, "s"), c(3, "s"), c(4, "s"), c(5, "s")])).toThrow(); + }); +}); + +describe("table creation and blinds", () => { + it("rejects tables outside the 2 to 4 player range", () => { + expect(() => createTableState([{ name: "Solo" }], {}, mulberry32(1))).toThrow(); + expect(() => + createTableState( + [{ name: "A" }, { name: "B" }, { name: "C" }, { name: "D" }, { name: "E" }], + {}, + mulberry32(1), + ), + ).toThrow(); + }); + + it("posts blinds correctly for a 4-handed table and sets UTG action", () => { + const state = makeTable(["A", "B", "C", "D"]); + expect(state.handNumber).toBe(1); + expect(state.buttonSeat).toBe(0); + expect(state.smallBlindSeat).toBe(1); + expect(state.bigBlindSeat).toBe(2); + expect(state.players[1].committed).toBe(SMALL_BLIND); + expect(state.players[2].committed).toBe(BIG_BLIND); + expect(state.players[1].stack).toBe(STARTING_STACK - SMALL_BLIND); + expect(state.players[2].stack).toBe(STARTING_STACK - BIG_BLIND); + expect(state.currentBet).toBe(BIG_BLIND); + expect(state.minRaise).toBe(BIG_BLIND); + expect(state.toActSeat).toBe(3); + expect(state.street).toBe("preflop"); + expect(state.phase).toBe("betting"); + expect(state.config.turnTimeoutMs).toBe(TURN_TIMEOUT_MS); + }); + + it("gives every player two hole cards and keeps the deck consistent", () => { + const state = makeTable(["A", "B", "C", "D"]); + for (const player of state.players) { + expect(player.holeCards).toHaveLength(2); + } + // 52 deck minus 8 dealt cards. + expect(state.deck).toHaveLength(44); + const allCards = [ + ...state.players.flatMap((player) => player.holeCards), + ...state.deck, + ]; + expect(allCards).toHaveLength(52); + }); + + it("does not mutate the state passed to applyAction", () => { + const state = makeTable(["A", "B"]); + const snapshot = JSON.parse(JSON.stringify(state)) as PokerTableState; + act(state, 0, "call"); + expect(JSON.parse(JSON.stringify(state))).toEqual(snapshot); + }); +}); + +describe("heads-up button and blind quirks", () => { + it("has the button post the small blind and act first preflop", () => { + const state = makeTable(["Button", "Other"]); + expect(state.buttonSeat).toBe(0); + expect(state.smallBlindSeat).toBe(0); + expect(state.bigBlindSeat).toBe(1); + expect(state.players[0].committed).toBe(SMALL_BLIND); + expect(state.players[1].committed).toBe(BIG_BLIND); + expect(state.toActSeat).toBe(0); + }); + + it("has the non-button act first and the button act last postflop", () => { + let state = makeTable(["Button", "Other"]); + state = act(state, 0, "call"); + expect(state.toActSeat).toBe(1); + state = act(state, 1, "check"); + expect(state.street).toBe("flop"); + expect(state.toActSeat).toBe(1); + state = act(state, 1, "check"); + expect(state.toActSeat).toBe(0); + state = act(state, 0, "check"); + expect(state.street).toBe("turn"); + expect(state.toActSeat).toBe(1); + state = act(state, 1, "check"); + state = act(state, 0, "check"); + expect(state.street).toBe("river"); + expect(state.toActSeat).toBe(1); + // River action resolves to a showdown with the full board. + state = act(state, 1, "bet", 50); + state = act(state, 0, "call"); + expect(state.phase).toBe("showdown"); + expect(state.communityCards).toHaveLength(5); + expect(state.showdown).not.toBeNull(); + expect(state.showdown?.pots[0]?.amount).toBe(140); + }); +}); + +describe("legal betting: min-raise and re-raise", () => { + it("enforces minimum bet and minimum raise sizes", () => { + let state = makeTable(["A", "B", "C"]); + const legal = getLegalActions(state, 0); + expect(legal.canRaise).toBe(true); + expect(legal.minRaiseTo).toBe(40); + expect(legal.maxRaiseTo).toBe(STARTING_STACK); + + expectFailure(state, 0, "raise", 30, "minimum raise"); + state = act(state, 0, "raise", 40); + expect(state.currentBet).toBe(40); + expect(state.minRaise).toBe(20); + + expectFailure(state, 1, "raise", 50, "minimum raise"); + state = act(state, 1, "raise", 60); + expect(state.currentBet).toBe(60); + expect(state.minRaise).toBe(20); + + state = act(state, 2, "call"); + state = act(state, 0, "call"); + expect(state.street).toBe("flop"); + expect(state.collectedPot).toBe(180); + expect(state.currentBet).toBe(0); + expect(state.minRaise).toBe(BIG_BLIND); + + // Postflop: opening bet of 20, then a raise must go to at least 40. + const betLegal = getLegalActions(state, 1); + expect(betLegal.canBet).toBe(true); + expect(betLegal.minBetTo).toBe(20); + expectFailure(state, 1, "bet", 15, "minimum bet"); + state = act(state, 1, "bet", 20); + expectFailure(state, 2, "raise", 35, "minimum raise"); + state = act(state, 2, "raise", 40); + state = act(state, 0, "fold"); + state = act(state, 1, "call"); + expect(state.street).toBe("turn"); + expect(state.toActSeat).toBe(1); + }); + + it("rejects illegal actions: out of turn, check facing a bet, bet over a bet, over-shove", () => { + let state = makeTable(["A", "B", "C"]); + expectFailure(state, 1, "call", undefined, "turn"); + state = act(state, 0, "raise", 40); + expectFailure(state, 1, "check", undefined, "Cannot check"); + expectFailure(state, 1, "bet", 80, "raise instead"); + expectFailure(state, 1, "raise", 2000, "exceeds your stack"); + expectFailure(state, 9, "fold", undefined, "turn"); + }); + + it("rejects actions once the game is complete", () => { + const state = driveGame(42, 2); + expect(state.phase).toBe("game-complete"); + expectFailure(state, 0, "check", undefined, "not accepting actions"); + expect(advanceState(state)).toEqual(state); + }); +}); + +describe("short all-in raises do not reopen betting", () => { + it("lets a short all-in stand but blocks re-raises from players who acted", () => { + let state = makeTable(["A", "B", "C"], [1000, 1000, 150]); + state = act(state, 0, "raise", 100); + expect(state.minRaise).toBe(80); + state = act(state, 1, "call"); + // C has 130 behind plus 20 posted: all-in to 150 is a raise of only 50. + state = act(state, 2, "raise", 150); + expect(state.currentBet).toBe(150); + expect(state.minRaise).toBe(80); + expect(state.lastFullRaiseBet).toBe(100); + + const legalA = getLegalActions(state, 0); + expect(legalA.canRaise).toBe(false); + expect(legalA.canCall).toBe(true); + expect(legalA.callAmount).toBe(50); + expectFailure(state, 0, "raise", 230, "Raising is not available"); + + state = act(state, 0, "call"); + const legalB = getLegalActions(state, 1); + expect(legalB.canRaise).toBe(false); + // B folds: the two live stacks (A matched 150, C all-in at 150) plus B's + // dead 100 go to a single showdown pot of 400. + state = act(state, 1, "fold"); + + expect(state.phase).toBe("showdown"); + expect(state.showdown?.pots).toHaveLength(1); + expect(state.showdown?.pots[0]?.amount).toBe(400); + const stackSum = state.players.reduce((sum, player) => sum + player.stack, 0); + expect(stackSum).toBe(2150); + }); + + it("still allows a full-sized all-in raise to reopen the betting", () => { + let state = makeTable(["A", "B", "C"], [1000, 1000, 400]); + state = act(state, 0, "raise", 100); + state = act(state, 1, "call"); + // C all-in to 400: increment 300 over 100, well above the 80 min-raise. + state = act(state, 2, "raise", 400); + expect(state.currentBet).toBe(400); + expect(state.minRaise).toBe(300); + expect(state.lastFullRaiseBet).toBe(400); + const legalA = getLegalActions(state, 0); + expect(legalA.canRaise).toBe(true); + expect(legalA.minRaiseTo).toBe(700); + }); +}); + +describe("side pots", () => { + it("builds layered side pots with exact chip math on a 3-way all-in", () => { + let state = makeTable(["A", "B", "C"], [1000, 500, 200]); + state = act(state, 0, "raise", 500); + state = act(state, 1, "call"); // all-in for 500 + state = act(state, 2, "call"); // all-in for 200 + + expect(state.phase).toBe("showdown"); + const pots = state.showdown?.pots ?? []; + expect(pots).toHaveLength(2); + expect(pots[0]?.amount).toBe(600); + expect([...(pots[0]?.eligibleSeats ?? [])].sort()).toEqual([0, 1, 2]); + expect(pots[1]?.amount).toBe(600); + expect([...(pots[1]?.eligibleSeats ?? [])].sort()).toEqual([0, 1]); + + // Expected winners derived independently from the evaluator. + const evaluations = [0, 1, 2].map((seat) => + evaluateHand([ + ...(state.players[seat]?.holeCards ?? []), + ...state.communityCards, + ]), + ); + for (let i = 0; i < evaluations.length - 1; i += 1) { + const a = evaluations[i]; + const b = evaluations[i + 1]; + expect(a && b && compareHandRanks(a.rank, b.rank)).not.toBe(0); + } + const bestOf = (seats: number[]): number => + seats.reduce((best, seat) => { + const bestEval = evaluations[best]; + const seatEval = evaluations[seat]; + if (bestEval && seatEval && compareHandRanks(seatEval.rank, bestEval.rank) > 0) { + return seat; + } + return best; + }, seats[0] ?? 0); + const mainWinner = bestOf([0, 1, 2]); + const sideWinner = bestOf([0, 1]); + const expectedWon = [0, 0, 0]; + expectedWon[mainWinner] += 600; + expectedWon[sideWinner] += 600; + + const results = state.showdown?.results ?? []; + for (const seat of [0, 1, 2]) { + const result = results[seat]; + expect(result?.chipsWon).toBe(expectedWon[seat]); + expect(result?.netChange).toBe(expectedWon[seat] - (state.players[seat]?.totalCommitted ?? 0)); + expect(result?.handRank).toEqual(evaluations[seat]?.rank); + } + expect(pots[0]?.winnerSeats).toEqual([mainWinner]); + expect(pots[1]?.winnerSeats).toEqual([sideWinner]); + + const stacks = state.players.map((player) => player.stack); + expect(stacks[0]).toBe(500 + expectedWon[0]); + expect(stacks[1]).toBe(expectedWon[1]); + expect(stacks[2]).toBe(expectedWon[2]); + expect(stacks.reduce((sum, value) => sum + value, 0)).toBe(1700); + }); + + it("splits a pot between tied hands and ranks the loser below", () => { + const community = [c(2, "h"), c(2, "d"), c(7, "c"), c(9, "s"), c(3, "d")]; + const state = showdownState( + [ + { hole: [c(14, "h"), c(12, "d")], committed: 100 }, + { hole: [c(14, "s"), c(12, "c")], committed: 100 }, + { hole: [c(14, "d"), c(11, "c")], committed: 100 }, + ], + community, + ); + const outcome = resolveShowdown(state); + expect(outcome.pots).toHaveLength(1); + expect(outcome.pots[0]?.amount).toBe(300); + expect([...(outcome.pots[0]?.winnerSeats ?? [])].sort()).toEqual([0, 1]); + const results = outcome.results; + expect(results[0]?.chipsWon).toBe(150); + expect(results[1]?.chipsWon).toBe(150); + expect(results[2]?.chipsWon).toBe(0); + expect(results[0]?.netChange).toBe(50); + expect(results[1]?.netChange).toBe(50); + expect(results[2]?.netChange).toBe(-100); + expect(results[0]?.placement).toBe(1); + expect(results[1]?.placement).toBe(1); + expect(results[2]?.placement).toBe(3); + }); + + it("gives the odd chip to the first winner left of the button", () => { + const community = [c(2, "h"), c(2, "d"), c(7, "c"), c(9, "s"), c(3, "d")]; + const state = showdownState( + [ + { hole: [c(14, "h"), c(12, "d")], committed: 100 }, + { hole: [c(14, "s"), c(12, "c")], committed: 100 }, + { hole: [c(14, "c"), c(12, "h")], committed: 100 }, + { hole: [c(14, "d"), c(11, "c")], committed: 100 }, + ], + community, + 0, + ); + const outcome = resolveShowdown(state); + expect(outcome.pots[0]?.amount).toBe(400); + // Three-way tie over a 400 pot: 133 each plus one odd chip, paid to the + // first winner left of the button (seat 1 when the button is seat 0). + expect(outcome.results[1]?.chipsWon).toBe(134); + expect(outcome.results[2]?.chipsWon).toBe(133); + expect(outcome.results[0]?.chipsWon).toBe(133); + expect(outcome.results[3]?.chipsWon).toBe(0); + }); + + it("compares kickers when one player outkicks a tied pair", () => { + const community = [c(2, "h"), c(2, "d"), c(7, "c"), c(9, "s"), c(3, "d")]; + const state = showdownState( + [ + { hole: [c(14, "h"), c(12, "d")], committed: 100 }, + { hole: [c(14, "s"), c(11, "c")], committed: 100 }, + ], + community, + ); + const outcome = resolveShowdown(state); + expect(outcome.pots[0]?.winnerSeats).toEqual([0]); + expect(outcome.results[0]?.chipsWon).toBe(200); + expect(outcome.results[1]?.chipsWon).toBe(0); + expect(outcome.results[0]?.placement).toBe(1); + expect(outcome.results[1]?.placement).toBe(2); + }); +}); + +describe("fold-out and winner-takes-all", () => { + it("awards the pot to the last live player when everyone else folds", () => { + let state = makeTable(["A", "B", "C"]); + state = act(state, 0, "fold"); + state = act(state, 1, "fold"); + expect(state.phase).toBe("hand-complete"); + expect(state.toActSeat).toBeNull(); + expect(state.showdown?.pots[0]?.amount).toBe(30); + expect(state.showdown?.pots[0]?.winnerSeats).toEqual([2]); + expect(state.players.map((player) => player.stack)).toEqual([1000, 990, 1010]); + const results = state.showdown?.results ?? []; + expect(results.map((result) => result.netChange)).toEqual([0, -10, 10]); + expect(results.map((result) => result.placement)).toEqual([2, 2, 1]); + // Hole cards stay hidden on a fold-out even in the public view. + const view = buildPublicView(state, null); + expect(view.players.every((player) => player.holeCards === null)).toBe(true); + }); + + it("runs a heads-up all-in to a winner-takes-all showdown", () => { + let state = makeTable(["A", "B"]); + state = act(state, 0, "raise", 1000); + state = act(state, 1, "call"); + expect(state.phase).toBe("showdown"); + expect(state.showdown?.pots[0]?.amount).toBe(2000); + + const evaluations = [0, 1].map((seat) => + evaluateHand([ + ...(state.players[seat]?.holeCards ?? []), + ...state.communityCards, + ]), + ); + const first = evaluations[0]; + const second = evaluations[1]; + expect(first && second && compareHandRanks(first.rank, second.rank)).not.toBe(0); + const winner = first && second && compareHandRanks(first.rank, second.rank) > 0 ? 0 : 1; + expect(state.showdown?.pots[0]?.winnerSeats).toEqual([winner]); + expect(state.players[winner]?.stack).toBe(2000); + expect(state.players[1 - winner]?.stack).toBe(0); + + const results = state.showdown?.results ?? []; + expect(results[winner]?.netChange).toBe(1000); + expect(results[1 - winner]?.netChange).toBe(-1000); + expect(results[winner]?.placement).toBe(1); + + // The busted player is eliminated and the game ends on the next advance. + state = advanceState(state); + expect(state.phase).toBe("game-complete"); + expect(state.finalPlacements).not.toBeNull(); + expect(state.finalPlacements?.find((p) => p.seatId === winner)?.placement).toBe(1); + expect(state.finalPlacements?.find((p) => p.seatId === 1 - winner)?.placement).toBe(2); + }); +}); + +describe("views", () => { + it("hides hole cards in public views except the viewer's own", () => { + const state = makeTable(["A", "B", "C"]); + const spectator = buildPublicView(state, null); + expect(spectator.players.every((player) => player.holeCards === null)).toBe(true); + expect(spectator.players.every((player) => player.cardCount === 2)).toBe(true); + + const seat0 = buildPublicView(state, 0); + expect(seat0.players[0]?.holeCards).toEqual(state.players[0]?.holeCards); + expect(seat0.players[1]?.holeCards).toBeNull(); + }); + + it("exposes legal actions and turn state in the private view", () => { + const state = makeTable(["A", "B", "C"]); + const view = buildPrivateView(state, 0); + expect(view.viewerSeat).toBe(0); + expect(view.yourHoleCards).toEqual(state.players[0]?.holeCards); + expect(view.yourTurn).toBe(true); + expect(view.legalActions.canCall).toBe(true); + // Seat 0 is the button and UTG preflop in a 3-handed game: no blind + // posted yet, so a full call costs the big blind. + expect(view.legalActions.callAmount).toBe(BIG_BLIND); + expect(view.legalActions.canRaise).toBe(true); + + const notTurn = buildPrivateView(state, 1); + expect(notTurn.yourTurn).toBe(false); + expect(notTurn.legalActions.canCheck).toBe(false); + expect(notTurn.legalActions.canFold).toBe(false); + }); + + it("reveals live hands to everyone at showdown", () => { + let state = makeTable(["A", "B"]); + state = act(state, 0, "raise", 1000); + state = act(state, 1, "call"); + expect(state.phase).toBe("showdown"); + const view = buildPublicView(state, null); + for (const player of view.players) { + expect(player.holeCards).toEqual(state.players[player.seatId]?.holeCards); + } + }); +}); + +describe("state progression", () => { + it("is a no-op while a betting round is still open", () => { + const state = makeTable(["A", "B", "C"]); + expect(advanceState(state)).toEqual(state); + }); + + it("starts the next hand with the button moved and busted players out", () => { + let state = makeTable(["A", "B", "C"], [1000, 1000, 30]); + // C posted the big blind from a 30 stack and has only 10 behind, so the + // 500 raise puts them all-in for 30 total. A and B still have live stacks + // and continue betting against each other on later streets. + state = act(state, 0, "raise", 500); + state = act(state, 1, "call"); + state = act(state, 2, "call"); + expect(state.street).toBe("flop"); + state = act(state, 1, "fold"); + state = act(state, 0, "check"); + expect(state.phase).toBe("showdown"); + + const bustedAfterHand = state.players.filter((player) => player.stack <= 0).length; + state = advanceState(state); + expect(state.phase).toBe("betting"); + expect(state.handNumber).toBe(2); + expect(state.players.filter((player) => player.eliminated)).toHaveLength(bustedAfterHand); + const eligible = state.players.filter((player) => !player.eliminated); + for (const seat of [state.buttonSeat, state.smallBlindSeat, state.bigBlindSeat]) { + expect(eligible.some((player) => player.seatId === seat)).toBe(true); + } + if (eligible.length === 2) { + expect(state.smallBlindSeat).toBe(state.buttonSeat); + } + }); +}); + +describe("full scripted games", () => { + it("plays a 4-handed game to a single winner with exact chip conservation", () => { + const state = driveGame(42, 4); + expect(state.phase).toBe("game-complete"); + expect(state.finalPlacements).not.toBeNull(); + const stacks = state.players.map((player) => player.stack); + expect(stacks.reduce((sum, value) => sum + value, 0)).toBe(4000); + const winners = state.players.filter((player) => player.stack > 0); + expect(winners).toHaveLength(1); + expect(winners[0]?.stack).toBe(4000); + const winnerPlacement = state.finalPlacements?.find( + (p) => p.seatId === winners[0]?.seatId, + ); + expect(winnerPlacement?.placement).toBe(1); + const placements = (state.finalPlacements ?? []).map((p) => p.placement); + expect(placements[0]).toBe(1); + expect(state.handNumber).toBeGreaterThan(1); + }); + + it("plays a 3-handed game to a single winner from a different seed", () => { + const state = driveGame(1337, 3); + expect(state.phase).toBe("game-complete"); + const stacks = state.players.map((player) => player.stack); + expect(stacks.reduce((sum, value) => sum + value, 0)).toBe(3000); + const winners = state.players.filter((player) => player.stack > 0); + expect(winners).toHaveLength(1); + expect(winners[0]?.stack).toBe(3000); + expect(state.finalPlacements).toHaveLength(3); + }); +}); diff --git a/tests/int/poker-hand-hint.int.spec.ts b/tests/int/poker-hand-hint.int.spec.ts new file mode 100644 index 0000000..50eea4d --- /dev/null +++ b/tests/int/poker-hand-hint.int.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { describeHandStrength } from "@/lib/minigames/poker/handHint"; +import type { Card, Rank, Suit } from "@/lib/minigames/poker"; + +const c = (rank: Rank, suit: Suit): Card => ({ rank, suit }); + +describe("poker hand strength hints", () => { + it("describes a preflop pair", () => { + expect(describeHandStrength([c(13, "s"), c(13, "h")], [])).toEqual({ + label: "Pair of Kings", + bestCardKeys: ["13-s", "13-h"], + }); + }); + + it("describes a preflop high card", () => { + expect(describeHandStrength([c(14, "s"), c(9, "h")], [])).toEqual({ + label: "High card Ace", + bestCardKeys: ["14-s"], + }); + }); + + it("describes flop two pair and returns the exact best cards", () => { + const hint = describeHandStrength([c(13, "s"), c(9, "h")], [c(13, "d"), c(9, "c"), c(2, "s")]); + + expect(hint?.label).toBe("Two pair, Kings and Nines"); + expect(new Set(hint?.bestCardKeys)).toEqual(new Set(["13-s", "9-h", "13-d", "9-c", "2-s"])); + }); + + it("describes a river straight that uses one hole card", () => { + const hint = describeHandStrength( + [c(6, "s"), c(14, "h")], + [c(2, "c"), c(3, "d"), c(4, "h"), c(5, "s"), c(13, "c")], + ); + + expect(hint?.label).toBe("Straight, Six high"); + expect(new Set(hint?.bestCardKeys)).toEqual(new Set(["6-s", "2-c", "3-d", "4-h", "5-s"])); + }); + + it("labels a wheel straight as five high", () => { + expect( + describeHandStrength( + [c(14, "s"), c(13, "h")], + [c(2, "c"), c(3, "d"), c(4, "h"), c(5, "s"), c(9, "c")], + )?.label, + ).toBe("Straight, Five high"); + }); +}); diff --git a/tests/int/poker-stats.int.spec.ts b/tests/int/poker-stats.int.spec.ts new file mode 100644 index 0000000..5455bdb --- /dev/null +++ b/tests/int/poker-stats.int.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { + computePokerStatsDelta, + readPokerStats, + xpForPlacement, + type PokerPlacementResult, + type PokerStats, +} from "@/lib/minigames/poker/stats"; + +function result(overrides: Partial = {}): PokerPlacementResult { + return { userId: 1, placement: 1, finalChips: 5000, handsWon: 3, ...overrides }; +} + +function current(overrides: Partial = {}): PokerStats { + return { + gamesPlayed: 0, + wins: 0, + secondPlaces: 0, + thirdPlaces: 0, + fourthPlaces: 0, + handsWon: 0, + bestScore: 0, + xpEarned: 0, + ...overrides, + }; +} + +describe("xpForPlacement", () => { + it("scales 1st place at 40x the player count", () => { + expect(xpForPlacement(1, 2)).toBe(80); + expect(xpForPlacement(1, 3)).toBe(120); + expect(xpForPlacement(1, 4)).toBe(160); + }); + + it("scales 2nd place at 20x the player count", () => { + expect(xpForPlacement(2, 2)).toBe(40); + expect(xpForPlacement(2, 3)).toBe(60); + expect(xpForPlacement(2, 4)).toBe(80); + }); + + it("scales 3rd place at 10x the player count", () => { + expect(xpForPlacement(3, 2)).toBe(20); + expect(xpForPlacement(3, 3)).toBe(30); + expect(xpForPlacement(3, 4)).toBe(40); + }); + + it("scales 4th place at 5x the player count", () => { + expect(xpForPlacement(4, 2)).toBe(10); + expect(xpForPlacement(4, 3)).toBe(15); + expect(xpForPlacement(4, 4)).toBe(20); + }); +}); + +describe("computePokerStatsDelta", () => { + it("increments gamesPlayed by exactly one", () => { + const next = computePokerStatsDelta(current({ gamesPlayed: 7 }), result(), 4); + expect(next.gamesPlayed).toBe(8); + }); + + it("increments the wins counter for 1st place only", () => { + const next = computePokerStatsDelta(current({ wins: 2 }), result({ placement: 1 }), 4); + expect(next.wins).toBe(3); + expect(next.secondPlaces).toBe(0); + expect(next.thirdPlaces).toBe(0); + expect(next.fourthPlaces).toBe(0); + }); + + it("increments the secondPlaces counter for 2nd place only", () => { + const next = computePokerStatsDelta( + current({ secondPlaces: 1 }), + result({ placement: 2 }), + 4, + ); + expect(next.secondPlaces).toBe(2); + expect(next.wins).toBe(0); + expect(next.thirdPlaces).toBe(0); + expect(next.fourthPlaces).toBe(0); + }); + + it("increments the thirdPlaces counter for 3rd place only", () => { + const next = computePokerStatsDelta( + current({ thirdPlaces: 4 }), + result({ placement: 3 }), + 4, + ); + expect(next.thirdPlaces).toBe(5); + expect(next.wins).toBe(0); + expect(next.secondPlaces).toBe(0); + expect(next.fourthPlaces).toBe(0); + }); + + it("increments the fourthPlaces counter for 4th place only", () => { + const next = computePokerStatsDelta( + current({ fourthPlaces: 1 }), + result({ placement: 4 }), + 4, + ); + expect(next.fourthPlaces).toBe(2); + expect(next.wins).toBe(0); + expect(next.secondPlaces).toBe(0); + expect(next.thirdPlaces).toBe(0); + }); + + it("accumulates handsWon on top of the existing total", () => { + const next = computePokerStatsDelta( + current({ handsWon: 10 }), + result({ handsWon: 4 }), + 4, + ); + expect(next.handsWon).toBe(14); + }); + + it("keeps bestScore when the final chip total is lower", () => { + const next = computePokerStatsDelta( + current({ bestScore: 9000 }), + result({ finalChips: 5000 }), + 4, + ); + expect(next.bestScore).toBe(9000); + }); + + it("raises bestScore when the final chip total is higher", () => { + const next = computePokerStatsDelta( + current({ bestScore: 3000 }), + result({ finalChips: 7500 }), + 4, + ); + expect(next.bestScore).toBe(7500); + }); + + it("adds the placement XP to the existing xpEarned total", () => { + const next = computePokerStatsDelta( + current({ xpEarned: 100 }), + result({ placement: 2 }), + 3, + ); + expect(next.xpEarned).toBe(160); + }); + + it("does not mutate the current stats object", () => { + const before = current({ gamesPlayed: 1, wins: 1, xpEarned: 160 }); + computePokerStatsDelta(before, result(), 4); + expect(before).toEqual(current({ gamesPlayed: 1, wins: 1, xpEarned: 160 })); + }); +}); + +describe("readPokerStats", () => { + it("reads poker stats from a profile that also carries sibling game stats", () => { + const profile = { + progression: { + minigames: { + flappyBird: { gamesPlayed: 12, xpEarned: 40 }, + poker: { gamesPlayed: 3, wins: 1, xpEarned: 200 }, + }, + }, + }; + expect(readPokerStats(profile)).toEqual( + current({ gamesPlayed: 3, wins: 1, xpEarned: 200 }), + ); + }); + + it("returns all zeros when the poker group is missing", () => { + expect(readPokerStats({ progression: { minigames: {} } })).toEqual(current()); + }); + + it("returns all zeros for a nullish profile", () => { + expect(readPokerStats(undefined)).toEqual(current()); + }); +}); diff --git a/tests/int/poker-tables.int.spec.ts b/tests/int/poker-tables.int.spec.ts new file mode 100644 index 0000000..7ba6d75 --- /dev/null +++ b/tests/int/poker-tables.int.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { createTable, joinTable, startGame } from "@/lib/minigames/poker/tables"; + +/** + * Regression tests for the registry's game-start path. Guards against the + * seat-map aliasing bug that once spawned phantom "Player null" engine seats + * when a table started with fewer than four players. + */ + +describe("poker table registry: startGame", () => { + it("starts a heads-up game with exactly the two seated players", () => { + const tableId = createTable(9001, "Host"); + joinTable(tableId, 9002, "Guest"); + + const snapshot = startGame(tableId, 9001); + + const seatedIds = snapshot.seats.filter((id) => id !== null); + expect(seatedIds).toHaveLength(2); + const players = snapshot.you?.players ?? []; + expect(players).toHaveLength(2); + for (const player of players) { + expect(player.name).not.toBe("Player null"); + expect(player.name.length).toBeGreaterThan(0); + expect(player.eliminated).toBe(false); + } + }); + + it("starts a three-handed game with exactly the three seated players", () => { + const tableId = createTable(9101, "Host"); + joinTable(tableId, 9102, "Second"); + joinTable(tableId, 9103, "Third"); + + const snapshot = startGame(tableId, 9101); + + const seatedIds = snapshot.seats.filter((id) => id !== null); + expect(seatedIds).toHaveLength(3); + const players = snapshot.you?.players ?? []; + expect(players).toHaveLength(3); + const names = players.map((player) => player.name); + expect(names).not.toContain("Player null"); + expect(new Set(names).size).toBe(3); + }); + + it("keeps every engine player backed by a real seated user", () => { + const tableId = createTable(9201, "Host"); + joinTable(tableId, 9202, "Second"); + + const snapshot = startGame(tableId, 9201); + const players = snapshot.you?.players ?? []; + + for (const player of players) { + const userId = snapshot.seats[player.seatId]; + expect(userId).not.toBeNull(); + expect(typeof userId).toBe("number"); + } + }); +});