UNPKG

@idealic/poker-engine

Version:

Professional poker game engine and hand evaluator with built-in iterator utilities

478 lines 18.7 kB
import { applyAction as originalApplyAction } from './actions/processor'; import { createTable, isShowdown, needsBlinds, createCensoredTable } from './game/table'; import { deal, dealStreet, showCards as dealerShowCards } from './game/dealer'; import { can } from './game/validation'; import { fold, check, call, bet, raise, dealBoard, dealHoleCards } from './Command'; import { Command as CommandFunctions } from './Command'; import { Hand } from './Hand'; /** * Helper function to create a Player object with sensible defaults * @param props - Partial Player properties * @returns A complete Player object */ function createPlayer(props) { return { winnings: 0, cards: [], hasFolded: false, hasActed: false, currentBet: 0, totalBet: 0, roundBet: 0, isAllIn: false, hasShownCards: false, rake: 0, isInactive: false, ...props }; } /** * Type detection helper to distinguish between Hand and partial Game objects * @param obj - Object to check * @returns True if object is a Hand, false if it's a partial Game */ function isHandObject(obj) { return (obj && Array.isArray(obj.players) && (obj.players.length === 0 || typeof obj.players[0] === 'string') && // Hand has string[], Game has Player[] Array.isArray(obj.startingStacks) && Array.isArray(obj.blindsOrStraddles) && Array.isArray(obj.actions)); } export function Game(handOrProps, actions) { // Type detection logic if (isHandObject(handOrProps)) { // New behavior: delegate to createTable return createTable(handOrProps, actions || handOrProps.actions); } // Legacy behavior: current Game constructor logic const props = handOrProps; // Provide sensible defaults const defaults = { tableId: `table-${Date.now()}`, gameId: `game-${Date.now()}`, hand: 1, gameTimestamp: Date.now(), variant: 'NT', board: [], pot: 0, street: 'preflop', bet: 0, usedCards: 0, nextPlayerIndex: 0, isShowdown: false, stats: [], }; // Merge props with defaults const game = { ...defaults, ...props }; // Validate required fields if (!game.players || !Array.isArray(game.players) || game.players.length === 0) { throw new Error('Game requires a non-empty players array'); } if (typeof game.minBet !== 'number' || game.minBet <= 0) { throw new Error('Game requires positive minBet'); } // Process players array to ensure complete Player objects game.players = game.players.map((playerProps, index) => { if (!playerProps.name || typeof playerProps.name !== 'string') { throw new Error(`Player at index ${index} requires a name`); } if (typeof playerProps.stack !== 'number' || playerProps.stack < 0) { throw new Error(`Player ${playerProps.name} requires a non-negative stack`); } // Set position if not provided const position = playerProps.position !== undefined ? playerProps.position : index; return createPlayer({ ...playerProps, position }); }); // Set calculated fields if not provided if (game.buttonIndex === undefined) { game.buttonIndex = 0; } if (game.smallBlindIndex === undefined) { game.smallBlindIndex = (game.buttonIndex + 1) % game.players.length; } if (game.bigBlindIndex === undefined) { game.bigBlindIndex = (game.buttonIndex + 2) % game.players.length; } // Initialize stats array if empty if (game.stats.length === 0) { game.stats = game.players.map(() => ({ flop: { vpip: false, pfr: false, aggr: false, called: false, raised: false }, turn: { vpip: false, pfr: false, aggr: false, called: false, raised: false }, river: { vpip: false, pfr: false, aggr: false, called: false, raised: false }, showdown: { vpip: false, pfr: false, aggr: false, called: false, raised: false }, })); } return game; } /** * Advances the game by executing any pending dealer actions if needed. * This function automatically handles the progression of the game when betting rounds are complete. * It will deal flop, turn, river cards, or determine winners as appropriate. * * @param game - The game state to advance * @returns The updated game state if dealer action was taken, or the original game if no action was needed */ function advance(game) { // Check if it's the dealer's turn (when nextPlayerIndex is -1) if (game.nextPlayerIndex !== -1) { // It's a player's turn, not dealer's - return game unchanged return game; } // Get the next dealer action using the existing deal function const dealerAction = deal(game); if (dealerAction) { // Apply the dealer action and return the new game state return originalApplyAction(game, dealerAction); } // No dealer action needed - return game unchanged return game; } /** * Gets the time remaining for the current player to act. * Returns time remaining in seconds based on action start time and time limits. * For legacy compatibility, returns elapsed time in milliseconds when no time limits are set. * * @param game - The game state to check time for * @returns Time remaining in seconds for time-limited games, or elapsed time in milliseconds for legacy behavior */ function getTimeLeft(game) { const currentTime = Date.now(); // Priority 1: Time limit-based games (real-time poker with actionStartTime and timeLimit) if (game.timeLimit && game.actionStartTime) { // Calculate elapsed time since action started const actionStart = game.actionStartTime.getTime(); const elapsed = currentTime - actionStart; // Base time limit (in seconds, convert to milliseconds for calculation) const timeLimit = game.timeLimit * 1000; // Time remaining from base limit let timeRemaining = timeLimit - elapsed; // If base time is expired but player has time bank if (timeRemaining <= 0 && game.timeBank && game.nextPlayerIndex >= 0) { const currentPlayer = game.nextPlayerIndex; const playerTimeBank = (game.timeBank[currentPlayer] || 0) * 1000; // Convert to ms const playerTimeBankUsed = ((game.timeBankUsed && game.timeBankUsed[currentPlayer]) || 0) * 1000; // Convert to ms const availableTimeBank = playerTimeBank - playerTimeBankUsed; // Add available time bank to remaining time timeRemaining = availableTimeBank + timeRemaining; // timeRemaining is negative here } // Convert back to seconds for return value return Math.round(timeRemaining / 1000); } // Priority 2: If timeLimit is set but no actionStartTime, assume action just started (full time available) if (game.timeLimit && !game.actionStartTime) { return game.timeLimit; } // Priority 3: Legacy behavior - elapsed time tracking // If lastTimestamp is available, return time since last action (in milliseconds) if (game.lastTimestamp !== undefined && game.lastTimestamp !== null) { return currentTime - game.lastTimestamp; } // If no lastTimestamp but gameTimestamp is available, return time since game start (in milliseconds) if (game.gameTimestamp !== undefined) { return currentTime - game.gameTimestamp; } // Default: No time tracking, return large positive value return Number.MAX_SAFE_INTEGER; } /** * Gets the index of the player who is the author of the hand. * Returns -1 if no author is specified or if the author is not found in the players list. * * @param game - The game state or hand to check * @returns Index of the author player (0-based), or -1 if not found */ function getAuthorPlayerIndex(game) { if (!game.author) { return -1; } // Handle both Game objects (players as Player[]) and Hand objects (players as string[]) if (Array.isArray(game.players) && game.players.length > 0) { if (typeof game.players[0] === 'string') { // Hand object - players is string[] return game.players.findIndex(playerName => playerName === game.author); } else { // Game object - players is Player[] return game.players.findIndex(player => player.name === game.author); } } return -1; } /** * A catch-all method for executing game commands. Takes the game state, * the command name (e.g., "fold"), and any additional arguments. * * @param game - The current game state * @param command - The command name to execute * @param args - Additional arguments required by specific commands * @returns The action string to be applied to the game * * @example * // Player 0 folds * const foldAction = Poker.Game.act(game, 'fold', 0); * * // Player 1 bets 100 chips * const betAction = Poker.Game.act(game, 'bet', 1, 100); * * // Deal flop cards * const flopAction = Poker.Game.act(game, 'dealBoard', ['Ah', 'Kh', 'Qh']); */ function act(game, command, ...args) { switch (command) { case 'fold': { const [playerIndex] = args; if (typeof playerIndex !== 'number') { throw new Error('fold requires playerIndex as first argument'); } return fold(game, playerIndex); } case 'check': { const [playerIndex] = args; if (typeof playerIndex !== 'number') { throw new Error('check requires playerIndex as first argument'); } return check(game, playerIndex); } case 'call': { const [playerIndex] = args; if (typeof playerIndex !== 'number') { throw new Error('call requires playerIndex as first argument'); } return call(game, playerIndex); } case 'bet': { const [playerIndex, amount] = args; if (typeof playerIndex !== 'number') { throw new Error('bet requires playerIndex as first argument'); } if (typeof amount !== 'number') { throw new Error('bet requires amount as second argument'); } return bet(game, playerIndex, amount); } case 'raise': { const [playerIndex, amount] = args; if (typeof playerIndex !== 'number') { throw new Error('raise requires playerIndex as first argument'); } if (typeof amount !== 'number') { throw new Error('raise requires amount as second argument'); } return raise(game, playerIndex, amount); } case 'allIn': { const [playerIndex] = args; if (typeof playerIndex !== 'number') { throw new Error('allIn requires playerIndex as first argument'); } return CommandFunctions.allIn(game, playerIndex); } case 'dealBoard': { const [cards] = args; if (!Array.isArray(cards)) { throw new Error('dealBoard requires cards array as first argument'); } return dealBoard(game, cards); } case 'dealHoleCards': { const [playerIndex, cards] = args; if (typeof playerIndex !== 'number') { throw new Error('dealHoleCards requires playerIndex as first argument'); } if (!Array.isArray(cards)) { throw new Error('dealHoleCards requires cards array as second argument'); } return dealHoleCards(game, playerIndex, cards); } case 'dealStreet': { return dealStreet(game); } case 'showCards': { const action = dealerShowCards(game); if (!action) { throw new Error('No cards to show'); } return action; } default: throw new Error(`Unknown command: ${command}`); } } /** * Game namespace for methods that operate on Game objects. */ (function (Game) { /** * Apply an action to the game state * @param game - Current game state * @param action - Action to apply * @returns Updated game state */ Game.applyAction = originalApplyAction; // REMOVED: Game.create - use Game(hand) directly instead /** * Advance the game by executing pending dealer actions * @param game - Current game state * @returns Updated game state */ function advanceGame(game) { return advance(game); } Game.advanceGame = advanceGame; /** * Get time remaining for current player to act * @param game - Current game state * @returns Time remaining in seconds (or elapsed time in ms for legacy) */ function getTimeLeftFunc(game) { return getTimeLeft(game); } Game.getTimeLeftFunc = getTimeLeftFunc; /** * Get the index of the author player * @param game - Game state or hand * @returns Author player index or -1 if not found */ function getAuthorPlayerIndexFunc(game) { return getAuthorPlayerIndex(game); } Game.getAuthorPlayerIndexFunc = getAuthorPlayerIndexFunc; /** * Check if the game is in showdown * @param game - Current game state * @returns True if in showdown */ Game.isShowdownFunc = isShowdown; /** * Check if the game needs blinds * @param game - Current game state * @returns True if blinds are needed */ Game.needsBlindsFunc = needsBlinds; /** * Create a censored table for player perspective * @param hand - Hand to create censored table from * @param playerId - Player ID for perspective * @returns Game state with censored information */ Game.createCensoredTableFunc = createCensoredTable; /** * Export a game state with an optional player perspective * @param game - The game state to export * @param playerId - Optional player ID to add as author and apply card censoring * @returns The game state as a Hand type with optional author and censored cards */ function output(game, playerId) { // Handle both Game objects (players as Player[]) and Hand objects (players as string[]) let playerNames; if (Array.isArray(game.players) && game.players.length > 0) { if (typeof game.players[0] === 'string') { // Hand object - players is string[] playerNames = game.players; } else { // Game object - players is Player[] playerNames = game.players.map((p) => p.name) || []; } } else { playerNames = []; } // Handle starting stacks - use existing if available, otherwise calculate from Game object let startingStacks; if (game.startingStacks) { startingStacks = game.startingStacks; } else if (Array.isArray(game.players) && game.players.length > 0 && typeof game.players[0] === 'object') { // Game object - calculate from player stacks startingStacks = game.players.map((p) => p.stack + (p.totalBet || 0)); } else { startingStacks = []; } const blindsArray = game.blindsOrStraddles || new Array(playerNames.length).fill(0); const antesArray = game.antes || new Array(playerNames.length).fill(0); // Create Hand object with only the fields that should be in a Hand // Don't include intermediate game state that would interfere with action replay const handData = { variant: game.variant || 'NT', currency: game.currency || 'USD', players: playerNames, startingStacks: startingStacks, blindsOrStraddles: blindsArray, antes: antesArray, actions: game.actions ?? [], time: game.time || new Date().toISOString(), timeZone: game.timeZone || 'UTC', minBet: game.minBet || 0 }; // Only include optional fields if they are explicitly set (not undefined) if (game.seed !== undefined) { handData.seed = game.seed; } if (game.venue !== undefined) { handData.venue = game.venue; } if (game.author !== undefined) { handData.author = game.author; } if (game.rakePercentage !== undefined) { handData.rakePercentage = game.rakePercentage; } // If a player ID is provided, add them as author and apply card censoring if (playerId !== undefined) { return Hand.output({ ...handData, author: playerId }, playerId); } return handData; } Game.output = output; /** * Check if a player can perform an action * @param game - Current game state * @returns Validation result */ Game.canFunc = can; /** * Execute a command on the game * @param game - Current game state * @param command - Command to execute * @param args - Command arguments * @returns Action string */ function actFunc(game, command, ...args) { return act(game, command, ...args); } Game.actFunc = actFunc; /** * Command object mapping command names to action functions */ Game.Command = { fold: CommandFunctions.fold, call: CommandFunctions.call, check: CommandFunctions.check, bet: CommandFunctions.bet, raise: CommandFunctions.raise, allIn: CommandFunctions.allIn, dealBoard: CommandFunctions.dealBoard, dealHoleCards: CommandFunctions.dealHoleCards, dealStreet: CommandFunctions.dealStreet, showCards: CommandFunctions.showCards, }; })(Game || (Game = {})); // Add backward compatibility aliases Game.advance = Game.advanceGame; Game.getTimeLeft = Game.getTimeLeftFunc; Game.getAuthorPlayerIndex = Game.getAuthorPlayerIndexFunc; Game.isShowdown = Game.isShowdownFunc; Game.needsBlinds = Game.needsBlindsFunc; Game.createCensoredTable = Game.createCensoredTableFunc; Game.export = Game.output; Game.can = Game.canFunc; Game.act = Game.actFunc; // Import extensions to register them with the Game namespace import './game/analytics'; //# sourceMappingURL=Game.js.map