@idealic/poker-engine
Version:
Poker game engine and hand evaluator
1,314 lines (1,214 loc) • 42.2 kB
text/typescript
import { Game } from '../Game';
import {
getActionAmount,
getActionPlayerIndex,
getActionTimestamp,
getActionType,
isLastToAct,
isPlayerInPosition,
} from '../game/position';
import { isPlayerAction } from '../game/progress';
import { Player, Streets, type Action, type Street } from '../types';
import type { StreetStat } from './types';
/**
* Determines if a raise qualifies as a steal attempt based on size.
* A steal must be at least 2.5 times the big blind.
*
* @param table - The current table state.
* @param amount - The amount of the raise.
* @returns boolean indicating if the raise is a steal attempt.
*/
function isStealAttempt(table: Game, amount: number): boolean {
return amount >= table.bigBlind * 2.5 && amount <= table.bigBlind * 4;
}
/**
* Determines if a player is in a "steal" position (Button, Cutoff, or Small Blind).
* This is used to identify opportunities for preflop steal raises.
*
* @param table - Current table state.
* @param playerId - ID or index of the player being evaluated.
* @returns boolean indicating if the player is in a steal position.
*/
function isLatePosition(table: Game, playerId: string | number): boolean {
const player = getTablePlayerByName(table, playerId);
const numPlayers = table.players.length;
const buttonIndex = table.buttonIndex;
const isButton = player.position === buttonIndex % numPlayers;
const isSmallBlind = player.position === table.smallBlindIndex % numPlayers;
// The Cutoff position only exists in games with 4 or more players.
// In 3-handed game, the position to the right of the button is the Big Blind.
const isCutoff =
numPlayers >= 4 && player.position === (buttonIndex - 1 + numPlayers) % numPlayers;
return isButton || isCutoff || isSmallBlind;
}
/**
* 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: Game,
playerId: number | string,
statEntry: StreetStat
): void {
playerId = Game.getPlayerName(table, playerId);
const playerIndex = Game.getPlayerIndex(table, playerId);
const currentPlayer = getTablePlayerByName(table, playerId);
const streetStats = table.stats.filter(s => s.street === table.street);
const streetAggressions = streetStats.filter(s => s.aggressions > 0);
const firstAggressorStat = streetStats.find(s => s.firstAggressions > 0);
const threeBettorStat = streetStats.find(
s => s.threeBetIpAttempts > 0 || s.threeBetOopAttempts > 0
);
const fourBettorStat = streetStats.find(s => s.fourBetIpAttempts > 0 || s.fourBetOopAttempts > 0);
const fiveBettorStat = streetStats.find(s => s.fiveBetIpAttempts > 0 || s.fiveBetOopAttempts > 0);
const cBettorStat = streetStats.find(s => s.cbetIpAttempts > 0 || s.cbetOopAttempts > 0);
const delayedCBettorStat = streetStats.find(
s => s.delayedCbetIpAttempts > 0 || s.delayedCbetOopAttempts > 0
);
// --- AGGRESSOR OPPORTUNITIES ---
// A player can limp (call the big blind) if they are first to enter the pot preflop without raising.
if (
// We are on the preflop street
table.street === 'preflop' &&
// No player has made an aggressive action yet
!firstAggressorStat
) {
statEntry.limpOpportunities = 1;
statEntry.preflopRaiseOpportunities = 1;
}
// Preflop Aggression Opportunities
if (table.street === 'preflop') {
const raises = streetAggressions.length;
// A player can 3-bet if there has been exactly one raise before their turn to act.
if (
// There has been exactly one raise on this street
raises === 1 &&
// No player has 3-bet yet
!threeBettorStat
) {
if (firstAggressorStat) {
const aggressorIndex = Game.getPlayerIndex(table, firstAggressorStat.player);
if (isPlayerInPosition(table, playerIndex, aggressorIndex)) {
statEntry.threeBetIpOpportunities = 1;
} else {
statEntry.threeBetOopOpportunities = 1;
}
}
// A player can squeeze if there was a raise and at least one caller before their turn.
const callers = streetStats.filter(s => s.calls > 0);
if (
// At least one player has called the initial raise
callers.length > 0
) {
if (firstAggressorStat) {
const aggressorIndex = Game.getPlayerIndex(table, firstAggressorStat.player);
if (isPlayerInPosition(table, playerIndex, aggressorIndex)) {
statEntry.squeezeIpOpportunities = 1;
} else {
statEntry.squeezeOopOpportunities = 1;
}
}
}
}
// A player can 4-bet if the last aggressive action was a 3-bet.
else if (
// A 3-bet has already occurred
threeBettorStat &&
// No 4-bet has occurred yet
!fourBettorStat
) {
const aggressorIndex = Game.getPlayerIndex(table, threeBettorStat.player);
if (isPlayerInPosition(table, playerIndex, aggressorIndex)) {
statEntry.fourBetIpOpportunities = 1;
} else {
statEntry.fourBetOopOpportunities = 1;
}
}
// A player can 5-bet if the last aggressive action was a 4-bet.
else if (
// A 4-bet has already occurred
fourBettorStat &&
// No 5-bet has occurred yet
!fiveBettorStat
) {
const aggressorIndex = Game.getPlayerIndex(table, fourBettorStat.player);
if (isPlayerInPosition(table, playerIndex, aggressorIndex)) {
statEntry.fiveBetIpOpportunities = 1;
} else {
statEntry.fiveBetOopOpportunities = 1;
}
}
// A player in a late position (CO, BTN, SB) can steal the blinds if the action folds to them.
else if (
// No player has made an aggressive action yet
!firstAggressorStat &&
// The current player is in a steal position (CO, BTN, or SB)
isLatePosition(table, playerId)
) {
const numPlayers = table.players.length;
const buttonIndex = table.buttonIndex;
const isButton = playerIndex === buttonIndex;
const isSmallBlind = playerIndex === table.smallBlindIndex;
const isCutoff =
numPlayers >= 4 && playerIndex === (buttonIndex - 1 + numPlayers) % numPlayers;
if (isButton || isCutoff) {
statEntry.stealIpOpportunities = 1;
} else if (isSmallBlind) {
statEntry.stealOopOpportunities = 1;
}
}
}
const prevStreetName: Street | undefined = Streets[Streets.indexOf(table.street) - 1];
const lastStreetStats = table.stats.filter(s => s.street === prevStreetName);
const prevStreetLastAggressor = lastStreetStats.find(s => s.lastAggressions > 0);
// Postflop Aggression Opportunities
if (
// We are on a postflop street
table.street !== 'preflop' &&
// The current player was the aggressor on the previous street
prevStreetLastAggressor?.player === playerId
) {
const noBetThisStreet = !firstAggressorStat;
// The preflop aggressor can make a continuation bet (C-Bet) on the flop if no one has bet yet.
if (
// We are on the flop
table.street === 'flop' &&
// No bet has been made on this street yet
noBetThisStreet
) {
if (isLastToAct(table, playerIndex)) {
statEntry.cbetIpOpportunities = 1;
} else {
statEntry.cbetOopOpportunities = 1;
}
}
// An aggressor can double or triple barrel by continuing to bet on the turn or river.
else if (
// No bet has been made on this street yet
noBetThisStreet
) {
const doubleBarrel = table.street === 'turn';
const tripleBarrel = table.street === 'river';
if (isLastToAct(table, playerIndex)) {
if (doubleBarrel) statEntry.doubleBarrelIpOpportunities = 1;
if (tripleBarrel) statEntry.tripleBarrelIpOpportunities = 1;
} else {
if (doubleBarrel) statEntry.doubleBarrelOopOpportunities = 1;
if (tripleBarrel) statEntry.tripleBarrelOopOpportunities = 1;
}
}
}
// The preflop aggressor can make a delayed C-Bet on the turn if they checked the flop and no one bet.
const preflopAggressor = table.stats.find(s => s.street === 'preflop' && s.lastAggressions > 0);
if (
// We are on the turn
table.street === 'turn' &&
// The current player was the preflop aggressor
preflopAggressor?.player === playerId &&
// No bet has been made on the turn yet
!firstAggressorStat
) {
const noAggressionsLastStreet = lastStreetStats.every(s => s.aggressions == 0);
if (
// No aggressive actions occurred on the previous street (flop)
noAggressionsLastStreet
) {
if (isLastToAct(table, playerIndex)) {
statEntry.delayedCbetIpOpportunities = 1;
} else {
statEntry.delayedCbetOopOpportunities = 1;
}
}
}
// General Postflop Opportunities
if (table.street !== 'preflop') {
// An OOP player can probe bet if the preflop aggressor checked back on the previous street.
if (
// We are on the turn or river
(table.street === 'turn' || table.street === 'river') &&
// There was a preflop aggressor
preflopAggressor &&
// The current player is not the preflop aggressor
preflopAggressor.player !== playerId &&
// No bet has been made on the current street yet
!firstAggressorStat
) {
const preflopAggressorLastStreetStat = lastStreetStats.find(
s => s.player === preflopAggressor.player
);
if (
// The preflop aggressor checked on the previous street
(preflopAggressorLastStreetStat?.checks || 0) > 0
) {
const aggressorIndex = Game.getPlayerIndex(table, preflopAggressor.player);
if (!isPlayerInPosition(table, playerIndex, aggressorIndex)) {
statEntry.probeBetOpportunities = 1;
}
}
}
// An IP player can float bet if the previous street's aggressor checks to them.
if (
// Opportunity is on the turn
table.street === 'turn' &&
// There was an aggressor on the previous street
prevStreetLastAggressor &&
// The current player is not that aggressor
prevStreetLastAggressor.player !== playerId &&
// The previous street's aggressor checked on the current street
streetStats.some(s => s.player === prevStreetLastAggressor.player && s.checks > 0) &&
// The current player called on the previous street
lastStreetStats.some(s => s.player === playerId && s.calls > 0)
) {
const aggressorIndex = Game.getPlayerIndex(table, prevStreetLastAggressor.player);
if (isPlayerInPosition(table, playerIndex, aggressorIndex)) {
statEntry.floatBetOpportunities = 1;
}
}
// An OOP player can donk bet by betting into the previous street's aggressor before they have acted.
if (
// There was an aggressor on the previous street
prevStreetLastAggressor &&
// The current player is not that aggressor
prevStreetLastAggressor.player !== playerId &&
// The previous street's aggressor has not yet acted on the current street
!streetStats.some(s => s.player === prevStreetLastAggressor.player && s.decisions > 0)
) {
const aggressorIndex = Game.getPlayerIndex(table, prevStreetLastAggressor.player);
if (!isPlayerInPosition(table, playerIndex, aggressorIndex)) {
statEntry.donkBetOpportunities = 1;
}
}
// A player can check-raise if they check and another player bets on the same street.
if (
// The current player has already checked on this street
statEntry.checks > 0 &&
// Another player has made a bet on this street
firstAggressorStat
) {
// Check-raises are always OOP by definition
statEntry.checkRaiseOpportunities = 1;
}
}
// A player can shove if they are not already all-in.
if (
// The player is not already all-in
!currentPlayer.isAllIn
) {
const isIp = isLastToAct(table, playerIndex);
// A player can open shove if they are the first to make an aggressive action on the street.
if (
// No player has made an aggressive action on this street yet
!firstAggressorStat
) {
if (isIp) {
statEntry.openShoveIpOpportunities = 1;
} else {
statEntry.openShoveOopOpportunities = 1;
}
}
// A player faces a challenge when another player makes an aggressive action.
if (isIp) {
statEntry.shoveIpOpportunities = 1;
} else {
statEntry.shoveOopOpportunities = 1;
}
}
// --- DEFENDER OPPORTUNITIES (CHALLENGES) ---
const lastAggressor = streetStats.find(s => s.lastAggressions > 0);
if (
// There was a final aggressor on the current street
lastAggressor &&
// The current player is not that aggressor
lastAggressor.player !== playerId
) {
const aggressorIndex = Game.getPlayerIndex(table, lastAggressor.player);
const isDefenderIp = isPlayerInPosition(table, playerIndex, aggressorIndex);
// Player has an opportunity to float if they are in position against a c-bet or barrel.
if (
table.street !== 'preflop' &&
isDefenderIp &&
(cBettorStat?.player === lastAggressor.player ||
(prevStreetLastAggressor?.player === lastAggressor.player &&
(table.street === 'turn' || table.street === 'river')))
) {
statEntry.floatOpportunities = 1;
}
const checkChallenge = (ipField: keyof StreetStat, oopField: keyof StreetStat) => {
if (isDefenderIp) {
(statEntry[ipField] as number) = 1;
statEntry.challengesInPosition = 1;
} else {
(statEntry[oopField] as number) = 1;
}
};
if (
// Player is facing a 5-bet
lastAggressor.fiveBetIpAttempts ||
lastAggressor.fiveBetOopAttempts
) {
checkChallenge('fiveBetIpChallenges', 'fiveBetOopChallenges');
} else if (
// Player is facing a 4-bet
lastAggressor.fourBetIpAttempts ||
lastAggressor.fourBetOopAttempts
) {
checkChallenge('fourBetIpChallenges', 'fourBetOopChallenges');
}
if (
// Player is facing a 3-bet
lastAggressor.threeBetIpAttempts ||
lastAggressor.threeBetOopAttempts
) {
checkChallenge('threeBetIpChallenges', 'threeBetOopChallenges');
if (
// Player is facing a squeeze (a type of 3-bet)
lastAggressor.squeezeIpAttempts ||
lastAggressor.squeezeOopAttempts
) {
checkChallenge('squeezeIpChallenges', 'squeezeOopChallenges');
}
} else if (
// Player is facing a steal attempt
lastAggressor.stealIpAttempts ||
lastAggressor.stealOopAttempts
) {
checkChallenge('stealIpChallenges', 'stealOopChallenges');
} else if (
// Player is facing a C-Bet
cBettorStat
) {
checkChallenge('cbetIpChallenges', 'cbetOopChallenges');
} else if (
// Player is facing a Delayed C-Bet
delayedCBettorStat
) {
checkChallenge('delayedCbetIpChallenges', 'delayedCbetOopChallenges');
}
if (
// Player is facing a Double Barrel
lastAggressor.doubleBarrelIpAttempts ||
lastAggressor.doubleBarrelOopAttempts
) {
checkChallenge('doubleBarrelIpChallenges', 'doubleBarrelOopChallenges');
}
if (
// Player is facing a Triple Barrel
lastAggressor.tripleBarrelIpAttempts ||
lastAggressor.tripleBarrelOopAttempts
) {
checkChallenge('tripleBarrelIpChallenges', 'tripleBarrelOopChallenges');
}
if (
// Player is facing a Donk Bet
lastAggressor.donkBetAttempts
) {
statEntry.donkBetChallenges = 1;
}
if (
// Player is facing a Probe Bet
lastAggressor.probeBetAttempts
) {
statEntry.probeBetChallenges = 1;
}
if (
// Player is facing a Float Bet
lastAggressor.floatBetAttempts
) {
statEntry.floatBetChallenges = 1;
}
if (
// Player is facing a Check-Raise
lastAggressor.checkRaiseAttempts
) {
statEntry.checkRaiseChallenges = 1;
}
if (
// Player is facing an Open Shove
lastAggressor.openShoveIpAttempts ||
lastAggressor.openShoveOopAttempts
) {
checkChallenge('openShoveIpChallenges', 'openShoveOopChallenges');
}
if (
// Player is facing a generic shove
lastAggressor.shoveIpAttempts ||
lastAggressor.shoveOopAttempts
) {
checkChallenge('shoveIpChallenges', 'shoveOopChallenges');
}
}
}
export const emptyStatValues: Omit<
StreetStat,
| 'gameId'
| 'hand'
| 'table'
| 'player'
| 'createdAt'
| 'street'
| 'venue'
| 'startedAt'
| 'isFinalAction'
> = {
// Meta
decisionDuration: 0,
aggressions: 0,
passivities: 0,
decisions: 0,
// Basic actions
bets: 0,
raises: 0,
calls: 0,
checks: 0,
folds: 0,
allIns: 0,
voluntaryPutMoneyInPotTimes: 0,
firstAggressions: 0,
lastAggressions: 0,
// Limp
limps: 0,
limpOpportunities: 0,
// Preflop Raise (PFR)
preflopRaiseOpportunities: 0,
preflopRaises: 0,
// 3-Bet
threeBetIpOpportunities: 0,
threeBetOopOpportunities: 0,
threeBetIpAttempts: 0,
threeBetOopAttempts: 0,
threeBetIpTakedowns: 0,
threeBetOopTakedowns: 0,
threeBetIpChallenges: 0,
threeBetOopChallenges: 0,
threeBetIpContinues: 0,
threeBetOopContinues: 0,
threeBetIpFolds: 0,
threeBetOopFolds: 0,
// Squeeze
squeezeIpOpportunities: 0,
squeezeOopOpportunities: 0,
squeezeIpAttempts: 0,
squeezeOopAttempts: 0,
squeezeIpTakedowns: 0,
squeezeOopTakedowns: 0,
squeezeIpChallenges: 0,
squeezeOopChallenges: 0,
squeezeIpContinues: 0,
squeezeOopContinues: 0,
squeezeIpFolds: 0,
squeezeOopFolds: 0,
// 4-Bet
fourBetIpOpportunities: 0,
fourBetOopOpportunities: 0,
fourBetIpAttempts: 0,
fourBetOopAttempts: 0,
fourBetIpTakedowns: 0,
fourBetOopTakedowns: 0,
fourBetIpChallenges: 0,
fourBetOopChallenges: 0,
fourBetIpContinues: 0,
fourBetOopContinues: 0,
fourBetIpFolds: 0,
fourBetOopFolds: 0,
// 5-Bet
fiveBetIpOpportunities: 0,
fiveBetOopOpportunities: 0,
fiveBetIpAttempts: 0,
fiveBetOopAttempts: 0,
fiveBetIpTakedowns: 0,
fiveBetOopTakedowns: 0,
fiveBetIpChallenges: 0,
fiveBetOopChallenges: 0,
fiveBetIpContinues: 0,
fiveBetOopContinues: 0,
fiveBetIpFolds: 0,
fiveBetOopFolds: 0,
// C-Bet
cbetIpOpportunities: 0,
cbetOopOpportunities: 0,
cbetIpAttempts: 0,
cbetOopAttempts: 0,
cbetIpTakedowns: 0,
cbetOopTakedowns: 0,
cbetIpChallenges: 0,
cbetOopChallenges: 0,
cbetIpContinues: 0,
cbetOopContinues: 0,
cbetIpFolds: 0,
cbetOopFolds: 0,
// Delayed C-Bet
delayedCbetIpOpportunities: 0,
delayedCbetOopOpportunities: 0,
delayedCbetIpAttempts: 0,
delayedCbetOopAttempts: 0,
delayedCbetIpTakedowns: 0,
delayedCbetOopTakedowns: 0,
delayedCbetIpChallenges: 0,
delayedCbetOopChallenges: 0,
delayedCbetIpContinues: 0,
delayedCbetOopContinues: 0,
delayedCbetIpFolds: 0,
delayedCbetOopFolds: 0,
// Double Barrel
doubleBarrelIpOpportunities: 0,
doubleBarrelOopOpportunities: 0,
doubleBarrelIpAttempts: 0,
doubleBarrelOopAttempts: 0,
doubleBarrelIpTakedowns: 0,
doubleBarrelOopTakedowns: 0,
doubleBarrelIpChallenges: 0,
doubleBarrelOopChallenges: 0,
doubleBarrelIpContinues: 0,
doubleBarrelOopContinues: 0,
doubleBarrelIpFolds: 0,
doubleBarrelOopFolds: 0,
// Triple Barrel
tripleBarrelIpOpportunities: 0,
tripleBarrelOopOpportunities: 0,
tripleBarrelIpAttempts: 0,
tripleBarrelOopAttempts: 0,
tripleBarrelIpTakedowns: 0,
tripleBarrelOopTakedowns: 0,
tripleBarrelIpChallenges: 0,
tripleBarrelOopChallenges: 0,
tripleBarrelIpContinues: 0,
tripleBarrelOopContinues: 0,
tripleBarrelIpFolds: 0,
tripleBarrelOopFolds: 0,
// Probe Bet
probeBetOpportunities: 0,
probeBetAttempts: 0,
probeBetTakedowns: 0,
probeBetChallenges: 0,
probeBetContinues: 0,
probeBetFolds: 0,
// Float Bet
floatBetOpportunities: 0,
floatBetAttempts: 0,
floatBetTakedowns: 0,
floatBetChallenges: 0,
floatBetContinues: 0,
floatBetFolds: 0,
// Float
floatOpportunities: 0,
floatAttempts: 0,
// Steal
stealIpOpportunities: 0,
stealOopOpportunities: 0,
stealIpAttempts: 0,
stealOopAttempts: 0,
stealIpTakedowns: 0,
stealOopTakedowns: 0,
stealIpChallenges: 0,
stealOopChallenges: 0,
stealIpContinues: 0,
stealOopContinues: 0,
stealIpFolds: 0,
stealOopFolds: 0,
// Donk Bet
donkBetOpportunities: 0,
donkBetAttempts: 0,
donkBetTakedowns: 0,
donkBetChallenges: 0,
donkBetContinues: 0,
donkBetFolds: 0,
// Check-Raise
checkRaiseOpportunities: 0,
checkRaiseAttempts: 0,
checkRaiseTakedowns: 0,
checkRaiseChallenges: 0,
checkRaiseContinues: 0,
checkRaiseFolds: 0,
// Shove
openShoveIpOpportunities: 0,
openShoveOopOpportunities: 0,
openShoveIpAttempts: 0,
openShoveOopAttempts: 0,
openShoveIpTakedowns: 0,
openShoveOopTakedowns: 0,
openShoveIpChallenges: 0,
openShoveOopChallenges: 0,
openShoveIpContinues: 0,
openShoveOopContinues: 0,
openShoveIpFolds: 0,
openShoveOopFolds: 0,
shoveIpOpportunities: 0,
shoveOopOpportunities: 0,
shoveIpAttempts: 0,
shoveOopAttempts: 0,
shoveIpTakedowns: 0,
shoveOopTakedowns: 0,
shoveIpChallenges: 0,
shoveOopChallenges: 0,
shoveIpContinues: 0,
shoveOopContinues: 0,
shoveIpFolds: 0,
shoveOopFolds: 0,
// Retroactive
success: 0,
// Positional flags (simple counters)
aggressionsInPosition: 0,
challengesInPosition: 0,
// Finance
wentToShowdown: 0,
wonAtShowdown: 0,
wonWithoutShowdown: 0,
pot: 0,
sawFlop: 0,
stackBefore: 0,
stackAfter: 0,
bigBlind: 0,
won: 0,
lost: 0,
currency: 'USD',
currencyRate: 1,
investments: 0,
returns: 0,
profits: 0,
balance: 0,
winnings: 0,
losses: 0,
rake: 0,
};
export const emptyStat: StreetStat = {
...emptyStatValues,
street: 'preflop',
createdAt: Date.now(),
hand: 0,
table: '',
venue: 'Virtual',
player: '',
startedAt: Date.now(),
isFinalAction: 0,
};
/**
* Helper function to get or create a stat entry for a player and street
* Also updates opportunity-based statistics
*/
export function getOrCreatePlayerStreetStats(
table: Game,
playerId: string | number,
street: Street
): StreetStat {
playerId = Game.getPlayerName(table, playerId);
if (!table.stats) {
table.stats = [];
}
// Try to find existing stat
let statEntry = table.stats.find(s => s.player === playerId && s.street === street);
if (!statEntry) {
// Create new stat entry for this player and street
statEntry = {
...emptyStatValues,
createdAt: Date.now(),
startedAt: table.stats[0]?.startedAt ?? Date.now(),
isFinalAction: 0,
street,
bigBlind: table.bigBlind ?? 0,
player: playerId,
hand: table.hand,
table: table.table,
venue: table.venue,
};
table.stats.push(statEntry);
}
return statEntry;
}
export function getTablePlayerByName(table: Game, playerId: string | number): Player {
return table.players[Game.getPlayerIndex(table, playerId)];
}
export function getPlayer(table: Game, playerId: string | number): string {
if (typeof playerId === 'number') {
return table.players[playerId].name;
}
return playerId;
}
export function recordStatsBefore(table: Game, playerId: string | number) {
const statEntry = createStatsEntry(table, playerId);
// Update opportunity stats every time we access the entry
updateOpportunityStats(table, playerId, statEntry);
}
export function createStatsEntry(table: Game, playerId: string | number): StreetStat {
playerId = Game.getPlayerName(table, playerId);
const statEntry = getOrCreatePlayerStreetStats(table, playerId, table.street);
const player = table.players.find(p => p.name === playerId)!;
if (statEntry.decisions === 0) {
statEntry.investments = player.roundInvestments;
statEntry.stackBefore = player.stack + (table.street === 'preflop' ? player.roundBet : 0);
}
return statEntry;
}
/**
* Records statistics after an action is applied to the table
* This handles all action-based statistics (bets, raises, etc.)
*/
export function recordStatsAfter(table: Game, action: Action): void {
if (!isPlayerAction(action)) return;
const playerIndex = getActionPlayerIndex(action);
if (playerIndex === undefined || playerIndex < 0 || playerIndex >= table.players.length) return;
const playerId = Game.getPlayerName(table, playerIndex);
const currentPlayer = table.players[playerIndex];
const actionType = getActionType(action);
let statEntry = getOrCreatePlayerStreetStats(table, playerIndex, table.street);
if (table.street === 'flop' && statEntry.decisions === 0) {
statEntry.sawFlop = 1;
}
// Always update stack after any action
statEntry.stackAfter = currentPlayer.stack;
const streetStats = table.stats.filter(s => s.street === table.street && s.player !== playerId);
const timestamp = new Date(getActionTimestamp(action) ?? Date.now());
if (timestamp) {
statEntry.createdAt ||= Number(timestamp);
statEntry.decisionDuration += Number(timestamp) - (table.lastTimestamp || 0);
}
const handleFold = () => {
statEntry.folds = 1;
if (statEntry.fiveBetIpChallenges || statEntry.fiveBetOopChallenges) {
if (statEntry.fiveBetIpChallenges) statEntry.fiveBetIpFolds = 1;
else statEntry.fiveBetOopFolds = 1;
}
if (statEntry.threeBetIpChallenges || statEntry.threeBetOopChallenges) {
if (statEntry.threeBetIpChallenges) statEntry.threeBetIpFolds = 1;
else statEntry.threeBetOopFolds = 1;
}
if (statEntry.squeezeIpChallenges || statEntry.squeezeOopChallenges) {
if (statEntry.squeezeIpChallenges) statEntry.squeezeIpFolds = 1;
else statEntry.squeezeOopFolds = 1;
}
if (statEntry.fourBetIpChallenges || statEntry.fourBetOopChallenges) {
if (statEntry.fourBetIpChallenges) statEntry.fourBetIpFolds = 1;
else statEntry.fourBetOopFolds = 1;
}
if (statEntry.cbetIpChallenges || statEntry.cbetOopChallenges) {
if (statEntry.cbetIpChallenges) statEntry.cbetIpFolds = 1;
else statEntry.cbetOopFolds = 1;
}
if (statEntry.delayedCbetIpChallenges || statEntry.delayedCbetOopChallenges) {
if (statEntry.delayedCbetIpChallenges) statEntry.delayedCbetIpFolds = 1;
else statEntry.delayedCbetOopFolds = 1;
}
if (statEntry.doubleBarrelIpChallenges || statEntry.doubleBarrelOopChallenges) {
if (statEntry.doubleBarrelIpChallenges) statEntry.doubleBarrelIpFolds = 1;
else statEntry.doubleBarrelOopFolds = 1;
}
if (statEntry.tripleBarrelIpChallenges || statEntry.tripleBarrelOopChallenges) {
if (statEntry.tripleBarrelIpChallenges) statEntry.tripleBarrelIpFolds = 1;
else statEntry.tripleBarrelOopFolds = 1;
}
if (statEntry.stealIpChallenges || statEntry.stealOopChallenges) {
if (statEntry.stealIpChallenges) statEntry.stealIpFolds = 1;
else statEntry.stealOopFolds = 1;
}
if (statEntry.donkBetChallenges) {
statEntry.donkBetFolds = 1;
}
if (statEntry.probeBetChallenges) {
statEntry.probeBetFolds = 1;
}
if (statEntry.floatBetChallenges) {
statEntry.floatBetFolds = 1;
}
if (statEntry.checkRaiseChallenges) {
statEntry.checkRaiseFolds = 1;
}
if (statEntry.openShoveIpChallenges || statEntry.openShoveOopChallenges) {
if (statEntry.openShoveIpChallenges) statEntry.openShoveIpFolds = 1;
else statEntry.openShoveOopFolds = 1;
}
if (statEntry.shoveIpChallenges || statEntry.shoveOopChallenges) {
if (statEntry.shoveIpChallenges) statEntry.shoveIpFolds = 1;
else statEntry.shoveOopFolds = 1;
}
};
const handleContinue = () => {
if (statEntry.fiveBetIpChallenges || statEntry.fiveBetOopChallenges) {
if (statEntry.fiveBetIpChallenges) statEntry.fiveBetIpContinues = 1;
else statEntry.fiveBetOopContinues = 1;
}
if (statEntry.threeBetIpChallenges || statEntry.threeBetOopChallenges) {
if (statEntry.threeBetIpChallenges) statEntry.threeBetIpContinues = 1;
else statEntry.threeBetOopContinues = 1;
}
if (statEntry.squeezeIpChallenges || statEntry.squeezeOopChallenges) {
if (statEntry.squeezeIpChallenges) statEntry.squeezeIpContinues = 1;
else statEntry.squeezeOopContinues = 1;
}
if (statEntry.fourBetIpChallenges || statEntry.fourBetOopChallenges) {
if (statEntry.fourBetIpChallenges) statEntry.fourBetIpContinues = 1;
else statEntry.fourBetOopContinues = 1;
}
if (statEntry.cbetIpChallenges || statEntry.cbetOopChallenges) {
if (statEntry.cbetIpChallenges) statEntry.cbetIpContinues = 1;
else statEntry.cbetOopContinues = 1;
}
if (statEntry.delayedCbetIpChallenges || statEntry.delayedCbetOopChallenges) {
if (statEntry.delayedCbetIpChallenges) statEntry.delayedCbetIpContinues = 1;
else statEntry.delayedCbetOopContinues = 1;
}
if (statEntry.doubleBarrelIpChallenges || statEntry.doubleBarrelOopChallenges) {
if (statEntry.doubleBarrelIpChallenges) statEntry.doubleBarrelIpContinues = 1;
else statEntry.doubleBarrelOopContinues = 1;
}
if (statEntry.tripleBarrelIpChallenges || statEntry.tripleBarrelOopChallenges) {
if (statEntry.tripleBarrelIpChallenges) statEntry.tripleBarrelIpContinues = 1;
else statEntry.tripleBarrelOopContinues = 1;
}
if (statEntry.stealIpChallenges || statEntry.stealOopChallenges) {
if (statEntry.stealIpChallenges) statEntry.stealIpContinues = 1;
else statEntry.stealOopContinues = 1;
}
if (statEntry.donkBetChallenges) {
statEntry.donkBetContinues = 1;
}
if (statEntry.probeBetChallenges) {
statEntry.probeBetContinues = 1;
}
if (statEntry.floatBetChallenges) {
statEntry.floatBetContinues = 1;
}
if (statEntry.checkRaiseChallenges) {
statEntry.checkRaiseContinues = 1;
}
if (statEntry.openShoveIpChallenges || statEntry.openShoveOopChallenges) {
if (statEntry.openShoveIpChallenges) statEntry.openShoveIpContinues = 1;
else statEntry.openShoveOopContinues = 1;
}
if (statEntry.shoveIpChallenges || statEntry.shoveOopChallenges) {
if (statEntry.shoveIpChallenges) statEntry.shoveIpContinues = 1;
else statEntry.shoveOopContinues = 1;
}
};
if (actionType === 'f') {
statEntry.decisions++;
statEntry.losses = currentPlayer.totalBet;
statEntry.lost = 1;
handleFold();
}
if (currentPlayer.isAllIn && !table.stats.find(s => s.allIns && s.player === playerId)) {
statEntry.allIns = 1;
}
statEntry.investments = currentPlayer.roundInvestments;
statEntry.balance = statEntry.stackAfter - statEntry.stackBefore;
// Handle call/check
if (actionType === 'cc') {
statEntry.decisions++;
statEntry.passivities++;
const hasBeenAggression =
streetStats.some(s => s.aggressions > 0) || table.street === 'preflop';
if (hasBeenAggression) {
statEntry.calls++;
if (statEntry.floatOpportunities > 0) {
statEntry.floatAttempts = 1;
}
handleContinue();
} else {
statEntry.checks++;
}
if (statEntry.limpOpportunities > 0 && statEntry.calls === 1 && statEntry.decisions == 1) {
statEntry.limps++;
}
if (table.street === 'preflop' && statEntry.calls > 0) {
statEntry.voluntaryPutMoneyInPotTimes = 1;
}
}
// Handle bet/raise
else if (actionType === 'cbr') {
statEntry.decisions++;
// Check for prior aggression BEFORE incrementing the current aggression count
const hasPriorAggression = streetStats.some(s => s.aggressions > 0);
statEntry.aggressions++;
handleContinue(); // Raising is also a form of continuing
if (!hasPriorAggression) {
statEntry.firstAggressions = 1;
if (table.street === 'preflop' && statEntry.preflopRaiseOpportunities > 0) {
statEntry.preflopRaises = 1;
}
}
// Clear previous last aggressor on this street
streetStats.forEach(s => (s.lastAggressions = 0));
statEntry.lastAggressions = 1;
// Determine if it's a raise or a bet
if (table.street === 'preflop' || hasPriorAggression) {
statEntry.raises++;
} else {
statEntry.bets++;
}
// --- Classify the Aggressive Action ---
// Note: These are not mutually exclusive. A single action can be a 3-bet, a squeeze, etc.
// 5-Bet
if (statEntry.fiveBetIpOpportunities || statEntry.fiveBetOopOpportunities) {
if (statEntry.fiveBetIpOpportunities) {
statEntry.fiveBetIpAttempts = 1;
statEntry.aggressionsInPosition = 1;
} else {
statEntry.fiveBetOopAttempts = 1;
}
}
// 4-Bet
else if (statEntry.fourBetIpOpportunities || statEntry.fourBetOopOpportunities) {
if (statEntry.fourBetIpOpportunities) {
statEntry.fourBetIpAttempts = 1;
statEntry.aggressionsInPosition = 1;
} else {
statEntry.fourBetOopAttempts = 1;
}
}
// Squeeze (is a form of 3-bet, so we check it first)
if (statEntry.squeezeIpOpportunities || statEntry.squeezeOopOpportunities) {
if (statEntry.squeezeIpOpportunities) {
statEntry.squeezeIpAttempts = 1;
statEntry.threeBetIpAttempts = 1; // Squeeze is a type of 3-bet
statEntry.aggressionsInPosition = 1;
} else {
statEntry.squeezeOopAttempts = 1;
statEntry.threeBetOopAttempts = 1; // Squeeze is a type of 3-bet
}
}
// 3-Bet (if not already counted as a squeeze)
else if (statEntry.threeBetIpOpportunities || statEntry.threeBetOopOpportunities) {
if (statEntry.threeBetIpOpportunities) {
statEntry.threeBetIpAttempts = 1;
statEntry.aggressionsInPosition = 1;
} else {
statEntry.threeBetOopAttempts = 1;
}
}
// Steal
if (statEntry.stealIpOpportunities || statEntry.stealOopOpportunities) {
if (isStealAttempt(table, getActionAmount(action))) {
if (statEntry.stealIpOpportunities) {
statEntry.stealIpAttempts = 1;
statEntry.aggressionsInPosition = 1;
} else {
statEntry.stealOopAttempts = 1;
}
}
}
// C-Bet
if (statEntry.cbetIpOpportunities || statEntry.cbetOopOpportunities) {
if (statEntry.cbetIpOpportunities) {
statEntry.cbetIpAttempts = 1;
statEntry.aggressionsInPosition = 1;
} else {
statEntry.cbetOopAttempts = 1;
}
}
// Delayed C-Bet, Double/Triple Barrels, etc.
if (statEntry.delayedCbetIpOpportunities || statEntry.delayedCbetOopOpportunities) {
if (statEntry.delayedCbetIpOpportunities) {
statEntry.delayedCbetIpAttempts = 1;
statEntry.aggressionsInPosition = 1;
} else {
statEntry.delayedCbetOopAttempts = 1;
}
}
if (statEntry.doubleBarrelIpOpportunities || statEntry.doubleBarrelOopOpportunities) {
if (statEntry.doubleBarrelIpOpportunities) {
statEntry.doubleBarrelIpAttempts = 1;
statEntry.aggressionsInPosition = 1;
} else {
statEntry.doubleBarrelOopAttempts = 1;
}
}
if (statEntry.tripleBarrelIpOpportunities || statEntry.tripleBarrelOopOpportunities) {
if (statEntry.tripleBarrelIpOpportunities) {
statEntry.tripleBarrelIpAttempts = 1;
statEntry.aggressionsInPosition = 1;
} else {
statEntry.tripleBarrelOopAttempts = 1;
}
}
// Postflop OOP Maneuvers
if (statEntry.checkRaiseOpportunities) {
statEntry.checkRaiseAttempts = 1;
}
if (statEntry.donkBetOpportunities) {
statEntry.donkBetAttempts = 1;
}
if (statEntry.probeBetOpportunities) {
statEntry.probeBetAttempts = 1;
}
if (statEntry.floatBetOpportunities) {
statEntry.floatBetAttempts = 1;
statEntry.aggressionsInPosition = 1;
}
// Shoves
if (statEntry.openShoveIpOpportunities || statEntry.openShoveOopOpportunities) {
if (statEntry.openShoveIpOpportunities) {
statEntry.openShoveIpAttempts = 1;
statEntry.aggressionsInPosition = 1;
} else {
statEntry.openShoveOopAttempts = 1;
}
}
if (currentPlayer.isAllIn) {
if (statEntry.shoveIpOpportunities || statEntry.shoveOopOpportunities) {
if (statEntry.shoveIpOpportunities) statEntry.shoveIpAttempts = 1;
else statEntry.shoveOopAttempts = 1;
}
}
statEntry.voluntaryPutMoneyInPotTimes = 1;
}
}
// Record final stacks and calculate winnings for all players at showdown
export function recordStatsFinish(table: Game) {
if (!table.isComplete) {
return;
}
// Check for takedowns - assign to the last aggressor if hand ended without showdown
if (!table.isShowdown) {
// Find the last aggressive action on any street
const allAggressions = table.stats.filter(s => s.aggressions > 0 && s.street == table.street);
if (allAggressions.length > 0) {
const lastAggressorStat = allAggressions.find(s => s.lastAggressions > 0)!;
const assignTakedown = (
ipAttempts: keyof StreetStat,
oopAttempts: keyof StreetStat,
ipTakedowns: keyof StreetStat,
oopTakedowns: keyof StreetStat
) => {
if (lastAggressorStat[ipAttempts]) {
(lastAggressorStat[ipTakedowns] as number) = 1;
return true;
}
if (lastAggressorStat[oopAttempts]) {
(lastAggressorStat[oopTakedowns] as number) = 1;
return true;
}
return false;
};
const assignTakedownSimple = (attempts: keyof StreetStat, takedowns: keyof StreetStat) => {
if (lastAggressorStat[attempts]) {
(lastAggressorStat[takedowns] as number) = 1;
return true;
}
return false;
};
assignTakedown(
'fiveBetIpAttempts',
'fiveBetOopAttempts',
'fiveBetIpTakedowns',
'fiveBetOopTakedowns'
);
assignTakedown(
'fourBetIpAttempts',
'fourBetOopAttempts',
'fourBetIpTakedowns',
'fourBetOopTakedowns'
);
assignTakedown(
'threeBetIpAttempts',
'threeBetOopAttempts',
'threeBetIpTakedowns',
'threeBetOopTakedowns'
);
assignTakedown(
'squeezeIpAttempts',
'squeezeOopAttempts',
'squeezeIpTakedowns',
'squeezeOopTakedowns'
);
assignTakedown('cbetIpAttempts', 'cbetOopAttempts', 'cbetIpTakedowns', 'cbetOopTakedowns');
assignTakedown(
'delayedCbetIpAttempts',
'delayedCbetOopAttempts',
'delayedCbetIpTakedowns',
'delayedCbetOopTakedowns'
);
assignTakedown(
'doubleBarrelIpAttempts',
'doubleBarrelOopAttempts',
'doubleBarrelIpTakedowns',
'doubleBarrelOopTakedowns'
);
assignTakedown(
'tripleBarrelIpAttempts',
'tripleBarrelOopAttempts',
'tripleBarrelIpTakedowns',
'tripleBarrelOopTakedowns'
);
assignTakedown(
'stealIpAttempts',
'stealOopAttempts',
'stealIpTakedowns',
'stealOopTakedowns'
);
assignTakedownSimple('donkBetAttempts', 'donkBetTakedowns');
assignTakedownSimple('probeBetAttempts', 'probeBetTakedowns');
assignTakedownSimple('floatBetAttempts', 'floatBetTakedowns');
assignTakedownSimple('checkRaiseAttempts', 'checkRaiseTakedowns');
assignTakedown(
'openShoveIpAttempts',
'openShoveOopAttempts',
'openShoveIpTakedowns',
'openShoveOopTakedowns'
);
assignTakedown(
'shoveIpAttempts',
'shoveOopAttempts',
'shoveIpTakedowns',
'shoveOopTakedowns'
);
}
}
table.players.forEach((player, idx) => {
if (player.isInactive) return;
const preflopStats = getOrCreatePlayerStreetStats(table, idx, 'preflop');
const playerId = Game.getPlayerName(table, idx);
const lastPlayerStat =
table.stats.filter(s => s.player === playerId && s.decisions > 0).pop()! ||
// if all other players folded preflop
table.stats.filter(s => s.player === playerId).pop();
if (table.isShowdown) {
if (player.hasShownCards) {
lastPlayerStat.wentToShowdown = 1;
}
}
// Calculate winnings using preflop stackBefore
const stackDiff = player.stack - preflopStats.stackBefore;
// Update final stack
lastPlayerStat.stackAfter = player.stack;
lastPlayerStat.returns = player.returns;
lastPlayerStat.winnings = player.winnings;
lastPlayerStat.profits = Math.max(0, stackDiff);
lastPlayerStat.losses = Math.max(0, -stackDiff);
lastPlayerStat.rake += player.rake;
lastPlayerStat.balance = lastPlayerStat.stackAfter - lastPlayerStat.stackBefore;
lastPlayerStat.investments ||= player.roundInvestments;
if (stackDiff > 0) {
Streets.map(street => {
const streetStats = getPlayerStreetStats(table, idx, street);
if (streetStats) {
streetStats.success = 1;
}
});
if (table.isShowdown) {
lastPlayerStat.wonAtShowdown = 1;
} else {
lastPlayerStat.wonWithoutShowdown = 1;
}
lastPlayerStat.won = 1;
} else if (stackDiff < 0) {
lastPlayerStat.lost = 1;
}
});
// Find the single stat entry corresponding to the very last action of the hand
// and assign game-level stats to it.
const allDecisionStats = table.stats.filter(s => s.decisions > 0);
const finalActionStat = allDecisionStats.pop(); // .pop() gets the last element
if (finalActionStat) {
finalActionStat.pot = table.pot;
finalActionStat.isFinalAction = 1;
}
}
/**
* Extracts player statistics for a specific player and street
*/
export function getPlayerStreetStats(
table: Game,
playerId: string | number,
street: Street
): StreetStat | undefined {
playerId = Game.getPlayerName(table, playerId);
return table.stats.find(s => s.player === playerId && s.street === street);
}