UNPKG

@idealic/poker-engine

Version:

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

433 lines 15.5 kB
import { isPlayerAction } from '../actions/processor'; import { Streets } from '../types'; import { getActionAmount, getActionPlayerIndex, getActionTimestamp, getActionType, } from '../utils/position'; /** * Constants for statistics tracking */ const STEAL_BET_THRESHOLD = 2.5; // Minimum bet size relative to BB to be considered a steal attempt /** * Gets the count of remaining active players who haven't acted yet * * @param table - Current table state * @param excludedPlayerIdx - Optional player index to exclude from the count * @returns number of remaining active players */ function getRemainingPlayersCount(table, excludedPlayerIdx) { const playerIndex = getTablePlayerIndex(table, excludedPlayerIdx); return table.players.filter((_, i) => playerIndex !== i && isPlayerActive(table, i)).length; } /** * Determines if a player is active and able to act * * @param table - Current table state * @param playerIndex - Index of the player being evaluated * @returns boolean indicating if player can act */ function isPlayerActive(table, playerIndex) { const player = table.players[playerIndex]; return !player.hasFolded && !player.hasActed && !player.isInactive; } /** * Determines if a player is in a favorable position (Button or Cutoff) * * @param table - Current table state * @param playerIndex - Index of the player being evaluated * @returns boolean indicating if player is in a favorable position */ function isFavorablePosition(table, playerId) { const player = getTablePlayerById(table, playerId); const numPlayers = table.players.length; const buttonIndex = table.buttonIndex; const isButton = player.position === buttonIndex % numPlayers; const isCutoff = player.position === (buttonIndex - 1 + numPlayers) % numPlayers; return isButton || isCutoff; } /** * Updates opportunity-based statistics for a player * This is called every time we get or create stats to ensure opportunities are current */ function updateOpportunityStats(table, playerId, statEntry) { playerId = getTablePlayerId(table, playerId); const prevStreetName = Streets[Streets.indexOf(table.street) - 1]; const lastStreet = table.stats.filter(s => s.street === prevStreetName); const lastAggressor = lastStreet.find(s => s.lastAggressions)?.playerId; const others = table.stats.filter(s => s.street === table.street && s.playerId !== playerId); // Record limp opportunities when there are no raises yet if (!others.some(s => s.aggressions)) { statEntry.limpOpportunities++; } // CBET: Last aggressor of previous street makes first aggression if (lastAggressor == playerId && !others.find(s => s.firstAggressions)) { statEntry.cbetOpportunities++; } // DONK: Out of position bet against previous street aggressor if (lastAggressor != null && lastAggressor != playerId && !others.some(s => s.playerId == lastAggressor && (s.calls || s.bets || s.raises))) { statEntry.donkBetOpportunities++; } // OPEN SHOVE: All in as first aggression if (!others.some(s => s.firstAggressions)) { statEntry.openShoveOpportunities++; } // CHECK RAISE: Checking, then raising if (statEntry.checks && others.some(s => s.firstAggressions)) { statEntry.checkRaiseOpportunities++; } // 3/4 BET: (re)Raising after a bet if (others.some(s => s.threeBets > 0)) { statEntry.fourBetOpportunities++; } else if (others.some(s => s.firstAggressions)) { statEntry.threeBetOpportunities++; } // Record steal opportunities if (table.street === 'preflop' && !others.some(s => s.firstAggressions) && isFavorablePosition(table, playerId) && getRemainingPlayersCount(table, playerId) > 0) { statEntry.stealOpportunities++; } if (others.find(s => s.lastAggressions && s.threeBets)) { statEntry.threeBetChallenges = 1; } if (others.find(s => s.lastAggressions && s.fourBets)) { statEntry.fourBetChallenges = 1; } if (others.find(s => s.lastAggressions && s.cbet)) { statEntry.cbetChallenges = 1; } if (others.find(s => s.lastAggressions && s.steals)) { statEntry.stealChallenges = 1; } if (others.find(s => s.lastAggressions && s.donkBets)) { statEntry.donkBetChallenges = 1; } if (others.find(s => s.lastAggressions && s.checkRaises)) { statEntry.checkRaiseChallenges = 1; } if (others.find(s => s.lastAggressions && s.openShoves)) { statEntry.openShoveChallenges = 1; } } export const emptyStat = { // Meta information about the game/street decisionDuration: 0, // Basic actions and counts aggressions: 0, passivities: 0, decisions: 0, bets: 0, raises: 0, calls: 0, checks: 0, folds: 0, allIns: 0, voluntaryPutMoneyInPotTimes: 0, firstAggressions: 0, lastAggressions: 0, // Stats filled retroactively success: 0, // Finance information wentToShowdown: 0, wonAtShowdown: 0, wonWithoutShowdown: 0, stackBefore: 0, stackAfter: 0, won: 0, lost: 0, investments: 0, profits: 0, balance: 0, winnings: 0, losses: 0, rake: 0, currency: 'USD', bigBlind: 0, currencyRate: 1, // Opportunities limps: 0, limpOpportunities: 0, threeBets: 0, threeBetOpportunities: 0, threeBetFolds: 0, threeBetChallenges: 0, threeBetDefenses: 0, fourBets: 0, fourBetOpportunities: 0, fourBetFolds: 0, fourBetChallenges: 0, fourBetDefenses: 0, cbet: 0, cbetOpportunities: 0, cbetFolds: 0, cbetChallenges: 0, cbetDefenses: 0, steals: 0, stealOpportunities: 0, stealFolds: 0, stealChallenges: 0, stealDefenses: 0, donkBets: 0, donkBetOpportunities: 0, donkBetFolds: 0, donkBetChallenges: 0, donkBetDefenses: 0, checkRaises: 0, checkRaiseOpportunities: 0, checkRaiseFolds: 0, checkRaiseChallenges: 0, checkRaiseDefenses: 0, shoveFolds: 0, shoveChallenges: 0, shoveDefenses: 0, openShoves: 0, openShoveOpportunities: 0, openShoveFolds: 0, openShoveChallenges: 0, openShoveDefenses: 0, }; /** * Helper function to get or create a stat entry for a player and street * Also updates opportunity-based statistics */ export function getOrCreatePlayerStreetStats(table, playerId, street) { playerId = getTablePlayerId(table, playerId); if (!table.stats) { table.stats = []; } // Try to find existing stat let statEntry = table.stats.find(s => s.playerId === playerId && s.street === street); if (!statEntry) { // Create new stat entry for this player and street statEntry = { ...emptyStat, timestamp: Number(new Date()), street, bigBlind: table.minBet ?? 0, gameId: table.gameId, playerId: playerId, hand: table.hand, tableId: table.tableId, }; table.stats.push(statEntry); } return statEntry; } export function getTablePlayerById(table, playerId) { return table.players[getTablePlayerIndex(table, playerId)]; } export function getTablePlayerId(table, playerId) { if (typeof playerId === 'number') { return table.players[playerId].name + '@' + table.venue; } return playerId; } export function getTablePlayerIndex(table, playerId) { if (typeof playerId === 'number') { return playerId; } return table.players.findIndex((p, i) => getTablePlayerId(table, i) == playerId); } export function recordStatsBefore(table, playerId) { playerId = getTablePlayerId(table, playerId); const statEntry = getOrCreatePlayerStreetStats(table, playerId, table.street); // Update opportunity stats every time we access the entry updateOpportunityStats(table, playerId, statEntry); // Update stack before if not set yet if (statEntry.stackBefore === 0) { const currentPlayer = getTablePlayerById(table, playerId); statEntry.stackBefore = currentPlayer.stack + (table.street === 'preflop' ? currentPlayer.currentBet : 0); } } /** * Records statistics after an action is applied to the table * This handles all action-based statistics (bets, raises, etc.) */ export function recordStatsAfter(table, action) { if (!isPlayerAction(action)) return; const playerIndex = getActionPlayerIndex(action); if (playerIndex === undefined || playerIndex < 0 || playerIndex >= table.players.length) return; const playerId = getTablePlayerId(table, playerIndex); const currentPlayer = table.players[playerIndex]; const actionType = getActionType(action); let statEntry = getOrCreatePlayerStreetStats(table, playerIndex, table.street); // Always update stack after any action statEntry.stackAfter = currentPlayer.stack; const streetStats = table.stats.filter(s => s.street === table.street); const streetAggression = streetStats.find(s => s.lastAggressions); const timestamp = getActionTimestamp(action); if (timestamp) { statEntry.timestamp || (statEntry.timestamp = timestamp); statEntry.decisionDuration += timestamp - (table.lastTimestamp || 0); } if (actionType === 'f') { statEntry.folds = 1; statEntry.decisions++; if (streetAggression?.threeBets) { statEntry.threeBetFolds = 1; } if (streetAggression?.fourBets) { statEntry.fourBetFolds = 1; } if (streetAggression?.cbet) { statEntry.cbetFolds = 1; } if (streetAggression?.steals) { statEntry.stealFolds = 1; } if (streetAggression?.donkBets) { statEntry.donkBetFolds = 1; } if (streetAggression?.checkRaises) { statEntry.checkRaiseFolds = 1; } if (streetAggression?.openShoves) { statEntry.openShoveFolds = 1; } } if (currentPlayer.isAllIn && !table.stats.find(s => s.allIns && s.playerId === playerId && s.gameId == table.gameId)) { statEntry.allIns = 1; } if (!currentPlayer.isAllIn || statEntry.allIns) { // Betting isnt reset for all in players that move into next round statEntry.investments = currentPlayer.roundBet / table.minBet; } // Handle call/check if (actionType === 'cc') { statEntry.decisions++; statEntry.passivities++; if (streetAggression || statEntry.street == 'preflop') { statEntry.calls++; } else { statEntry.checks++; } // Check for limp (call without prior raises) if (statEntry.limpOpportunities && (statEntry.street == 'preflop' ? statEntry.calls : statEntry.checks) == 1 && !streetAggression) { statEntry.limps++; } // For preflop calls, mark as VPIP if (table.street === 'preflop') { statEntry.voluntaryPutMoneyInPotTimes = 1; } } // Handle bet/raise else if (actionType === 'cbr') { statEntry.decisions++; statEntry.aggressions++; if (!streetAggression) { statEntry.firstAggressions = 1; if (statEntry.openShoveOpportunities) { statEntry.openShoves = 1; } } if (streetAggression || statEntry.street == 'preflop') { if (streetAggression) streetAggression.lastAggressions = 0; // MUTATION! statEntry.lastAggressions = 1; statEntry.raises++; // Check for three/four-bet if (statEntry.fourBetOpportunities > 0) { statEntry.fourBets = 1; } else if (statEntry.threeBetOpportunities > 0) { statEntry.threeBets = 1; } if (statEntry.checkRaiseOpportunities) { statEntry.checkRaises = 1; } // Check for steal if (statEntry.stealOpportunities) { const targetAmount = getActionAmount(action); if (targetAmount >= table.minBet * STEAL_BET_THRESHOLD) { statEntry.steals = 1; } } } else { statEntry.lastAggressions = 1; statEntry.decisions++; statEntry.bets++; // Check for continuation bet on flop if (statEntry.cbetOpportunities) { statEntry.cbet = 1; } if (statEntry.cbetOpportunities) { statEntry.cbet = 1; } if (statEntry.donkBetOpportunities) { statEntry.donkBets = 1; } } // For preflop betting, mark as VPIP statEntry.voluntaryPutMoneyInPotTimes = 1; } // Handle showdown else if (actionType === 'sm') { if (currentPlayer.hasShownCards) { statEntry.wentToShowdown = 1; // Also update river stats if needed if (table.street !== 'river') { let riverStats = getOrCreatePlayerStreetStats(table, playerIndex, 'river'); riverStats.wentToShowdown = 1; } } } // Record final stacks and calculate winnings for all players at showdown if (table.isHandComplete) { table.players.forEach((player, idx) => { if (player.isInactive) return; const preflopStats = getOrCreatePlayerStreetStats(table, idx, 'preflop'); const riverStats = getOrCreatePlayerStreetStats(table, idx, table.street); // Update final stack riverStats.stackAfter = player.stack; // Calculate winnings using preflop stackBefore if (preflopStats?.stackBefore !== undefined) { const stackDiff = player.stack - preflopStats.stackBefore; if (stackDiff > 0) { Streets.map(street => { const streetStats = getPlayerStreetStats(table, idx, street); if (streetStats) { streetStats.success = 1; } }); if (table.isShowdown) { riverStats.wonAtShowdown = 1; } else { riverStats.wonWithoutShowdown = 1; } riverStats.winnings = stackDiff / table.minBet; riverStats.losses = 0; riverStats.won = 1; riverStats.rake = player.rake / table.minBet; } else if (stackDiff < 0) { riverStats.winnings = 0; riverStats.losses = Math.abs(stackDiff / table.minBet); riverStats.lost = 1; } else { riverStats.winnings = 0; riverStats.losses = 0; } } }); } } /** * Extracts player statistics for a specific player and street */ export function getPlayerStreetStats(table, playerId, street) { playerId = getTablePlayerId(table, playerId); return table.stats.find(s => s.playerId === playerId && s.street === street); } //# sourceMappingURL=stats.js.map