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