@idealic/poker-engine
Version:
Poker game engine and hand evaluator
336 lines (299 loc) • 11 kB
text/typescript
import { type Game } from '../Game';
import { recordStatsAfter, recordStatsBefore, recordStatsFinish } from '../stats/stats';
import type { Action, Card, Player } from '../types';
import {
ACTION_CHECK_CALL,
ACTION_COMPLETE_BET_RAISE,
ACTION_DEAL_BOARD,
ACTION_DEAL_HOLE,
ACTION_FOLD,
ACTION_MESSAGE,
ACTION_SHOW_MUCK,
} from '../types';
import { completeBetting, makeBet, matchBet, resetBettingState } from './betting';
import {
getActionAmount,
getActionCards,
getActionPlayerIndex,
getActionTimestamp,
getActionType,
getCurrentPlayerIndex,
getRemainingPlayers,
} from './position';
import { completeHand } from './showdown';
import { canBet, canCall, canCheck, canFold, canRaise } from './validation';
/**
* Determines if action is a dealer action
*/
export function isDealerAction(action: Action): boolean {
return action.startsWith('d ');
}
/**
* Determines if action is a player action
*/
export function isPlayerAction(action?: Action, index?: number): boolean {
return action?.startsWith('p' + (index == null ? '' : index + 1)) ?? false;
}
// Parse & apply a single action line to the Table
export function applyAction(game: Game, action: Action) {
const playerIndex = getActionPlayerIndex(action);
const cards = (getActionCards(action) as Card[]) || [];
const actionType = getActionType(action);
// Check if game is valid before applying any action
// We check activePlayers.length < 2 as well because players might fold during the game
const activePlayers = getRemainingPlayers(game);
if (!game.isPlayable || activePlayers.length < 2) {
throw new Error(
`Cannot apply action to invalid game (less than 2 active players). Action: ${action}`
);
}
if (actionType == ACTION_MESSAGE) {
return game;
}
if (isDealerAction(action)) {
if (game.nextPlayerIndex != -1) {
throw new Error(
`It's not the dealer's turn to deal, nextPlayerIndex is ${game.nextPlayerIndex}. Action: ${action}`
);
}
} else {
if (game.nextPlayerIndex != playerIndex) {
throw new Error(
`It's not the player's turn to act, nextPlayerIndex is ${game.nextPlayerIndex} but the action is for player ${playerIndex}. Action: ${action}. Before: ${game.lastAction}`
);
}
}
if (game.isComplete) {
throw new Error(`Cannot apply action after hand is complete. Action: ${action}`);
}
if (game.isShowdown && game.players.some(p => p.hasShownCards && p.position === playerIndex)) {
throw new Error(`Cannot apply showdown action after player has shown cards. Action: ${action}`);
}
// Store the action
game.lastAction = action;
if (isDealerAction(action)) {
// Dealer action
if (actionType === ACTION_DEAL_HOLE && playerIndex != null) {
// Deal hole cards
if (playerIndex >= 0 && playerIndex < game.players.length) {
game.players[playerIndex].cards = cards;
game.usedCards += cards.length;
}
} else if (actionType === ACTION_DEAL_BOARD) {
// Validate all active players have hole cards before dealing board
const activePlayers = getRemainingPlayers(game);
const allPlayersHaveHoleCards = activePlayers.every(p => p.cards.length > 0);
if (!allPlayersHaveHoleCards) {
throw new Error(
`Cannot deal board cards before all active players have hole cards. Action: ${action}`
);
}
// Validate correct number of cards for the street
const cardsToAdd = cards.length;
const currentBoardSize = game.board.length;
let expectedCards: number;
if (currentBoardSize === 0) {
expectedCards = 3; // Flop must be 3 cards
} else if (currentBoardSize === 3 || currentBoardSize === 4) {
expectedCards = 1; // Turn and river must be 1 card each
} else {
throw new Error(
`Invalid board state: board has ${currentBoardSize} cards. Action: ${action}`
);
}
if (cardsToAdd !== expectedCards) {
throw new Error(
`Invalid board deal: expected ${expectedCards} card(s) but got ${cardsToAdd}. ` +
`Current board has ${currentBoardSize} cards. Action: ${action}`
);
}
// Validate not exceeding 5 total board cards
if (currentBoardSize + cardsToAdd > 5) {
throw new Error(
`Cannot deal more than 5 total board cards. Current: ${currentBoardSize}, ` +
`attempting to add: ${cardsToAdd}. Action: ${action}`
);
}
// Deal board cards
game.board = [...game.board, ...cards];
game.usedCards += cards.length;
// Reset state for new street
advanceStreet(game);
}
} else if (actionType === ACTION_SHOW_MUCK && playerIndex != null) {
if (!game.isShowdown || game.isComplete) {
throw new Error(`Cards should be shown at showdown. Action: ${action}`);
}
// Show cards at showdown
const currentPlayer = game.players[playerIndex];
const cards = getActionCards(action);
if (cards) {
currentPlayer.hasShownCards = true;
currentPlayer.cards = cards as Card[];
} else {
currentPlayer.hasShownCards = false;
}
currentPlayer.hasActed = true;
currentPlayer.roundAction = action;
game.lastPlayerAction = action;
} else if (playerIndex != null) {
const currentPlayer = game.players[playerIndex];
if (actionType === ACTION_FOLD) {
if (!canFold(game, playerIndex)) {
debugger;
throw new Error(`Invalid Action: Player ${playerIndex} cannot fold.`);
}
// Fold
currentPlayer.hasFolded = true;
currentPlayer.hasActed = true;
} else if (actionType === ACTION_CHECK_CALL) {
// Call or check
const isCheck = currentPlayer.roundBet === game.bet;
if (isCheck) {
if (!canCheck(game, playerIndex)) {
throw new Error(`Invalid Action: Player ${playerIndex} cannot check.`);
}
} else {
if (!canCall(game, playerIndex)) {
throw new Error(`Invalid Action: Player ${playerIndex} cannot call.`);
}
}
matchBet(game, playerIndex, game.bet);
} else if (actionType === ACTION_COMPLETE_BET_RAISE) {
const amount = getActionAmount(action) ?? 0;
const isBet = game.bet === 0;
if (isBet) {
if (!canBet(game, playerIndex, amount)) {
throw new Error(`Invalid Action: Player ${playerIndex} cannot bet.`);
}
} else {
// Raise
if (!canRaise(game, playerIndex, amount)) {
throw new Error(`Invalid Action: Player ${playerIndex} cannot raise.`);
}
}
makeBet(game, playerIndex, amount);
} else {
throw new Error(`Invalid action type: ${actionType}`);
}
currentPlayer.roundAction = action;
game.lastPlayerAction = action;
}
detectRunout(game);
completeBetting(game);
completeHand(game);
// game.isRunOut = isRunOut(game);
game.nextPlayerIndex = getCurrentPlayerIndex(game);
if (!game.venue.includes('fuzz')) {
if (isPlayerAction(action) && getActionType(action) !== ACTION_SHOW_MUCK) {
// Record stats after the action is applied
recordStatsAfter(game, action);
}
// First, record stats BEFORE the action is applied
if (game.nextPlayerIndex != -1 && !game.isShowdown) {
recordStatsBefore(game, game.nextPlayerIndex);
}
if (game.isComplete) {
recordStatsFinish(game);
}
}
// Record the timestamp of the action
if (getActionType(action) !== ACTION_MESSAGE) {
game.lastTimestamp = getActionTimestamp(action) || Date.now();
}
return game;
}
/**
* Resets player states for a new street
*/
export function advanceStreet(game: Game): void {
// Update street based on number of board cards
if (game.board.length === 3) {
game.street = 'flop';
} else if (game.board.length === 4) {
game.street = 'turn';
} else if (game.board.length === 5) {
game.street = 'river';
}
resetBettingState(game);
game.lastBetAction = undefined;
game.lastPlayerAction = undefined;
game.isBettingComplete = false;
// Only reset state for players who are still in the hand and not all-in
// Skip resetting hasActed in multi-way all-in situations
game.players.forEach((p: Player) => {
p.roundInvestments = 0;
p.roundAction = null;
if (!p.hasFolded && !p.isAllIn) {
p.hasActed = false;
}
});
} /**
* Determines if dealer intervention is needed
*/
export function isAwaitingDealer(game: Game): boolean {
// Get active players (not folded)
const activePlayers = getRemainingPlayers(game);
// Condition 1: Missing hole cards
// We need to deal if any player is missing cards, regardless of initial deal phase
if (activePlayers.some((p: Player) => p.cards.length === 0)) {
return true;
}
// Condition 2: Single player remaining (need to award pot)
if (activePlayers.length === 1) {
return true;
}
// Condition 3: All-in situations (need to deal remaining streets)
if (game.isRunOut && game.street !== 'river') {
return true;
}
// Check if betting round is complete
const allPlayersActed = activePlayers.every((p: Player) => p.hasActed || p.isAllIn);
const allPlayersBetsMatch = activePlayers.every(
(p: Player) => p.roundBet === game.bet || p.hasFolded || p.isAllIn
);
// Condition 4: Betting round completion
if ((!game.isBettingComplete || game.street !== 'river') && !game.isShowdown) {
// Condition 5: No betting or last bet was called
if (allPlayersActed && allPlayersBetsMatch) {
const lastBetWasCalled =
game.lastBetAction &&
activePlayers.every((p: Player) => p.hasFolded || p.isAllIn || p.roundBet === game.bet);
if (!game.lastBetAction || lastBetWasCalled) {
return true;
}
}
}
return false;
}
/**
* Checks if we're in a multi-way all-in situation where no more betting is possible.
* This happens when:
* 1. At least one player is all-in
* 2. All remaining players have matched the all-in amount
*
* @example
* - One player all-in, others matched -> true
* - One player all-in, others still betting -> false
* - All players all-in -> true
* - No all-in players -> false
*/
export function isRunOut(game: Game): boolean {
const activePlayers = getRemainingPlayers(game);
const allInPlayers = activePlayers.filter((p: Player) => p.isAllIn);
const activeNotAllInPlayers = activePlayers.filter((p: Player) => !p.isAllIn);
// Get the highest all-in amount
const maxAllInBet = Math.max(...allInPlayers.map(p => p.totalBet));
// Check if all non-all-in players have matched the highest all-in bet
return activeNotAllInPlayers.every(
(p: Player) => p.totalBet >= maxAllInBet && activeNotAllInPlayers.length == 1
);
}
export function detectRunout(game: Game) {
if (game.street !== 'river') {
game.isRunOut = isRunOut(game);
if (game.isRunOut) {
resetBettingState(game);
}
}
}