const crypto = require('node:crypto'); const {createDeck, evaluateBestHand, calculatePayouts} = require('./poker'); const {AppError} = require('./core'); const dict = () => Object.create(null); const live = game => game.players.filter(player => game.hands[player] && !game.folded[player]); const canAct = (game, player) => live(game).includes(player) && game.stacks[player] > 0; const callAmount = (game, player) => Math.max(0, game.currentBet - (game.playerBets[player] || 0)); const canRaise = (game, player) => game.actedAt[player] == null || game.currentBet - game.actedAt[player] >= game.minRaise; const turn = game => game.phase === 'betting' ? game.players[game.currentPlayerIndex] : null; function intermission(players, balances, config, previous = null) { return { id: crypto.randomUUID(), players: [...players], stacks: {...balances}, hands: dict(), folded: dict(), contributions: dict(), playerBets: dict(), actedAt: dict(), communityCards: [], deck: [], phase: 'intermission', street: 'intermission', round: previous?.round || 0, dealerIndex: previous?.dealerIndex ?? -1, currentPlayerIndex: -1, pot: 0, currentBet: 0, minRaise: config.ante, turnEndsAt: null, nextRoundStartsAt: config.now() + config.intermissionMs, lastAction: 'Waiting for the next hand.', winner: null, showdown: null, payouts: dict(), departed: [] }; } function deal(players, balances, config, previous) { const game = intermission(players, balances, config, previous); game.round++; game.dealerIndex = (game.dealerIndex + 1) % players.length; game.nextRoundStartsAt = null; const funded = players.filter(player => balances[player] >= config.ante); if (funded.length < 2) { game.phase = 'waiting'; game.lastAction = `Waiting for two players with at least ${config.ante} chips.`; return game; } game.deck = (config.createDeck || createDeck)(); game.phase = 'betting'; game.street = 'preflop'; for (const player of players) { game.contributions[player] = 0; game.playerBets[player] = 0; game.actedAt[player] = null; game.folded[player] = !funded.includes(player); if (!game.folded[player]) { game.hands[player] = [game.deck.pop(), game.deck.pop()]; pay(game, player, config.ante); // The ante is not a street bet. game.playerBets[player] = 0; } } game.lastAction = `Round ${game.round}: each player paid ${config.ante} chips.`; advance(game, config, game.dealerIndex); return game; } function pay(game, player, amount) { game.stacks[player] -= amount; game.playerBets[player] += amount; game.contributions[player] += amount; game.pot += amount; } function settle(game, config) { const active = live(game); const evaluations = active.map(player => ({player, hand: game.hands[player], result: active.length === 1 ? {score: [0], name: 'Last remaining hand', comboCards: []} : evaluateBestHand([...game.hands[player], ...game.communityCards])})); const seats = game.players.slice(game.dealerIndex + 1).concat(game.players.slice(0, game.dealerIndex + 1)); game.payouts = calculatePayouts(game.contributions, evaluations, seats); for (const [player, amount] of Object.entries(game.payouts)) game.stacks[player] += amount; const distributed = Object.values(game.payouts).reduce((sum, amount) => sum + amount, 0); if (distributed !== game.pot) throw new Error('Pot conservation failed'); game.lastAction = `Distributed ${game.pot} chips: ${Object.entries(game.payouts).map(([player, amount]) => `${player} +${amount}`).join(', ')}.`; game.pot = 0; game.phase = 'intermission'; game.currentPlayerIndex = -1; game.turnEndsAt = null; game.nextRoundStartsAt = config.now() + config.intermissionMs; game.winner = Object.keys(game.payouts).join(', '); game.showdown = active.length > 1 ? evaluations : []; } function advance(game, config, from = game.currentPlayerIndex) { if (live(game).length <= 1) { settle(game, config); return; } const solvent = live(game).filter(player => game.stacks[player] > 0); const pending = solvent.filter(player => game.actedAt[player] == null || callAmount(game, player) > 0); // A sole solvent hand cannot bet into players who are already all-in. if (pending.length && !(solvent.length === 1 && callAmount(game, solvent[0]) === 0)) { for (let offset = 1; offset <= game.players.length; offset++) { const index = (from + offset + game.players.length) % game.players.length; if (!pending.includes(game.players[index])) continue; game.currentPlayerIndex = index; game.turnEndsAt = config.now() + config.turnMs; return; } } if (game.street === 'river') { settle(game, config); return; } const next = {preflop: 'flop', flop: 'turn', turn: 'river'}[game.street]; const count = next === 'flop' ? 3 : 1; for (let i = 0; i < count; i++) game.communityCards.push(game.deck.pop()); game.street = next; game.currentBet = 0; game.minRaise = config.ante; for (const player of game.players) { game.playerBets[player] = 0; game.actedAt[player] = null; } advance(game, config, game.dealerIndex); } function action(game, player, kind, amount, config) { if (game.phase !== 'betting') throw new AppError('WRONG_PHASE', 'No hand is accepting actions', 409); if (turn(game) !== player || !canAct(game, player)) throw new AppError('NOT_YOUR_TURN', 'Wait for your turn', 409); if (kind === 'timeout') kind = callAmount(game, player) ? 'fold' : 'hit'; if (kind === 'fold') { game.folded[player] = true; game.lastAction = `${player} folded.`; } else if (kind === 'hit' || kind === 'bet') { const call = callAmount(game, player); if (kind === 'hit') amount = Math.min(call, game.stacks[player]); if (!Number.isSafeInteger(amount) || amount < 0 || amount > game.stacks[player]) throw new AppError('INVALID_BET', 'Bet must be whole chips within your stack'); const allIn = amount === game.stacks[player]; const target = game.playerBets[player] + amount; if (amount < call && !allIn) throw new AppError('INVALID_BET', `Call at least ${call} chips or go all-in`); if (target > game.currentBet) { if (!canRaise(game, player)) throw new AppError('RAISE_NOT_REOPENED', 'A short all-in has not reopened your raise'); if (live(game).filter(other => other !== player && game.stacks[other] > 0).length === 0) throw new AppError('INVALID_BET', 'No opponent can match a raise'); const raise = target - game.currentBet; if (raise < game.minRaise && !allIn) throw new AppError('MINIMUM_RAISE', `A full raise must add at least ${game.minRaise} chips above the table bet`); if (raise >= game.minRaise) game.minRaise = raise; game.currentBet = target; } pay(game, player, amount); game.actedAt[player] = game.currentBet; game.lastAction = `${player} ${amount ? `paid ${amount} chips` : 'checked'}.`; } else throw new AppError('INVALID_ACTION', 'Unknown poker action'); advance(game, config); } function leave(game, player, config) { if (!game || game.phase !== 'betting' || !game.players.includes(player)) return; game.departed.push(player); game.folded[player] = true; if (turn(game) === player || live(game).length <= 1) advance(game, config); else { const solvent = live(game).filter(other => game.stacks[other] > 0); if (!solvent.length || (solvent.length === 1 && callAmount(game, solvent[0]) === 0)) advance(game, config); } } function state(game, username, lobbyId, config) { return { type: 'poker:state', lobbyId, username, players: game.players, round: game.round, pot: game.pot, currentBet: game.currentBet, bet: game.currentBet, communityCards: game.communityCards, hand: game.hands[username] || [], currentPlayer: turn(game), isYourTurn: turn(game) === username, queue: game.phase === 'betting' ? game.players.slice(game.currentPlayerIndex).concat(game.players.slice(0, game.currentPlayerIndex)).filter(player => canAct(game, player)) : [], folded: game.folded, street: game.street, phase: game.phase, lastAction: game.lastAction, yourContribution: game.contributions[username] || 0, yourStack: game.stacks[username] || 0, isSpectator: !game.hands[username] || Boolean(game.folded[username]), callAmount: callAmount(game, username), minimumStake: config.ante, minimumRaise: game.minRaise, canRaise: canRaise(game, username), nextRoundStartsAt: game.nextRoundStartsAt, turnEndsAt: game.turnEndsAt, winner: game.winner, showdown: game.showdown, payouts: game.payouts }; } module.exports = {intermission, deal, action, leave, state, turn, callAmount};