@idealic/poker-engine
Version:
Poker game engine and hand evaluator
256 lines (220 loc) • 7.68 kB
text/typescript
import { Game } from '../Game';
import type { Card } from '../types';
import { compareHands } from './showdown';
export interface Pot {
bet: number;
potSize: number;
contributors: number[];
isUncalled: boolean;
}
export function calculatePots(game: Game, includeCurrentRound = true): Pot[] {
/**
* STEP A: Determine which players actually contributed (totalBet > 0).
*/
const involved = game.players.map((p, i) => (p.totalBet > 0 ? i : -1)).filter(x => x !== -1);
// Calculate total amount bet by players to detect dead money (dead blinds/antes)
const totalBetSum = game.players.reduce((sum, p) => sum + p.totalBet, 0);
const deadMoney = game.pot - totalBetSum;
if (involved.length === 0) {
if (deadMoney > 0) {
// No bets, but pot has money (e.g. antes only, or check-down with dead blinds).
// Treat everyone as involved in the base pot.
// Eligible winners will be filtered in finalizeStacks.
return [
{
bet: 0,
potSize: deadMoney,
contributors: game.players.map((_, i) => i),
isUncalled: false,
},
];
}
// No contributions => no pot to distribute
return [];
}
/**
* STEP B: We gather all relevant "bet levels":
* 1. The distinct totalBet values of each involved player
* 2. The blindOrStraddle values (e.g. 50, 100) for players who actually posted them
* so we don't skip from 0 -> 200 ignoring a big blind of 100.
*/
const betLevelsSet = new Set<number>();
for (const idx of involved) {
// A player's totalBet
betLevelsSet.add(
game.players[idx].totalBet - (!includeCurrentRound ? game.players[idx].roundBet : 0)
);
}
// Always ensure we start from bet=0 so the first slice covers that range.
betLevelsSet.add(0);
// Convert to a sorted array
const uniqueBetLevels = [...betLevelsSet].sort((a, b) => a - b);
/**
* STEP C: Build pot slices by iterating through consecutive bet levels.
*
* Example betLevels might be [0, 50, 100, 200, 8303].
* We'll slice from 0->50, 50->100, 100->200, 200->8303, etc.
*/
const pots: Pot[] = [];
let prevBet = 0;
for (let i = 1; i < uniqueBetLevels.length; i++) {
const currentBet = uniqueBetLevels[i];
if (currentBet <= prevBet) {
// skip duplicates or weirdness
continue;
}
// Find all players whose totalBet >= currentBet
const contributors = involved.filter(idx => game.players[idx].totalBet >= currentBet);
const betDiff = currentBet - prevBet;
const potSlice = contributors.length * betDiff;
// Advance for next iteration
prevBet = currentBet;
if (potSlice === 0) {
continue;
}
// Check for uncalled bet scenario: exactly 1 contributor
if (contributors.length === 1 && !game.players[contributors[0]].hasFolded) {
pots.push({
bet: currentBet,
potSize: potSlice,
contributors,
isUncalled: true,
});
// No need to add to pots, not "fully called"
} else {
// This chunk is fully called
pots.push({
bet: currentBet,
potSize: potSlice,
contributors,
isUncalled: false,
});
}
}
// Add dead money to the base pot (the one with the lowest bet, which is first in array before sort)
if (deadMoney > 0 && pots.length > 0) {
// pots[0] corresponds to the first slice (bet 0 -> minBet), which is the main pot.
pots[0].potSize += deadMoney;
}
return pots.sort((a, b) => b.bet - a.bet);
}
export function finalizeStacks(
game: Game,
compare: (cardsA: Card[], cardsB: Card[]) => number = compareHands
): number[] {
// 1) Initialize final stacks
const finishingStacks = game.players.map(p => p.stack);
const winnings = game.players.map(_ => 0);
// Early returns if we can't distribute yet
if (game.isComplete || game.pot === 0 || !game.isBettingComplete) {
return finishingStacks;
}
// 3) Identify active (non-folded) players
const activePlayerIndices = game.players
.filter(p => !p.isInactive && !p.hasFolded)
.map(p => p.position);
// If not all active players have "shown cards," we cannot distribute yet
const allHaveShown =
activePlayerIndices.every(i => game.players[i].hasShownCards != null) ||
activePlayerIndices.length === 1;
if (!allHaveShown) {
return finishingStacks;
}
const pots = calculatePots(game);
const totalCalled = pots.filter(p => !p.isUncalled).reduce((sum, p) => sum + p.potSize, 0);
// Handle uncalled bets
pots
.filter(p => p.isUncalled)
.forEach(uncalledPot => {
const playerIndex = uncalledPot.contributors[0];
finishingStacks[playerIndex] += uncalledPot.potSize;
game.players[playerIndex].returns += uncalledPot.potSize;
});
/**
* STEP D: Apply rake only on the portion that was fully called.
*/
const everyoneFolded = activePlayerIndices.length === 1;
const shouldTakeRake = !everyoneFolded && game.street !== 'preflop' && game.board.length > 0;
const rakeBase = shouldTakeRake ? totalCalled : 0;
const rake =
typeof game.rake === 'number'
? game.rake // if an explicit amount is provided
: shouldTakeRake
? Math.min(
game.rakeCap ?? Infinity,
parseFloat((rakeBase * (game.rakePercentage ?? 0)).toFixed(2))
)
: 0;
game.pot = totalCalled - rake;
game.rake = rake;
/**
* STEP E: Distribute each pot among eligible winners.
*/
let remainingRake = rake;
pots
.filter(p => !p.isUncalled)
.forEach(thePot => {
let potSize = thePot.potSize;
// Among contributors, only non-folded players can win
const eligible = thePot.contributors.filter(
c =>
!game.players[c].hasFolded &&
game.players[c].hasShownCards !== false &&
!game.players[c].isInactive
);
// Find best hand(s)
let winners: number[] = [];
for (const playerIndex of eligible) {
if (winners.length === 0) {
winners = [playerIndex];
} else {
const cNew = game.players[playerIndex].cards.concat(game.board) as Card[];
const cWin = game.players[winners[0]].cards.concat(game.board) as Card[];
const cmp = compare(cNew, cWin);
if (cmp > 0) {
winners = [playerIndex];
} else if (cmp === 0) {
winners.push(playerIndex);
}
}
}
// Take proportional rake from this pot
const potRake = (potSize / totalCalled) * rake;
potSize -= potRake;
remainingRake -= potRake;
// Split pot among winners
if (winners.length > 0) {
const share = Math.floor(potSize / winners.length);
const remainder = potSize % winners.length;
for (const w of winners) {
game.players[w].rake += potRake / winners.length;
finishingStacks[w] += share;
winnings[w] += share;
}
// Give leftover remainder to first winner
finishingStacks[winners[0]] += remainder;
winnings[winners[0]] += remainder;
}
});
game.players.forEach(p => {
p.winnings = winnings[p.position];
p.stack = finishingStacks[p.position];
});
return finishingStacks;
}
export function getCurrentPot(game: Game): Pot | undefined {
if (!game.isShowdown) {
return undefined;
}
const pots = calculatePots(game);
return pots.find(
pot =>
!pot.isUncalled &&
pot.contributors.some(
idx =>
!game.players[idx].hasFolded &&
game.players[idx].hasShownCards === null &&
!game.players[idx].isInactive
)
);
}