/** * 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 { Card, 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]; let queuedBoard = [...state.queuedBoard]; while (community.length < 5) { const nextCard = queuedBoard.shift() ?? deck.shift(); if (!nextCard) break; community.push(nextCard); } const withBoard: PokerTableState = { ...state, communityCards: community, deck, queuedBoard, street: "river", toActSeat: null, }; return resolveAndPay(withBoard); } /** Draw `count` board cards: dev-queued cards first, then the shuffled deck. */ function drawBoardCards( state: PokerTableState, count: number, ): { cards: Card[]; queuedBoard: readonly Card[]; deck: readonly Card[] } { const fromQueue = state.queuedBoard.slice(0, count); const fromDeck = state.deck.slice(0, count - fromQueue.length); return { cards: [...fromQueue, ...fromDeck], queuedBoard: state.queuedBoard.slice(fromQueue.length), deck: state.deck.slice(fromDeck.length), }; } 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; const draw = drawBoardCards(next, cardsToDeal); return { ...next, street, communityCards: [...next.communityCards, ...draw.cards], deck: draw.deck, queuedBoard: draw.queuedBoard, 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, queuedBoard: [], queuedHoles: {}, }; // 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); }