const SUITS = [ "spades", "hearts", "diamonds", "clubs" ]; const RANKS = [ "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A" ]; const RANK_VALUES = { "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, "10": 10, J: 11, Q: 12, K: 13, A: 14 }; const HAND_CATEGORIES = { HIGH_CARD: 0, PAIR: 1, TWO_PAIR: 2, THREE_OF_A_KIND: 3, STRAIGHT: 4, FLUSH: 5, FULL_HOUSE: 6, FOUR_OF_A_KIND: 7, STRAIGHT_FLUSH: 8, ROYAL_FLUSH: 9 }; const HAND_CATEGORY_NAMES = { [HAND_CATEGORIES.HIGH_CARD]: "High Card", [HAND_CATEGORIES.PAIR]: "Pair", [HAND_CATEGORIES.TWO_PAIR]: "Two Pair", [HAND_CATEGORIES.THREE_OF_A_KIND]: "Three of a Kind", [HAND_CATEGORIES.STRAIGHT]: "Straight", [HAND_CATEGORIES.FLUSH]: "Flush", [HAND_CATEGORIES.FULL_HOUSE]: "Full House", [HAND_CATEGORIES.FOUR_OF_A_KIND]: "Four of a Kind", [HAND_CATEGORIES.STRAIGHT_FLUSH]: "Straight Flush", [HAND_CATEGORIES.ROYAL_FLUSH]: "Royal Flush" }; const STREETS = [ "PREFLOP", "FLOP", "TURN", "RIVER" ]; class PokerEngineError extends Error { constructor( code, message ) { super(message); this.name = "PokerEngineError"; this.code = code; } } function createDeck() { const deck = []; for (const suit of SUITS) { for (const rank of RANKS) { deck.push({ rank, suit }); } } return deck; } function shuffleDeck( deck, rng = Math.random ) { const shuffled = deck.map(card => ({ ...card })); for ( let index = shuffled.length - 1; index > 0; index-- ) { const swapIndex = Math.floor( rng() * (index + 1) ); [ shuffled[index], shuffled[swapIndex] ] = [ shuffled[swapIndex], shuffled[index] ]; } return shuffled; } function cardToString( card ) { if (!card) { return ""; } const symbols = { spades: "♠", hearts: "♥", diamonds: "♦", clubs: "♣" }; return ( `${card.rank}${symbols[card.suit] ?? "?"}` ); } function cloneState( state ) { if ( typeof structuredClone === "function" ) { return structuredClone( state ); } return JSON.parse( JSON.stringify(state) ); } function combinations( items, choose ) { const output = []; function walk( start, current ) { if ( current.length === choose ) { output.push( current.slice() ); return; } for ( let index = start; index < items.length; index++ ) { current.push( items[index] ); walk( index + 1, current ); current.pop(); } } walk( 0, [] ); return output; } function evaluateFiveCardHand( cards ) { if ( !Array.isArray(cards) || cards.length !== 5 ) { throw new PokerEngineError( "INVALID_HAND", "Exactly five cards are required." ); } const values = cards .map( card => RANK_VALUES[card.rank] ) .sort( (a, b) => b - a ); if ( values.some( value => !Number.isInteger(value) ) ) { throw new PokerEngineError( "INVALID_CARD", "A card has an invalid rank." ); } const flush = cards.every( card => card.suit === cards[0].suit ); const uniqueValues = [...new Set(values)]; let straightHigh = null; if ( uniqueValues.length === 5 ) { if ( uniqueValues[0] - uniqueValues[4] === 4 ) { straightHigh = uniqueValues[0]; } else if ( uniqueValues.join(",") === "14,5,4,3,2" ) { straightHigh = 5; } } const counts = new Map(); for (const value of values) { counts.set( value, (counts.get(value) ?? 0) + 1 ); } const groups = [...counts.entries()] .map( ([value, count]) => ({ value, count }) ) .sort( (a, b) => b.count - a.count || b.value - a.value ); if ( flush && straightHigh !== null ) { if ( straightHigh === 14 ) { return buildRank( HAND_CATEGORIES.ROYAL_FLUSH, [14], cards ); } return buildRank( HAND_CATEGORIES.STRAIGHT_FLUSH, [straightHigh], cards ); } if ( groups[0].count === 4 ) { return buildRank( HAND_CATEGORIES.FOUR_OF_A_KIND, [ groups[0].value, groups[1].value ], cards ); } if ( groups[0].count === 3 && groups[1].count === 2 ) { return buildRank( HAND_CATEGORIES.FULL_HOUSE, [ groups[0].value, groups[1].value ], cards ); } if (flush) { return buildRank( HAND_CATEGORIES.FLUSH, values, cards ); } if ( straightHigh !== null ) { return buildRank( HAND_CATEGORIES.STRAIGHT, [straightHigh], cards ); } if ( groups[0].count === 3 ) { const kickers = groups .slice(1) .map( group => group.value ) .sort( (a, b) => b - a ); return buildRank( HAND_CATEGORIES.THREE_OF_A_KIND, [ groups[0].value, ...kickers ], cards ); } if ( groups[0].count === 2 && groups[1].count === 2 ) { const pairs = [ groups[0].value, groups[1].value ].sort( (a, b) => b - a ); const kicker = groups.find( group => group.count === 1 ).value; return buildRank( HAND_CATEGORIES.TWO_PAIR, [ ...pairs, kicker ], cards ); } if ( groups[0].count === 2 ) { const kickers = groups .slice(1) .map( group => group.value ) .sort( (a, b) => b - a ); return buildRank( HAND_CATEGORIES.PAIR, [ groups[0].value, ...kickers ], cards ); } return buildRank( HAND_CATEGORIES.HIGH_CARD, values, cards ); } function buildRank( category, tiebreak, cards ) { return { category, name: HAND_CATEGORY_NAMES[ category ], tiebreak, cards: cards.map( card => ({ ...card }) ) }; } function compareHandRanks( left, right ) { if ( left.category !== right.category ) { return ( left.category > right.category ? 1 : -1 ); } const length = Math.max( left.tiebreak.length, right.tiebreak.length ); for ( let index = 0; index < length; index++ ) { const leftValue = left.tiebreak[index] ?? 0; const rightValue = right.tiebreak[index] ?? 0; if ( leftValue !== rightValue ) { return ( leftValue > rightValue ? 1 : -1 ); } } return 0; } function evaluateHand( cards ) { if ( !Array.isArray(cards) || cards.length < 5 || cards.length > 7 ) { throw new PokerEngineError( "INVALID_HAND", "A poker hand must contain between five and seven cards." ); } let best = null; for ( const candidate of combinations( cards, 5 ) ) { const rank = evaluateFiveCardHand( candidate ); if ( !best || compareHandRanks( rank, best ) > 0 ) { best = rank; } } return best; } function splitPot( pot, winnerSeats, dealerSeat = -1 ) { if ( !Number.isSafeInteger(pot) || pot < 0 ) { throw new PokerEngineError( "INVALID_POT", "Pot must be a non-negative safe integer." ); } if ( !Array.isArray(winnerSeats) || winnerSeats.length === 0 ) { throw new PokerEngineError( "NO_WINNERS", "At least one winner is required." ); } const uniqueSeats = [...new Set(winnerSeats)] .sort( (a, b) => a - b ); const base = Math.floor( pot / uniqueSeats.length ); let remainder = pot % uniqueSeats.length; const ordered = orderSeatsAfter( uniqueSeats, dealerSeat ); const payouts = {}; for (const seat of ordered) { payouts[seat] = base; } for ( let index = 0; remainder > 0; index++, remainder-- ) { const seat = ordered[ index % ordered.length ]; payouts[seat]++; } return payouts; } function resolveShowdown( state ) { const active = state.players.filter( player => !player.folded ); if ( active.length === 0 ) { throw new PokerEngineError( "NO_ACTIVE_PLAYERS", "Showdown has no active players." ); } let winners = []; let bestRank = null; for (const player of active) { const rank = evaluateHand([ ...player.holeCards, ...state.communityCards ]); player.handRank = rank; if (!bestRank) { bestRank = rank; winners = [player]; continue; } const comparison = compareHandRanks( rank, bestRank ); if (comparison > 0) { bestRank = rank; winners = [player]; } else if ( comparison === 0 ) { winners.push(player); } } const payouts = splitPot( state.pot, winners.map( player => player.seat ), state.dealerSeat ); for (const player of state.players) { const payout = payouts[player.seat] ?? 0; player.stack += payout; } state.result = { type: "showdown", winnerSeats: winners.map( player => player.seat ), payouts, handRank: bestRank }; state.pot = 0; state.state = "PAYOUT"; state.currentSeat = null; return state; } function startHand({ players, dealerSeat = null, smallBlind = 100, bigBlind = 200, deck = null }) { validatePlayers( players ); if ( !Number.isSafeInteger( smallBlind ) || !Number.isSafeInteger( bigBlind ) || smallBlind <= 0 || bigBlind <= 0 || smallBlind >= bigBlind ) { throw new PokerEngineError( "INVALID_BLINDS", "Blinds must be positive integers and the small blind must be lower than the big blind." ); } const sortedPlayers = players .map( player => ({ userId: String( player.userId ), seat: Number( player.seat ), stack: Number( player.stack ) }) ) .sort( (a, b) => a.seat - b.seat ); const handCap = Math.min( ...sortedPlayers.map( player => player.stack ) ); if ( handCap < bigBlind ) { throw new PokerEngineError( "STACK_BELOW_BIG_BLIND", "Every player must have at least one big blind available to start a hand." ); } const seats = sortedPlayers.map( player => player.seat ); const resolvedDealerSeat = dealerSeat === null ? seats[0] : Number(dealerSeat); if ( !seats.includes( resolvedDealerSeat ) ) { throw new PokerEngineError( "INVALID_DEALER", "Dealer seat does not belong to an active player." ); } const workingDeck = deck ? deck.map( card => ({ ...card }) ) : shuffleDeck( createDeck() ); if ( workingDeck.length < sortedPlayers.length * 2 + 5 ) { throw new PokerEngineError( "INSUFFICIENT_DECK", "The deck does not contain enough cards." ); } const state = { state: "PREFLOP", street: "PREFLOP", players: sortedPlayers.map( player => ({ ...player, startingStack: player.stack, handCap, totalBet: 0, streetBet: 0, folded: false, allIn: false, actedThisStreet: false, holeCards: [] }) ), dealerSeat: resolvedDealerSeat, smallBlind, bigBlind, smallBlindSeat: null, bigBlindSeat: null, currentSeat: null, communityCards: [], deck: workingDeck, pot: 0, currentBet: 0, lastRaiseSize: bigBlind, handCap, result: null }; dealHoleCards( state ); const headsUp = state.players.length === 2; const smallBlindSeat = headsUp ? state.dealerSeat : nextSeatAfter( state.players, state.dealerSeat, () => true ); const bigBlindSeat = nextSeatAfter( state.players, smallBlindSeat, () => true ); state.smallBlindSeat = smallBlindSeat; state.bigBlindSeat = bigBlindSeat; postForcedBet( state, smallBlindSeat, smallBlind ); postForcedBet( state, bigBlindSeat, bigBlind ); state.currentBet = bigBlind; state.lastRaiseSize = bigBlind; state.currentSeat = nextSeatAfter( state.players, bigBlindSeat, canPlayerAct ); if ( state.currentSeat === null ) { progressUntilActionOrPayout( state ); } return state; } function validatePlayers( players ) { if ( !Array.isArray(players) || players.length < 2 || players.length > 6 ) { throw new PokerEngineError( "INVALID_PLAYER_COUNT", "Poker requires between 2 and 6 players." ); } const userIds = new Set(); const seats = new Set(); for (const player of players) { const userId = String( player?.userId ?? "" ); const seat = Number( player?.seat ); const stack = Number( player?.stack ); if (!userId) { throw new PokerEngineError( "INVALID_PLAYER", "Every player needs a userId." ); } if ( !Number.isSafeInteger(seat) || seat < 0 ) { throw new PokerEngineError( "INVALID_SEAT", "Every player needs a non-negative integer seat." ); } if ( !Number.isSafeInteger(stack) || stack <= 0 ) { throw new PokerEngineError( "INVALID_STACK", "Every player needs a positive integer stack." ); } if ( userIds.has(userId) ) { throw new PokerEngineError( "DUPLICATE_PLAYER", "A player cannot occupy multiple seats." ); } if ( seats.has(seat) ) { throw new PokerEngineError( "DUPLICATE_SEAT", "Two players cannot occupy the same seat." ); } userIds.add(userId); seats.add(seat); } } function dealHoleCards( state ) { for ( let round = 0; round < 2; round++ ) { const orderedSeats = orderSeatsAfter( state.players.map( player => player.seat ), state.dealerSeat ); for (const seat of orderedSeats) { const player = getPlayerBySeat( state, seat ); player.holeCards.push( state.deck.pop() ); } } } function postForcedBet( state, seat, amount ) { const player = getPlayerBySeat( state, seat ); const wager = Math.min( amount, remainingHandBudget( player ) ); commitChips( state, player, wager ); if ( remainingHandBudget( player ) === 0 ) { player.allIn = true; } } function getLegalActions( state, userId ) { if ( !STREETS.includes( state.state ) ) { return { actions: [], reason: "HAND_NOT_ACTIVE" }; } const player = state.players.find( candidate => candidate.userId === String(userId) ); if (!player) { return { actions: [], reason: "PLAYER_NOT_FOUND" }; } if ( player.seat !== state.currentSeat ) { return { actions: [], reason: "OUT_OF_TURN" }; } if ( !canPlayerAct(player) ) { return { actions: [], reason: "PLAYER_CANNOT_ACT" }; } const toCall = Math.max( 0, state.currentBet - player.streetBet ); const remaining = remainingHandBudget( player ); const maxBetTo = player.streetBet + remaining; const actions = [ "fold" ]; if ( toCall === 0 ) { actions.push( "check" ); } else if ( remaining > 0 ) { actions.push( "call" ); } if ( remaining > 0 && maxBetTo > state.currentBet ) { actions.push( state.currentBet === 0 ? "bet" : "raise" ); } if ( remaining > 0 ) { actions.push( "all_in" ); } const minimumRaiseTo = state.currentBet === 0 ? state.bigBlind : state.currentBet + state.lastRaiseSize; return { actions, toCall, minRaiseTo: Math.min( minimumRaiseTo, maxBetTo ), maxRaiseTo: maxBetTo, handCap: state.handCap, remainingHandBudget: remaining }; } function applyAction( originalState, userId, action ) { const state = cloneState( originalState ); if ( !action || !action.type ) { throw new PokerEngineError( "INVALID_ACTION", "An action type is required." ); } if ( !STREETS.includes( state.state ) ) { throw new PokerEngineError( "HAND_NOT_ACTIVE", "The hand is not accepting actions." ); } const player = state.players.find( candidate => candidate.userId === String(userId) ); if (!player) { throw new PokerEngineError( "PLAYER_NOT_FOUND", "Player is not part of this hand." ); } if ( player.seat !== state.currentSeat ) { throw new PokerEngineError( "OUT_OF_TURN", "It is not this player's turn." ); } if ( !canPlayerAct(player) ) { throw new PokerEngineError( "PLAYER_CANNOT_ACT", "This player cannot act." ); } const type = String( action.type ).toLowerCase(); const toCall = Math.max( 0, state.currentBet - player.streetBet ); if (type === "fold") { player.folded = true; player.actedThisStreet = true; } else if ( type === "check" ) { if ( toCall !== 0 ) { throw new PokerEngineError( "CANNOT_CHECK", "Player cannot check while facing a bet." ); } player.actedThisStreet = true; } else if ( type === "call" ) { if ( toCall <= 0 ) { throw new PokerEngineError( "NOTHING_TO_CALL", "There is no bet to call." ); } const remaining = remainingHandBudget( player ); if ( remaining < toCall ) { throw new PokerEngineError( "CALL_EXCEEDS_CAP", "Call would exceed the single-pot hand cap." ); } commitChips( state, player, toCall ); player.actedThisStreet = true; if ( remainingHandBudget( player ) === 0 ) { player.allIn = true; } } else if ( type === "bet" || type === "raise" ) { const target = Number( action.amount ); if ( !Number.isSafeInteger( target ) || target <= 0 ) { throw new PokerEngineError( "INVALID_RAISE", "Bet or raise amount must be a positive integer target." ); } if ( type === "bet" && state.currentBet !== 0 ) { throw new PokerEngineError( "BET_NOT_AVAILABLE", "Use raise when a bet already exists." ); } if ( type === "raise" && state.currentBet === 0 ) { throw new PokerEngineError( "RAISE_NOT_AVAILABLE", "Use bet when no bet exists." ); } applyRaiseTo( state, player, target ); } else if ( type === "all_in" ) { const remaining = remainingHandBudget( player ); if ( remaining <= 0 ) { throw new PokerEngineError( "NO_CHIPS_AVAILABLE", "Player has no playable chips remaining this hand." ); } const oldCurrentBet = state.currentBet; const target = player.streetBet + remaining; commitChips( state, player, remaining ); player.allIn = true; player.actedThisStreet = true; if ( target > oldCurrentBet ) { const raiseSize = target - oldCurrentBet; if ( raiseSize >= state.lastRaiseSize ) { state.lastRaiseSize = raiseSize; reopenBettingAfterRaise( state, player.seat ); } state.currentBet = target; } } else { throw new PokerEngineError( "UNKNOWN_ACTION", "Unknown poker action." ); } progressAfterAction( state, player.seat ); return state; } function applyRaiseTo( state, player, target ) { const remaining = remainingHandBudget( player ); const maxTarget = player.streetBet + remaining; if ( target > maxTarget ) { throw new PokerEngineError( "RAISE_EXCEEDS_CAP", "Raise would exceed the single-pot hand cap." ); } if ( target <= state.currentBet ) { throw new PokerEngineError( "RAISE_TOO_SMALL", "Raise target must exceed the current bet." ); } const minimumTarget = state.currentBet === 0 ? state.bigBlind : state.currentBet + state.lastRaiseSize; if ( target < minimumTarget ) { throw new PokerEngineError( "RAISE_TOO_SMALL", `Minimum raise target is ${minimumTarget}.` ); } const oldCurrentBet = state.currentBet; const amount = target - player.streetBet; commitChips( state, player, amount ); state.currentBet = target; state.lastRaiseSize = target - oldCurrentBet; reopenBettingAfterRaise( state, player.seat ); player.actedThisStreet = true; if ( remainingHandBudget( player ) === 0 ) { player.allIn = true; } } function reopenBettingAfterRaise( state, raiserSeat ) { for (const candidate of state.players) { if ( candidate.seat !== raiserSeat && canPlayerAct(candidate) ) { candidate.actedThisStreet = false; } } } function commitChips( state, player, amount ) { if ( !Number.isSafeInteger(amount) || amount < 0 ) { throw new PokerEngineError( "INVALID_COMMIT", "Committed chips must be a non-negative integer." ); } if ( amount > remainingHandBudget(player) ) { throw new PokerEngineError( "HAND_CAP_EXCEEDED", "Player cannot contribute beyond the single-pot hand cap." ); } if ( amount > player.stack ) { throw new PokerEngineError( "STACK_EXCEEDED", "Player does not have enough chips." ); } player.stack -= amount; player.totalBet += amount; player.streetBet += amount; state.pot += amount; } function remainingHandBudget( player ) { return Math.max( 0, player.handCap - player.totalBet ); } function canPlayerAct( player ) { return ( !player.folded && !player.allIn && remainingHandBudget( player ) > 0 ); } function progressAfterAction( state, actedSeat ) { const activePlayers = state.players.filter( player => !player.folded ); if ( activePlayers.length === 1 ) { awardFoldWin( state, activePlayers[0] ); return; } if ( isBettingRoundComplete( state ) ) { advanceStreet( state ); progressUntilActionOrPayout( state ); return; } state.currentSeat = nextSeatAfter( state.players, actedSeat, canPlayerAct ); if ( state.currentSeat === null ) { advanceStreet( state ); progressUntilActionOrPayout( state ); } } function progressUntilActionOrPayout( state ) { while ( STREETS.includes( state.state ) ) { const activePlayers = state.players.filter( player => !player.folded ); if ( activePlayers.length === 1 ) { awardFoldWin( state, activePlayers[0] ); return; } const actionable = state.players.filter( canPlayerAct ); if ( actionable.length === 0 ) { advanceStreet( state ); continue; } if ( state.currentSeat === null || !actionable.some( player => player.seat === state.currentSeat ) ) { state.currentSeat = firstPostflopSeat( state ); } return; } } function isBettingRoundComplete( state ) { const actionable = state.players.filter( canPlayerAct ); if ( actionable.length === 0 ) { return true; } return actionable.every( player => player.actedThisStreet && player.streetBet === state.currentBet ); } function advanceStreet( state ) { if ( state.state === "PREFLOP" ) { state.state = "FLOP"; state.street = "FLOP"; state.communityCards.push( state.deck.pop(), state.deck.pop(), state.deck.pop() ); } else if ( state.state === "FLOP" ) { state.state = "TURN"; state.street = "TURN"; state.communityCards.push( state.deck.pop() ); } else if ( state.state === "TURN" ) { state.state = "RIVER"; state.street = "RIVER"; state.communityCards.push( state.deck.pop() ); } else if ( state.state === "RIVER" ) { state.state = "SHOWDOWN"; state.street = "SHOWDOWN"; resolveShowdown( state ); return state; } else { return state; } state.currentBet = 0; state.lastRaiseSize = state.bigBlind; for (const player of state.players) { player.streetBet = 0; player.actedThisStreet = false; } state.currentSeat = firstPostflopSeat( state ); return state; } function awardFoldWin( state, winner ) { const payout = state.pot; winner.stack += payout; state.result = { type: "fold", winnerSeats: [ winner.seat ], payouts: { [winner.seat]: payout }, handRank: null }; state.pot = 0; state.state = "PAYOUT"; state.currentSeat = null; } function firstPostflopSeat( state ) { return nextSeatAfter( state.players, state.dealerSeat, canPlayerAct ); } function nextSeatAfter( players, seat, predicate = () => true ) { const seats = players .map( player => player.seat ) .sort( (a, b) => a - b ); if ( seats.length === 0 ) { return null; } const startIndex = seats.findIndex( candidate => candidate > seat ); const ordered = startIndex === -1 ? seats.slice() : [ ...seats.slice( startIndex ), ...seats.slice( 0, startIndex ) ]; if ( startIndex === -1 ) { ordered.sort( (a, b) => a - b ); } for (const candidateSeat of ordered) { const player = players.find( candidate => candidate.seat === candidateSeat ); if ( player && predicate(player) ) { return candidateSeat; } } return null; } function orderSeatsAfter( seats, seat ) { const sorted = [...new Set(seats)] .sort( (a, b) => a - b ); if ( sorted.length <= 1 ) { return sorted; } const after = sorted.filter( candidate => candidate > seat ); const before = sorted.filter( candidate => candidate <= seat ); return [ ...after, ...before ]; } function getPlayerBySeat( state, seat ) { const player = state.players.find( candidate => candidate.seat === seat ); if (!player) { throw new PokerEngineError( "PLAYER_NOT_FOUND", `No player occupies seat ${seat}.` ); } return player; } function getPublicState( state ) { return { state: state.state, street: state.street, dealerSeat: state.dealerSeat, smallBlindSeat: state.smallBlindSeat, bigBlindSeat: state.bigBlindSeat, currentSeat: state.currentSeat, smallBlind: state.smallBlind, bigBlind: state.bigBlind, handCap: state.handCap, pot: state.pot, currentBet: state.currentBet, communityCards: state.communityCards.map( card => ({ ...card }) ), players: state.players.map( player => ({ userId: player.userId, seat: player.seat, stack: player.stack, folded: player.folded, allIn: player.allIn, streetBet: player.streetBet, totalBet: player.totalBet }) ), result: state.result }; } function getPrivateCards( state, userId ) { const player = state.players.find( candidate => candidate.userId === String(userId) ); if (!player) { throw new PokerEngineError( "PLAYER_NOT_FOUND", "Player is not part of this hand." ); } return player.holeCards.map( card => ({ ...card }) ); } module.exports = { SUITS, RANKS, RANK_VALUES, HAND_CATEGORIES, HAND_CATEGORY_NAMES, STREETS, PokerEngineError, createDeck, shuffleDeck, cardToString, evaluateFiveCardHand, evaluateHand, compareHandRanks, splitPot, resolveShowdown, startHand, getLegalActions, applyAction, advanceStreet, getPublicState, getPrivateCards, remainingHandBudget, canPlayerAct, nextSeatAfter, orderSeatsAfter };