@idealic/poker-engine
Version:
Poker game engine and hand evaluator
1,398 lines (1,206 loc) • 73.7 kB
text/typescript
import { Command } from './Command';
import { Game } from './Game';
import {
getActionAmount,
getActionCards,
getActionPlayerIndex,
getActionTimestamp,
getActionType,
} from './game/position';
import { ensureSeatOrder } from './game/seats';
import {
Action,
ACTION_DEAL_BOARD,
ACTION_DEAL_HOLE,
ACTION_MESSAGE,
FixedLimitHand,
NoLimitHand,
PlayerIdentifier,
StudHand,
} from './types';
/** Hand type representing a poker hand */
export type Hand = NoLimitHand | FixedLimitHand | StudHand;
/**
* Creates a new Hand object with the given properties and extras.
* @param props - The properties to set on the Hand object.
* @param extras - The extra properties to set on the Hand object.
* @returns A new Hand object.
*/
export function Hand(props: Partial<Hand>, extras: Partial<Hand> = {}): Hand {
const merged = {
actions: [],
...props,
...extras,
};
Hand.validate(merged);
return merged as Hand;
}
/**
* Hand namespace - Data notation layer for poker engine
* Provides standardized format for representing game states through action sequences
*/
export namespace Hand {
/**
* Get venue-specific player ID from _venueIds array
* @param hand - Hand object
* @param playerIdentifier - Player index (0-based) or name
* @returns Venue ID for the player or null if not found or _venueIds missing
*/
export function getPlayerId(hand: Hand, playerIdentifier: PlayerIdentifier): string | null {
const index = getPlayerIndex(hand, playerIdentifier);
// Return null if player not found
if (index === -1) {
return null;
}
// Check if _venueIds exists and has value at index
if (!Array.isArray(hand._venueIds)) {
return null;
}
const venueId = hand._venueIds[index];
return typeof venueId === 'string' ? venueId : null;
}
/**
* Get player index (0-based) for a given identifier
* @param hand - Hand object
* @param playerIdentifier - Player index or name
* @returns Player index (0-based) or -1 if not found
*/
export function getPlayerIndex(hand: Hand, playerIdentifier: PlayerIdentifier): number {
// Handle numeric index
if (typeof playerIdentifier === 'number') {
// Check bounds
if (playerIdentifier < 0 || playerIdentifier >= hand.players.length) {
return -1;
}
return playerIdentifier;
}
// Handle string name - simple indexOf for string array
if (typeof playerIdentifier === 'string') {
return hand.players.indexOf(playerIdentifier);
}
return -1;
}
/**
* Returns the index of the perspective player for table operations, or -1 if not found.
* Essential for player-specific views.
* @param hand - Hand object with potential author field
* @returns Player index (0-based) of the author, or -1 if not found
*/
export function getAuthorPlayerIndex(hand: Hand): number {
// Check if we have an author field
const author = hand.author;
if (!author) return -1;
// Get players array
const players = hand.players;
if (!players || players.length === 0) return -1;
// For Hand objects, players are strings
if (typeof author === 'string') {
const index = players.indexOf(author);
return index;
}
return -1;
}
/**
* Checks if the hand has enough active players to start a game.
* A game requires at least 2 active players who:
* - Are not inactive (_inactive === 0 or undefined)
* - Have chips to play (startingStacks > 0)
* @param hand - Hand object to check
* @returns true if 2 or more players can play, false otherwise
*/
export function isPlayable(hand: Hand): boolean {
// Count players who can play (active + have chips)
let playableCount = 0;
for (let i = 0; i < hand.players.length; i++) {
// Check if player is active (no _inactive array means all active)
const isActive = !Array.isArray(hand._inactive) || hand._inactive[i] === 0;
// Check if player has chips
const hasChips = (hand.startingStacks[i] ?? 0) > 0;
if (isActive && hasChips) {
playableCount++;
// Early exit: once we have 2 playable, we're good
if (playableCount >= 2) {
return true;
}
}
}
return false;
}
/**
* Starts a game and ensures blinds are correctly posted.
* @param hand - Hand object to start
* @returns Hand ready to play
*/
export function start(hand: Hand): Hand {
// Check if game can be played
if (!isPlayable(hand)) {
return Hand(hand);
}
// Get BB value from minBet (required for no-limit games)
const bbValue = hand.minBet;
if (!bbValue || bbValue <= 0) {
return Hand(hand);
}
const sbValue = Math.floor(bbValue / 2);
// Find playable player indices (active + has chips)
const playableIndices: number[] = [];
const inactive = hand._inactive ?? [];
for (let i = 0; i < hand.players.length; i++) {
const isActive = inactive[i] === 0 || inactive[i] === undefined;
const hasChips = (hand.startingStacks[i] ?? 0) > 0;
if (isActive && hasChips) {
playableIndices.push(i);
}
}
// Find existing BB position (preserves button position)
const currentBBIndex = hand.blindsOrStraddles.findIndex(b => b === bbValue);
// Determine BB position: use existing or default to second playable
const bbIndex = currentBBIndex >= 0 ? currentBBIndex : playableIndices[1];
// Determine SB position: left of BB among playable players
let sbIndex: number;
if (playableIndices.length === 2) {
// Heads-up: SB is the other player
sbIndex = playableIndices[0] === bbIndex ? playableIndices[1] : playableIndices[0];
} else {
// 3+ players: SB is the playable player immediately left of BB
const bbPlayablePos = playableIndices.indexOf(bbIndex);
const sbPlayablePos = bbPlayablePos <= 0 ? playableIndices.length - 1 : bbPlayablePos - 1;
sbIndex = playableIndices[sbPlayablePos];
}
// Assign blinds
const newBlinds = new Array(hand.players.length).fill(0);
newBlinds[sbIndex] = sbValue;
newBlinds[bbIndex] = bbValue;
return Hand({
...hand,
blindsOrStraddles: newBlinds,
});
}
/**
* Applies an action to a game state.
* @param hand - The hand state to apply the action to
* @param action - The action to apply
* @returns The updated game state with finishing data when hand completes
*/
export function applyAction(hand: Hand, action: Action): Hand {
// Step 1: Create Game from Hand
const game = Game(hand);
// Step 2: Apply action (mutates game object)
Game.applyAction(game, action);
// Step 3: Create new Hand with action appended
const newHand: Hand = {
...hand,
actions: [...hand.actions, action],
};
// Step 4: If hand is complete, extract finishing state from Game
if (game.isComplete) {
return Hand(Game.finish(game, newHand));
}
return Hand(newHand);
}
/**
* Tries to merge two game states, assuming they are from the same table, have common players and are in the same hand.
* Preserves known card information over hidden cards during merge.
* Supports sit-in/out functionality through _intents field modifications.
* @param oldHand - The first game state (typically server/authoritative state)
* @param newHand - The second game state (typically client/incoming state)
* @param allowUnsafeMerge - Whether to allow unsafe merge authorless state
* @returns The merged game state, or oldHand if incompatible
*/
export function merge(oldHand: Hand, newHand: Hand, allowUnsafeMerge: boolean = false): Hand {
// Security: If author is set, never allow unsafe merge regardless of parameter
const effectiveAllowUnsafeMerge = newHand.author ? false : allowUnsafeMerge;
// Step 0: Basic structure validation
try {
Hand.validate(newHand);
} catch (e) {
return oldHand; // Invalid hand structure
}
// Step 0a: Validate state combinations - reject invalid states
if (!isValidStateCombination(newHand)) {
return oldHand;
}
// Initialize _inactive if missing or copy existing
if (!Array.isArray(oldHand._inactive)) {
oldHand._inactive = new Array(oldHand.players.length).fill(0);
} else {
// Copy existing _inactive
oldHand._inactive = [...oldHand._inactive];
}
// Initialize _deadBlinds if missing or copy existing
if (!Array.isArray(oldHand._deadBlinds)) {
oldHand._deadBlinds = new Array(oldHand.players.length).fill(0);
} else {
// Copy existing _deadBlinds
oldHand._deadBlinds = [...oldHand._deadBlinds];
}
// Step 0a: Check if author is inactive - they can ONLY change their intent
if (newHand.author) {
const authorIdx = getAuthorPlayerIndex(newHand);
if (
authorIdx >= 0 &&
authorIdx < oldHand._inactive.length &&
oldHand._inactive[authorIdx] === 1
) {
// Inactive player can ONLY change their intent, nothing else
// Create merged hand with only intent change
let mergedIntents = Array.isArray(oldHand._intents) ? [...oldHand._intents] : undefined;
let mergedInactive = Array.isArray(oldHand._inactive) ? [...oldHand._inactive] : undefined;
if (Array.isArray(newHand._intents) && authorIdx < newHand._intents.length) {
const newIntent = newHand._intents[authorIdx];
// Check if player is already leaving (intent = 3) - cannot change
if (Array.isArray(oldHand._intents) && oldHand._intents[authorIdx] === 3) {
// Player already decided to leave - keep original state
return oldHand;
}
if (newIntent >= 0 && newIntent <= 3) {
if (!mergedIntents) {
mergedIntents = new Array(oldHand.players.length).fill(0);
}
mergedIntents[authorIdx] = newIntent;
// Update _inactive based on intent change
// If intent is 0 (resume), mark as active for NEXT hand
// If intent is non-zero, keep as inactive
if (!mergedInactive) {
mergedInactive = new Array(oldHand.players.length).fill(0);
}
// Keep them inactive this hand regardless of intent change
mergedInactive[authorIdx] = 1;
}
}
// Return oldHand with only intent and inactive modified
return Hand({
...oldHand,
_intents: mergedIntents,
_inactive: mergedInactive,
author: undefined,
});
}
}
// Step 1: Check if this is a sit-in scenario (new player joining)
const authorName = newHand.author;
const isNewPlayerJoining = authorName && !oldHand.players.includes(authorName);
// Step 1a: Modified compatibility check - skip player array comparison for sit-in
if (!isNewPlayerJoining && !areHandsCompatible(oldHand, newHand)) {
return oldHand;
}
if (isNewPlayerJoining) {
const authorIndex = getAuthorPlayerIndex(newHand);
// Validate that author exists in the new players array
if (authorIndex === -1 || authorIndex >= newHand.players.length) {
return oldHand;
}
const newPlayerName = newHand.players[authorIndex];
// Validate starting stack is positive
if (newHand.startingStacks[authorIndex] <= 0) {
// Invalid stack amount - must be positive
return oldHand;
}
// Validate all required arrays have correct length
if (
newHand.startingStacks.length !== newHand.players.length ||
newHand.blindsOrStraddles.length !== newHand.players.length ||
newHand.antes.length !== newHand.players.length
) {
return oldHand;
}
// Validate seats if present
if (Array.isArray(newHand.seats)) {
if (newHand.seats.length !== newHand.players.length) {
return oldHand;
}
const newSeat = newHand.seats[authorIndex];
// Validate seat is within table range
if (oldHand.seatCount && (newSeat < 1 || newSeat > oldHand.seatCount)) {
return oldHand;
}
// Check for duplicate seats
if (Array.isArray(oldHand.seats) && oldHand.seats.includes(newSeat)) {
return oldHand;
}
}
// Validate critical fields match even for sit-in
const criticalFields: (keyof Hand)[] = ['variant', 'venue', 'currency', 'table', 'hand'];
for (const field of criticalFields) {
if (
oldHand[field] !== undefined &&
newHand[field] !== undefined &&
oldHand[field] !== newHand[field]
) {
return oldHand;
}
}
// Build arrays by expanding oldHand arrays and adding ONLY the author's data at authorIndex
// New players always get blindsOrStraddles = 0, advance() assigns blinds when game starts
const blindsOrStraddles: number[] = [...oldHand.blindsOrStraddles, 0];
const mergedHand: Hand = {
...oldHand,
players: [...oldHand.players, newPlayerName],
startingStacks: [...oldHand.startingStacks, newHand.startingStacks[authorIndex]],
blindsOrStraddles,
antes: [...oldHand.antes, newHand.antes[authorIndex]],
author: undefined,
};
// Handle optional arrays - ONLY extract author's value at authorIndex
if (Array.isArray(newHand.seats) && newHand.seats[authorIndex] !== undefined) {
if (Array.isArray(oldHand.seats)) {
mergedHand.seats = [...oldHand.seats, newHand.seats[authorIndex]];
} else {
// Create seats array with sequential values for existing players
const seats = new Array(oldHand.players.length).fill(0).map((_, i) => i + 1);
seats.push(newHand.seats[authorIndex]);
mergedHand.seats = seats;
}
} else if (Array.isArray(oldHand.seats)) {
// Preserve existing seats, add next available
const maxSeat = Math.max(...oldHand.seats);
mergedHand.seats = [...oldHand.seats, maxSeat + 1];
}
if (Array.isArray(newHand._venueIds) && newHand._venueIds[authorIndex] !== undefined) {
if (Array.isArray(oldHand._venueIds)) {
mergedHand._venueIds = [...oldHand._venueIds, newHand._venueIds[authorIndex]];
} else {
// Create _venueIds array
const venueIds = new Array(oldHand.players.length)
.fill(undefined)
.map((_, i) => `${oldHand.players[i]}${oldHand.venue ? `@${oldHand.venue}` : ''}`);
venueIds.push(newHand._venueIds[authorIndex]);
mergedHand._venueIds = venueIds;
}
} else if (Array.isArray(oldHand._venueIds)) {
mergedHand._venueIds = [
...oldHand._venueIds,
newHand._venueIds?.[authorIndex] ??
`${newHand.players[authorIndex]}${oldHand.venue ? `@${oldHand.venue}` : ''}`,
];
}
// Handle _intents - ONLY take author's intent value
// Default to 0 (ready to play) if _intents array is missing
const authorIntent = Array.isArray(newHand._intents) ? newHand._intents[authorIndex] : 0;
if (Array.isArray(oldHand._intents)) {
mergedHand._intents = [...oldHand._intents, authorIntent];
} else {
const intents = new Array(oldHand.players.length).fill(0);
intents.push(authorIntent);
mergedHand._intents = intents;
}
// Handle _inactive - determine if players should be activated
// When joining creates enough players to start, activate them
const authorInactive = 2; // Default to inactive (new player state)
if (Array.isArray(oldHand._inactive)) {
mergedHand._inactive = [...oldHand._inactive, authorInactive];
} else {
const inactive = new Array(oldHand.players.length).fill(0);
inactive.push(authorInactive);
mergedHand._inactive = inactive;
}
// Handle _deadBlinds - just expand the array with 0, no calculation
if (Array.isArray(oldHand._deadBlinds)) {
mergedHand._deadBlinds = [...oldHand._deadBlinds, 0];
} else {
const deadBlinds = new Array(oldHand.players.length).fill(0);
deadBlinds.push(0);
mergedHand._deadBlinds = deadBlinds;
}
// Early return for player sit-in scenario - validate generated hand
return Hand(mergedHand);
}
// Step 2: Handle hole actions
const oldHoleActions = oldHand.actions.filter(
action => getActionType(action) === ACTION_DEAL_HOLE
);
const newHoleActions = newHand.actions.filter(
action => getActionType(action) === ACTION_DEAL_HOLE
);
let resultHoleActions: Action[] = [];
for (const oldAction of oldHoleActions) {
const newMatchingHoleAction = newHoleActions.find(
newAction => getActionPlayerIndex(newAction) === getActionPlayerIndex(oldAction)
);
// if there is a new matching hole action, use it to compare cards
if (newMatchingHoleAction) {
// get sorted actions cards
const oldActionCards = getActionCards(oldAction) ?? ['??', '??'];
const newActionCards = getActionCards(newMatchingHoleAction) ?? ['??', '??'];
const resultActionCards: string[] = [...oldActionCards];
// replace any cards only with known cards
// perfer only old cards over new cards
oldActionCards.forEach((card, index) => {
// if oldAction has known card, use it
resultActionCards[index] = card !== '??' ? card : newActionCards[index];
});
// if cards are the same, use old hole action
if (oldActionCards.join('') === newActionCards.join('')) {
resultHoleActions.push(oldAction);
} else {
// if cards are different, use new hole action with replaced cards
resultHoleActions.push(
newMatchingHoleAction.replace(newActionCards.join(''), resultActionCards.join(''))
);
}
} else {
// if no new matching hole action, use old action
resultHoleActions.push(oldAction);
}
}
// new hole actions in new hand, and unsafe merge is allowed
if (newHoleActions.length > oldHoleActions.length && effectiveAllowUnsafeMerge) {
const newHoleActionsToAdd = newHoleActions.reduce((acc, newAction) => {
// if old hole actions doesn't include a hole action for player, which hole action is present in new hand
if (
!oldHoleActions.some(
oldAction => getActionPlayerIndex(oldAction) === getActionPlayerIndex(newAction)
)
) {
// add new hole action to result hole actions
acc.push(newAction);
}
return acc;
}, [] as Action[]);
// add new hole actions to result hole actions
resultHoleActions.push(...newHoleActionsToAdd);
}
// Step 3: Make sure the common prefix actions are fine, and ready to be merged
const oldActions = oldHand.actions || [];
const newActions = newHand.actions || [];
// get common actions and replace dealer hole actions with result hole actions
// to make sure that dealer hole actions have relevant cards
const commonActions = getCommonActions(oldActions, newActions).map(action => {
if (getActionType(action) === ACTION_DEAL_HOLE) {
return (
resultHoleActions.find(
holeAction => getActionPlayerIndex(holeAction) === getActionPlayerIndex(action)
) ?? action
);
}
return action;
});
// Step 4: Add remaining actions
const remainingActions = newActions.slice(commonActions.length);
// Always validate actions strictly - treat all cases as client processing for security
const hasDealerActions = remainingActions.some(action => {
const actionType = getActionType(action);
return actionType === ACTION_DEAL_HOLE || actionType === ACTION_DEAL_BOARD;
});
// Check if there are non-author actions (only relevant if author is specified)
let hasOtherAuthorActions = false;
if (newHand.author) {
hasOtherAuthorActions = remainingActions.some(action => {
const actionType = getActionType(action);
const actionPlayerIndex = getActionPlayerIndex(action);
// Messages are always allowed
if (actionType === ACTION_MESSAGE) {
return false;
}
// If it's the author's action, it's allowed
const authorIndex = Hand.getAuthorPlayerIndex(newHand);
if (actionPlayerIndex === authorIndex) {
return false;
}
// Any other player action is not allowed
return true;
});
} else {
// When author is undefined (server state), check if there are any non-message player actions
// These should be rejected unless allowUnsafeMerge is true
hasOtherAuthorActions = remainingActions.some(action => {
const actionType = getActionType(action);
// Messages are always allowed
return actionType !== ACTION_MESSAGE;
});
}
// Determine if remaining actions should be appended
const hasProblematicActions = hasDealerActions || hasOtherAuthorActions;
const shouldAppendActions = !hasProblematicActions || effectiveAllowUnsafeMerge;
if (shouldAppendActions && remainingActions.length > 0) {
// Check if these are only the author's own actions (no dealer or other player actions)
const isOnlyAuthorActions = newHand.author && !hasDealerActions && !hasOtherAuthorActions;
if (isOnlyAuthorActions) {
// Update timestamp on the last action to current time
const actionsToAppend = [...remainingActions];
const lastIndex = actionsToAppend.length - 1;
const lastAction = actionsToAppend[lastIndex];
// Replace existing timestamp or add new one
const timestampIndex = lastAction.indexOf('#');
if (timestampIndex !== -1) {
// Replace existing timestamp
actionsToAppend[lastIndex] = lastAction.substring(0, timestampIndex) + '#' + Date.now();
} else {
// Add new timestamp
actionsToAppend[lastIndex] = lastAction + ' #' + Date.now();
}
commonActions.push(...actionsToAppend);
} else {
// Append actions without timestamp modification
commonActions.push(...remainingActions);
}
}
// Step 5: Handle intent changes for existing players
let mergedIntents = Array.isArray(oldHand._intents) ? [...oldHand._intents] : undefined;
let mergedInactive = Array.isArray(oldHand._inactive) ? [...oldHand._inactive] : undefined;
if (Array.isArray(newHand._intents) && newHand.author) {
const authorIdx = getAuthorPlayerIndex(newHand);
// Reject if author doesn't exist in players array
if (authorIdx === -1) {
return oldHand;
}
// Author can only change their own intent
if (authorIdx >= 0 && authorIdx < newHand.players.length) {
// Validate intent value (0, 1, 2, or 3)
const newIntent = newHand._intents[authorIdx];
if (newIntent === 0 || newIntent === 1 || newIntent === 2 || newIntent === 3) {
if (!Array.isArray(mergedIntents)) {
mergedIntents = new Array(oldHand.players.length).fill(0);
}
mergedIntents[authorIdx] = newIntent;
// Update _inactive based on intent change
if (!Array.isArray(mergedInactive)) {
mergedInactive = new Array(oldHand.players.length).fill(0);
}
// If intent is 1, 2, or 3 (pause/leave), player stays active
// advance() will handle auto-fold for active players with intent > 0
// We DON'T make them inactive here because they need to auto-fold first
// If intent is 0 (resume) from pause state, player stays inactive until next hand
// (server controls when they become active again)
} else {
// Invalid intent value - return unchanged
return oldHand;
}
}
}
// Step 6: Create merged hand
const mergedHand: Hand = {
...oldHand,
actions: [...commonActions],
author: undefined,
};
// Only include _intents if it exists or was modified
if (mergedIntents) {
mergedHand._intents = mergedIntents;
}
// Include _inactive if it exists or was modified due to intent change
if (mergedInactive) {
mergedHand._inactive = mergedInactive;
}
// Preserve _deadBlinds - this remains server-controlled
if (Array.isArray(oldHand._deadBlinds)) {
mergedHand._deadBlinds = oldHand._deadBlinds;
}
// Validate generated hand
return Hand(mergedHand);
}
// Validate state combinations - reject invalid states
function isValidStateCombination(hand: Hand): boolean {
// Check if _inactive and _deadBlinds arrays exist
if (!Array.isArray(hand._inactive) || !Array.isArray(hand._deadBlinds)) {
// If arrays don't exist, consider it valid (no state to validate)
return true;
}
// Validate that arrays have same length
if (hand._inactive.length !== hand._deadBlinds.length) {
return false;
}
// Note: Active players CAN have dead blinds when returning from pause.
// Game() constructor charges the debt when hand starts.
return true;
}
// Helper function to check if arrays are equal or one is a prefix of another
// Arrays can only be extended (players added), never contracted (players removed)
function isArrayPrefixOrEqual(arr1: any[], arr2: any[]): boolean {
if (!Array.isArray(arr1) || !Array.isArray(arr2)) {
return false;
}
// Case 1: Arrays are equal
if (arr1.length === arr2.length) {
return JSON.stringify(arr1) === JSON.stringify(arr2);
}
// Case 2: arr2 is a prefix of arr1 (client doesn't know about new players)
if (arr2.length < arr1.length) {
return arr2.every(
(element, index) => JSON.stringify(element) === JSON.stringify(arr1[index])
);
}
// Case 3: arr1 is a prefix of arr2 (client is adding new players)
if (arr1.length < arr2.length) {
return arr1.every(
(element, index) => JSON.stringify(element) === JSON.stringify(arr2[index])
);
}
return false;
}
// Check if two hands are compatible for merging
function areHandsCompatible(hand1: Hand, hand2: Hand): boolean {
// Critical fields that must match exactly
const criticalFields: (keyof Hand)[] = [
'variant',
'venue',
'currency',
'table',
'seed',
'hand',
// 'winnings', YF: cant compare === because it's an array
'rake',
'rakePercentage',
'anteTrimming',
'timeLimit',
];
for (const field of criticalFields) {
if (
hand1[field] !== undefined &&
hand2[field] !== undefined &&
hand1[field] !== hand2[field]
) {
return false;
}
}
// Player-related structural array fields that can be extended (new players joining)
// These arrays must be equal or one must be a prefix of another
// Note: _intents, _inactive, _deadBlinds are state arrays handled separately in merge logic
const playerArrayFields: (keyof Hand)[] = [
'players',
'startingStacks',
'blindsOrStraddles',
'antes',
'seats',
'_venueIds',
'timeBanks',
];
for (const field of playerArrayFields) {
if (hand1[field] !== undefined && hand2[field] !== undefined) {
const arr1 = hand1[field] as any[];
const arr2 = hand2[field] as any[];
if (!isArrayPrefixOrEqual(arr1, arr2)) {
return false;
}
}
}
// Betting structure fields
const bettingFields: (keyof Hand)[] = ['minBet', 'smallBet', 'bigBet', 'bringIn'];
for (const field of bettingFields) {
if (
hand1[field] !== undefined &&
hand2[field] !== undefined &&
hand1[field] !== hand2[field]
) {
return false;
}
}
return true;
}
// Get only common actions
function getCommonActions(oldActions: Action[], newActions: Action[]): Action[] {
// Find common prefix length
const prefixLen = findCommonPrefixLength(oldActions, newActions);
if (prefixLen <= oldActions.length) {
return oldActions;
}
const combined: Action[] = [];
// Add common actions
for (let i = 0; i < prefixLen; i++) {
combined.push(oldActions[i]);
}
return combined;
}
// Find how many actions at the start are equivalent
function findCommonPrefixLength(actions1: Action[], actions2: Action[]): number {
const minLen = Math.min(actions1.length, actions2.length);
let prefixLen = 0;
while (prefixLen < minLen) {
const action1 = actions1[prefixLen];
const action2 = actions2[prefixLen];
const actionType1 = getActionType(action1);
const actionType2 = getActionType(action2);
const actionPlayerIndex1 = getActionPlayerIndex(action1);
const actionPlayerIndex2 = getActionPlayerIndex(action2);
const actionAmount1 = getActionAmount(action1);
const actionAmount2 = getActionAmount(action2);
// Check if actions are equivalent
// hole card actions are equivalent
if (
actionType1 === actionType2 &&
actionPlayerIndex1 === actionPlayerIndex2 &&
actionAmount1 === actionAmount2
) {
prefixLen++;
continue;
}
break; // Actions don't match
}
return prefixLen;
}
/**
* Get remaining time for the current player's action
* @param hand - Hand object
* @returns Remaining time in milliseconds, or Infinity if no time limit
*/
export function getTimeLeft(hand: Hand): number {
// If no time limit, return Infinity (matching Game.getTimeLeft behavior)
if (!hand.timeLimit) return Infinity;
if (!hand.actions || hand.actions.length === 0) {
// No actions yet, full time available
return hand.timeLimit * 1000;
}
// Find the most recent timestamped action
let mostRecentTimestamp = 0;
for (let i = hand.actions.length - 1; i >= 0; i--) {
const timestamp = getActionTimestamp(hand.actions[i], false);
if (timestamp) {
mostRecentTimestamp = timestamp;
break;
}
}
if (mostRecentTimestamp === 0) {
// No timestamped actions, full time available
return hand.timeLimit * 1000;
}
const elapsedTime = Date.now() - mostRecentTimestamp;
// Calculate remaining time (fix operator precedence with parentheses)
const remaining = hand.timeLimit * 1000 - elapsedTime;
return Math.max(0, remaining);
}
/**
* Compare two hands for deep equality using JSON serialization
* @param hand1 - First hand to compare
* @param hand2 - Second hand to compare
* @returns True if hands are deeply equal
*/
export function isEqual(hand1: Hand, hand2: Hand): boolean {
return JSON.stringify(hand1) === JSON.stringify(hand2);
}
/**
* Return hand from specific player's perspective, hiding other players' hole cards
* @param hand - Hand to personalize
* @param playerIdentifier - Player whose perspective to use (optional)
* @returns Hand with hole cards hidden for other players
*/
export function personalize(hand: Hand, playerIdentifier?: PlayerIdentifier): Hand {
// If no player specified, return original hand
if (playerIdentifier === undefined) {
return hand;
}
// Get the player index to determine perspective
const perspectiveIndex = getPlayerIndex(hand, playerIdentifier);
// Determine author name for the personalized hand
let authorName = '';
if (typeof playerIdentifier === 'string') {
authorName = playerIdentifier;
} else if (typeof playerIdentifier === 'number' && perspectiveIndex >= 0) {
authorName = hand.players[perspectiveIndex];
}
// Process actions to hide other players' hole cards
const personalizedActions = hand.actions.map(action => {
const actionType = getActionType(action);
// Check if this is a hole card deal action
if (actionType === 'dh') {
const playerIndex = getActionPlayerIndex(action);
// Hide cards if not the perspective player
if (playerIndex !== perspectiveIndex) {
// Replace cards with ????
// Keep the action structure intact - just replace the card portion
const cards = getActionCards(action);
if (cards) {
// Replace each card with ??
const hiddenCards = cards.map(() => '??').join('');
// Find and replace the cards in the action
const cardsString = cards.join('');
return action.replace(cardsString, hiddenCards);
}
}
}
// Keep all other actions unchanged (including showdown cards)
return action;
});
// Return new hand with personalized actions and author set
return Hand({
...hand,
actions: personalizedActions,
author: authorName,
seed: undefined,
_venueIds: undefined,
});
}
/**
* Advance hand by automatically handling dealer actions and timeouts
* @param hand - Hand to advance
* @returns Hand with new actions added if any automatic actions were needed
*/
export function advance(hand: Hand): Hand {
// Early return: waiting state for games without enough players
// A poker game needs at least 2 players to start
if (hand.players.length < 2) {
return Hand(hand); // Return unchanged - waiting for players
}
// Check if we need to activate players to start the game
// This happens when we have enough players ready to play but not enough active players
if (
Array.isArray(hand._inactive) &&
Array.isArray(hand._intents) &&
hand.actions.length === 0
) {
// 1. Initialization
const bbValue = hand.minBet;
let activeCount = 0;
// 2. Categorize players by intent
const readyNowIndices: number[] = []; // Players with intent 0
const waitForBBIndices: number[] = []; // Players with intent 1
for (let i = 0; i < hand.players.length; i++) {
// Count active players
if (hand._inactive[i] === 0) {
activeCount++;
}
// Categorize by intent
if (hand._intents[i] === 0) {
readyNowIndices.push(i);
} else if (hand._intents[i] === 1) {
waitForBBIndices.push(i);
}
}
// 3. Check if activation is possible and needed
const totalPotential = readyNowIndices.length + waitForBBIndices.length;
// Already have enough active players
if (activeCount >= 2) {
// No activation needed
}
// Check if we can potentially reach minimum
else if (activeCount + totalPotential >= 2) {
// 4. Create copy for modifications
const newHand = { ...hand };
newHand._inactive = [...hand._inactive];
newHand._intents = [...hand._intents];
// Also copy _deadBlinds array
if (Array.isArray(hand._deadBlinds)) {
newHand._deadBlinds = [...hand._deadBlinds];
} else {
newHand._deadBlinds = new Array(hand.players.length).fill(0);
}
// 5. First wave: activate all players with intent = 0
for (const index of readyNowIndices) {
newHand._inactive[index] = 0;
newHand._deadBlinds[index] = 0;
// _intents[index] remains 0
}
// 6. Check if we have enough active players after first wave
const currentActive = activeCount + readyNowIndices.length;
if (currentActive < 2) {
// 7. Second wave: need to activate waitForBB players
let needToActivate = 2 - currentActive;
// 7.1 Priority: activate waitForBB players who are on BB position
for (const index of waitForBBIndices) {
if (needToActivate > 0 && hand.blindsOrStraddles[index] === bbValue) {
newHand._inactive[index] = 0;
newHand._deadBlinds[index] = 0;
newHand._intents[index] = 0; // Reset intent since they got their BB
needToActivate--;
}
}
// 7.2 Forced activation: activate remaining waitForBB if still needed
if (needToActivate > 0) {
for (const index of waitForBBIndices) {
if (needToActivate > 0 && newHand._inactive[index] !== 0) {
newHand._inactive[index] = 0;
newHand._deadBlinds[index] = 0;
newHand._intents[index] = 0; // Forced activation resets intent
needToActivate--;
}
}
}
}
// else: We have enough players, waitForBB players stay inactive
// Continue with the modified hand (start() will assign blinds if needed)
hand = start(newHand);
}
}
// Try to start the game (assigns blinds if conditions are met)
// Safe to call multiple times - returns unchanged if already started
hand = start(hand);
// Check if we should wait (not enough active players to start)
const activePlayers = hand.players.filter(
(_, i) => !Array.isArray(hand._inactive) || !hand._inactive[i]
);
const gameStarted = hand.actions.length > 0;
if (!gameStarted && activePlayers.length < 2) {
// Not enough players to start
return Hand(hand);
}
const game = Game(hand);
// Get next action from dealer
const dealerAction = Command.deal(game);
if (dealerAction) {
// Apply dealer action and return new hand
const result = applyAction(hand, dealerAction);
// Recursively advance to continue generating dealer actions
return advance(result);
}
// If there are no more dealer actions (e.g., all cards dealt) and the
// betting round is over, the hand should be finished to proceed to showdown.
const isBettingRoundOver = game.nextPlayerIndex < 0;
const needsShowdown = game.isPlayable && isBettingRoundOver && !dealerAction;
// If betting round is over and hand needs showdown, finish the hand
if (needsShowdown && hand.actions.length > 0 && !isComplete(hand)) {
return finish(hand);
}
// Check for timeout handling
return handleTimeOut(hand);
}
/**
* Handle timeout for current player if time limit is exceeded
* @param hand - Hand to check for timeout
* @returns Hand with timeout action added if needed, original hand otherwise
*/
export function handleTimeOut(hand: Hand): Hand {
// Check if timeLimit is set
if (!hand.timeLimit || hand.timeLimit <= 0) {
return Hand(hand);
}
// Get remaining time for current action
const remaining = getTimeLeft(hand);
// Check if time has expired (remaining time is 0 or less)
if (remaining <= 0) {
// Create game to determine current player and state
const game = Game(hand);
// If no player to act, return unchanged
if (game.nextPlayerIndex < 0) {
return Hand(hand);
}
// Use Command.auto to generate appropriate timeout action
const timeoutAction = Command.auto(game, game.nextPlayerIndex);
// Apply timeout action (applyAction already wraps in Hand())
return applyAction(hand, timeoutAction);
}
return Hand(hand);
}
/**
* Check if an action can be applied to the current hand state
* @param hand - Hand to check
* @param action - Action to validate
* @returns True if action is valid and can be applied
*/
export function canApplyAction(hand: Hand, action: Action): boolean {
try {
// Try to apply action through processor
Game.applyAction(Game(hand), action);
return true;
} catch {
return false;
}
}
/**
* Check if a hand is complete
* @param hand - Hand to check
* @returns True if hand is complete, false otherwise
*/
export function isComplete(hand: Hand): boolean {
return hand.finishingStacks !== undefined;
}
/**
* Creates a new hand from a completed hand with proper button rotation
* @param completedHand - The completed hand to create next hand from
* @returns New hand with rotated positions and chip continuity
*/
export function next(completedHand: Hand): Hand {
// Edge case: all players want to quit before game started
// Return empty notation, game with NO players, no stacks, no blinds, no antes, no actions, no inactive, no intents, no dead blinds, no seats, no finishing stacks, no winnings
const allWantToQuit =
Array.isArray(completedHand._intents) &&
completedHand._intents.length > 0 &&
completedHand._intents.every(intent => intent === 3);
const gameNotStarted = completedHand.actions.length === 0;
if (allWantToQuit && gameNotStarted) {
return Hand({
...completedHand,
players: [],
startingStacks: [],
blindsOrStraddles: [],
antes: [],
actions: [],
_inactive: [],
_intents: [],
_deadBlinds: [],
seats: undefined, // Clear seats array for empty table
finishingStacks: undefined,
winnings: undefined,
});
}
// Validate that hand is complete
if (!isComplete(completedHand)) {
throw new Error('Cannot create next hand from incomplete hand');
}
// Initialize player-related arrays BEFORE sorting to ensure they're included in sort
const handWithArrays = {
...completedHand,
_inactive: completedHand._inactive || new Array(completedHand.players.length).fill(0),
_intents: completedHand._intents || new Array(completedHand.players.length).fill(0),
_deadBlinds: completedHand._deadBlinds || new Array(completedHand.players.length).fill(0),
};
// Ensure arrays are sorted by seat order ONLY ONCE at the beginning
let orderedHand = ensureSeatOrder(handWithArrays);
// Check if seats are invalid and handle appropriately
if (orderedHand.seats && orderedHand.seats.length > 0) {
// Validate seats
const validSeats =
orderedHand.seats.length === orderedHand.players.length &&
orderedHand.seats.every(seat => Number.isInteger(seat) && seat >= 1 && seat <= 9) &&
new Set(orderedHand.seats).size === orderedHand.seats.length;
if (!validSeats) {
// Replace invalid seats with sequential seats [1, 2, 3...]
orderedHand = {
...orderedHand,
seats: orderedHand.players.map((_, index) => index + 1),
};
}
}
// Use the initialized arrays from orderedHand
const _inactive = orderedHand._inactive!;
const _intents = orderedHand._intents!;
let _deadBlinds = orderedHand._deadBlinds!;
// STEP 1: REMOVE PLAYERS (happens FIRST before all other operations)
// Identify players to remove
const playersToRemove: number[] = [];
const finishingStacks = orderedHand.finishingStacks!;
// First calculate rotated positions for the next hand
const rotatedBlinds = [...orderedHand.blindsOrStraddles];
rotatedBlinds.unshift(rotatedBlinds.pop()!);
let rotatedAntes = orderedHand.antes ? [...orderedHand.antes] : [];
if (rotatedAntes.length > 0) {
rotatedAntes.unshift(rotatedAntes.pop()!);
}
for (let i = 0; i < orderedHand.players.length; i++) {
// Remove if player wants to leave (_intents: 3)
if (_intents[i] === 3) {
playersToRemove.push(i);
continue;
}
// Remove if player has zero or negative chips
if (finishingStacks[i] <= 0) {
playersToRemove.push(i);
continue;
}
// Calculate what the player would need to pay in the next hand
// Use the pre-calculated rotated positions
const upcomingBlind = rotatedBlinds[i];
const upcomingAnte = rotatedAntes[i] || 0;
// Comprehensive chip requirement check based on poker rules
let totalRequired = 0;
if (_inactive[i] === 2) {
// New player
const bbValue = orderedHand.minBet;
if (_intents[i] === 1 && rotatedBlinds[i] !== bbValue) {
// Waiting for BB and not in BB, so no cost this round.
continue;
}
// Otherwise, they need to be able to afford the blind/ante.
totalRequired = upcomingBlind + upcomingAnte;
} else if (_inactive[i] === 0) {
// Active player
totalRequired = upcomingBlind + upcomingAnte;
} else if (_inactive[i] === 1) {
// Paused player
if (_intents[i] === 0) {
// Wants to return
totalRequired = upcomingBlind + upcomingAnte + _deadBlinds[i];
} else if (_intents[i] === 1) {
// Wait for BB
const bbValue = orderedHand.minBet;
if (rotatedBlinds[i] === bbValue) {
totalRequired = upcomingBlind + upcomingAnte;
} else {
continue;
}
} else if (_intents[i] === 2) {
// Still pausing
totalRequired = upcomingBlind + upcomingAnte;
}
}
// Remove if can't afford required amount
if (totalRequired > 0 && finishingStacks[i] < totalRequired) {
playersToRemove.push(i);
}
}
// Filter all arrays to remove players
const keepIndices = Array.from({ length: orderedHand.players.length }, (_, i) => i).filter(
i => !playersToRemove.includes(i)
);
// Filter all arrays
const filteredPlayers = keepIndices.map(i => orderedHand.players[i]);
const filteredStacks = keepIndices.map(i => finishingStacks[i]);
const filteredBlinds = keepIndices.map(i => orderedHand.blindsOrStraddles[i]);
const filteredAntes = keepIndices.map(i => orderedHand.antes?.[i] || 0);
const filteredInactive = keepIndices.map(i => _inactive[i]);
const filteredIntents = keepIndices.map(i => _intents[i]);
const filteredDeadBlinds = keepIndices.map(i => _deadBlinds[i]);
// Filter optional arrays only if they exist
const filteredSeats = orderedHand.seats
? keepIndices.map(i => orderedHand.seats![i])
: undefined;
const filteredVenueIds = orderedHand._venueIds
? keepIndices.map(i => orderedHand._venueIds![i])
: undefined;
const filteredTimeBanks = orderedHand.timeBanks
? keepIndices.map(i => orderedHand.timeBanks![i])
: undefined;
const filteredHeroIds = orderedHand._heroIds
? keepIndices.map(i => orderedHand._heroIds![i])
: undefined;
// STEP 2: ROTATE POSITIONS AND RECALCULATE BLINDS
// After removing players, we need to recalculate blinds properly
let nextRotatedBlinds: number[];
// BB value: minBet for No-Limit, smallBet for Fixed-Limit
const bbValueForCalc = (orderedHand.minBet ?? orderedHand.smallBet) as number;
if (filteredPlayers.length === 0) {
nextRotatedBlinds = [];
} else if (filteredPlayers.length === 1) {
// Single player - gets BB
nextRotatedBlinds = [bbValueForCalc];
} else if (filteredPlayers.length === 2) {
// Two players - heads up: first player SB, second BB
const sbValue = Math.floor(bbValueForCalc / 2);
// Rotate from previous state
const prevBlinds = [...filteredBlinds];
prevBlinds.unshift(prevBlinds.pop()!);
// Determine who should have SB and BB based on rotation
if (prevBlinds[0] > 0 || prevBlinds[1] > 0) {
// Someone had blinds before, rotate normally
nextRotatedBlinds = [sbValue, bbValueForCalc];
} else {
// No one had blinds, start fresh
nextRotatedBlinds = [sbValue, bbValueForCalc];
}
} else {
// 3+ players - normal blind structure
const sbValue = Math.floor(bbValueForCalc / 2);
// Check if we have complex blind structure (straddle, etc.) or players were removed
const nonZeroBlinds = filteredBlinds.filter(b => b > 0).length;
const playersRemoved = keepIndices.length < orderedHand.players.length;
if (nonZeroBlinds > 2 || !playersRemoved) {
// Complex structure (straddle, etc.) or no players removed - use simple rotation
nextRotatedBlinds = [...filteredBlinds];
nextRotatedBlinds.unshift(nextRotatedBlinds.pop()!);
} else {
// Standard structure with players removed - recalculate SB/BB positions
// Start with zeros for all positions
nextRotatedBlinds = new Array(filteredPlayers.length).fill(0);
// Rotate from filtered blinds to find next button position
const prevBlinds = [...filteredBlinds];
prevBlinds.unshift(prevBlinds.pop()!);
// Find BB position in rotated blinds - button is 2 positions back from BB
const bbIndexInRotated = prevBlinds.indexOf(bbValueForCalc);
let buttonIndex: number;
if (bbIndexInRotated !== -1) {
// BB found - button is 2 positions counter-clockwise from BB
buttonIndex = (bbIndexInRotated - 2 + prevBlinds.length) % prevBlinds.length;
} else {
// BB not found (edge case) - use fallback logic
buttonIndex = 0;
}
// Place blinds: SB is position after button, BB is position after SB
const sbIndex = (buttonIndex + 1) % filteredPlayers.length;
const bbIndex = (buttonIndex + 2) % filteredPlayers.length;
nextRotatedBlinds[sbIndex] = sbValue;
nextRotatedBlinds[bbIndex] = bbValueForCalc;
}
}
// DO NOT ROTATE SEATS - keep them in ascending order
const keptSeats = filteredSeats ? [...filteredSeats] : undefined;
const nextRotatedAntes = [...filteredAntes];
if (nextRotatedAntes.length > 0) {
nextRotatedAntes.unshift(nextRo