@idealic/poker-engine
Version:
Professional poker game engine and hand evaluator with built-in iterator utilities
478 lines • 18.7 kB
JavaScript
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