@idealic/poker-engine
Version:
Professional poker game engine and hand evaluator with built-in iterator utilities
1,078 lines • 47.2 kB
JavaScript
/**
* Venue-agnostic PHH → Poker Room Narrative
*
* @instructions Convert PHH hand to poker room narrative hand with exact action length summary and PokerStars/Home Game conventions. Extensible for other venues.
* Do not attempt to optimize or remove redundant actions during narration.
*/
import { calculateHandStrength, getRankCategory } from '../utils/hand-strength';
import { createTable } from '../game/table';
import { getActionAmount, getActionCards, getActionPlayerIndex, getActionType, } from '../utils/position';
function trimTrailingZeros(val, currency) {
// For play money, display whole numbers only (no decimals)
if (currency === 'PLAY') {
return Math.floor(val).toString();
}
// For USD etc., format to 2 decimal places, then remove .00 if it's a whole number.
const fixed = Number(val.toFixed(2)) === Number(val.toFixed(3))
? Number(val).toFixed(2)
: Number(val).toFixed(3);
if (fixed.endsWith('.00')) {
return fixed.substring(0, fixed.length - 3);
}
return fixed;
}
function formatMoney(amount, currency) {
const num = typeof amount === 'number' ? amount : Number(amount) || 0;
if (currency === 'PLAY')
return trimTrailingZeros(num, currency);
let currencySign = '';
switch (currency) {
case 'EUR':
currencySign = '€';
break;
case 'GBP':
currencySign = '£';
break;
case 'USD':
currencySign = '$';
break;
default:
break;
}
// Call trimTrailingZeros with the number directly
return `${currencySign}${trimTrailingZeros(num, currency)}`;
}
function formatTimestamp(timestamp, timeZone) {
const date = new Date(timestamp);
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: timeZone === 'EDT' || timeZone === 'EST' || timeZone === 'ET'
? 'America/New_York'
: timeZone || 'UTC',
};
const formattedDate = date.toLocaleString('en-US', options).replace(',', '');
const zoneAbbr = timeZone === 'EDT' ? 'EDT' : timeZone?.split('/').pop() || 'UTC';
return `${formattedDate} ${zoneAbbr}`;
}
// PokerStars seat mapping for 9-max: [1,2,3,4,5,6,7,8,9], but only used seats are shown
function getPokerStarsSeats(players, seatCount) {
// PokerStars seat numbers for 9-max: 1,2,3,5,9 for 5 players (example)
// We'll use a fixed mapping for 9-max: [1,2,3,5,9,4,6,7,8]
if (seatCount === 9) {
const seatMap = [1, 2, 3, 5, 9, 4, 6, 7, 8]; // This is a potential source of issues if players.length > 9
return seatMap.slice(0, Math.min(players.length, seatMap.length));
}
if (seatCount === 6) {
return [1, 2, 3, 4, 5, 6].slice(0, Math.min(players.length, 6));
}
// fallback: 1-based
return Array.from({ length: players.length }, (_, i) => i + 1);
}
function getVenueHeader(hand, seats, buttonIdx, sbIdx, bbIdx, currency, sbValue, bbValue, timestamp, timeZone, tableName, seatCount) {
const venue = (hand.venue || '').toLowerCase();
let handId = '';
let headerTime = '';
const isPokerStars = venue === 'pokerstars' || venue === 'pokerstars home game' || venue === '';
const isHomeGame = venue === 'pokerstars home game';
const isPlayMoney = currency === 'PLAY';
// Club ID for PokerStars Home Game
let clubId = '';
if (isHomeGame && typeof hand.table === 'string') {
// Try to extract {Club #...} from table name or from a clubId property
if (hand.clubId) {
clubId = `{Club #${hand.clubId}}`;
}
else if (typeof hand.table === 'string') {
// Try to extract from table string if present
const clubMatch = hand.table.match(/\{Club #(\d+)\}/);
if (clubMatch)
clubId = `{Club #${clubMatch[1]}}`;
}
// Or from a clubId property
if (!clubId && hand.clubId) {
clubId = `{Club #${hand.clubId}}`;
}
}
if (isPokerStars) {
handId = hand.id || hand.hand || '';
if (typeof hand.year === 'number' &&
typeof hand.month === 'number' &&
typeof hand.day === 'number' &&
typeof hand.time === 'string') {
const y = hand.year;
const m = hand.month;
const d = hand.day;
const t = hand.time;
const iso = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}T${t}`;
const date = new Date(iso);
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: 'America/New_York',
};
const formatter = new Intl.DateTimeFormat('en-US', options);
const parts = formatter.formatToParts(date);
let year = '', month = '', day = '', hour = '', minute = '', second = '';
for (const part of parts) {
if (part.type === 'year')
year = part.value;
else if (part.type === 'month')
month = part.value;
else if (part.type === 'day')
day = part.value;
else if (part.type === 'hour')
hour = part.value;
else if (part.type === 'minute')
minute = part.value;
else if (part.type === 'second')
second = part.value;
}
headerTime = `${year}/${month}/${day} ${hour}:${minute}:${second} ET`;
}
else {
headerTime = formatTimestamp(timestamp, 'EDT');
}
// PokerStars (not Home Game)
return [
`PokerStars${isHomeGame ? ' Home Game' : ''} Hand #${String(handId)}: Hold'em No Limit (${formatMoney(sbValue, currency)}/${formatMoney(bbValue, currency)} ${currency}) - ${headerTime}`,
`Table '${tableName}' ${String(seatCount)}-max Seat #${String(seats[buttonIdx])} is the button${isPlayMoney ? ` (Play Money)` : ''}`,
];
}
// Default generic header
handId = hand.hand || '';
headerTime = formatTimestamp(timestamp, timeZone);
return [
`${venue ? venue.charAt(0).toUpperCase() + venue.slice(1) : 'Poker Room'} Hand #${String(handId)}: Hold'em No Limit (${formatMoney(sbValue, currency)}/${formatMoney(bbValue, currency)}${isPlayMoney ? '' : ` ${currency}`}) - ${headerTime}`,
`Table '${tableName}' ${String(seatCount)}-max Seat #${String(seats[buttonIdx])} is the button`,
];
}
// --- NEW HELPER FUNCTIONS ---
function resolveHandSetupInfo(hand, table) {
const seatCount = hand.seatCount || hand.players.length;
const buttonIdx = table.buttonIndex;
const sbIdx = table.smallBlindIndex;
const bbIdx = table.bigBlindIndex;
// Nominal SB value
const nominalSbValue = hand.blindsOrStraddles[sbIdx] || 0;
// Infer nominal BB for header display
let nominalBbValueForHeader = 0;
if (bbIdx !== -1 && hand.blindsOrStraddles[bbIdx] > 0) {
nominalBbValueForHeader = hand.blindsOrStraddles[bbIdx];
}
else if (nominalSbValue > 0) {
nominalBbValueForHeader = nominalSbValue * 2;
}
else {
nominalBbValueForHeader = Math.max(...hand.blindsOrStraddles.filter((b) => b > 0), 0);
if (nominalBbValueForHeader === nominalSbValue && nominalSbValue > 0) {
nominalBbValueForHeader = nominalSbValue * 2;
}
}
if (nominalBbValueForHeader < nominalSbValue && nominalSbValue > 0) {
nominalBbValueForHeader = nominalSbValue;
}
// Seats for header (button seat resolution)
const seatsForHeader = Array.isArray(hand.seats) && hand.seats.length === hand.players.length
? hand.seats
: getPokerStarsSeats(hand.players, seatCount);
// Seat iteration order for seat lines, posts, and summary
const seatIterationOrder = Array.isArray(hand.seats) && hand.seats.length === hand.players.length
? hand.seats
: getPokerStarsSeats(hand.players, seatCount).sort((a, b) => a - b);
// Actual physical seat number for each player (by hand.players index)
const playerIndexToActualSeatMap = new Map();
const seatsToAssignToPlayersSorted = Array.isArray(hand.seats) && hand.seats.length === hand.players.length
? [...hand.seats].sort((a, b) => a - b)
: getPokerStarsSeats(hand.players, seatCount).sort((a, b) => a - b);
hand.players.forEach((_, playerIdx) => {
playerIndexToActualSeatMap.set(playerIdx, seatsToAssignToPlayersSorted[playerIdx]);
});
// Construct seatInfo based on seatIterationOrder
const seatInfo = seatIterationOrder.map(seatNumInIterationOrder => {
let playerIdxForThisSeat = -1;
for (const [pIdx, physicalSeatOfPlayer] of playerIndexToActualSeatMap.entries()) {
if (physicalSeatOfPlayer === seatNumInIterationOrder) {
playerIdxForThisSeat = pIdx;
break;
}
}
// Assuming playerIdxForThisSeat will always be found based on prior logic
const playerName = hand.players[playerIdxForThisSeat];
const playerStack = hand.startingStacks[playerIdxForThisSeat];
const playerIsInactive = !!table.players[playerIdxForThisSeat]?.isInactive;
return {
seat: seatNumInIterationOrder,
idx: playerIdxForThisSeat,
player: playerName,
isInactive: playerIsInactive,
stack: playerStack,
};
});
return {
seatInfo,
buttonIdx,
sbIdx,
bbIdx,
nominalSbValue,
nominalBbValueForHeader,
seatsForHeader,
};
}
function generateSeatLines(seatInfo, currency) {
return seatInfo.map(({ seat, player, stack, isInactive }) => {
const stackStr = formatMoney(stack, currency);
return `Seat ${seat}: ${player} (${stackStr} in chips)${isInactive ? ' is sitting out' : ''}`;
});
}
function generatePostNarrations(hand, table, seatInfo, currency, nominalSbValue, nominalBbValueForHeader, sbIdx, bbIdx) {
const posts = [];
const deadBlinds = hand._deadBlinds || Array(hand.players.length).fill(0);
const processedForBlinds = new Set();
const gameSmallBlindValue = nominalSbValue;
const gameBigBlindValue = nominalBbValueForHeader;
// 1. Small Blind
if (sbIdx !== -1 && !table.players[sbIdx].isInactive) {
const player = hand.players[sbIdx];
const blindAmount = hand.blindsOrStraddles[sbIdx] || 0;
const deadAmount = deadBlinds[sbIdx] || 0;
if (blindAmount > 0 && deadAmount > 0) {
posts.push(`${player}: posts small & big blinds ${formatMoney(blindAmount + deadAmount, currency)}`);
}
else if (blindAmount > 0) {
posts.push(`${player}: posts small blind ${formatMoney(blindAmount, currency)}`);
}
else if (deadAmount > 0) {
if (deadAmount === gameSmallBlindValue && gameSmallBlindValue > 0) {
posts.push(`${player}: posts small blind ${formatMoney(deadAmount, currency)}`);
}
else if (deadAmount === gameBigBlindValue && gameBigBlindValue > 0) {
posts.push(`${player}: posts big blind ${formatMoney(deadAmount, currency)}`);
}
else {
posts.push(`${player}: posts dead blind ${formatMoney(deadAmount, currency)}`);
}
}
if (blindAmount > 0 || deadAmount > 0)
processedForBlinds.add(sbIdx);
}
// 2. Big Blind
if (bbIdx !== -1 && !table.players[bbIdx].isInactive && !processedForBlinds.has(bbIdx)) {
const player = hand.players[bbIdx];
const blindAmount = hand.blindsOrStraddles[bbIdx] || 0;
const deadAmount = deadBlinds[bbIdx] || 0;
if (blindAmount > 0 && deadAmount > 0) {
posts.push(`${player}: posts small & big blinds ${formatMoney(blindAmount + deadAmount, currency)}`);
}
else if (blindAmount > 0) {
posts.push(`${player}: posts big blind ${formatMoney(blindAmount, currency)}`);
}
else if (deadAmount > 0) {
if (deadAmount === gameSmallBlindValue &&
gameSmallBlindValue > 0 &&
bbIdx !== sbIdx) {
posts.push(`${player}: posts small blind ${formatMoney(deadAmount, currency)}`);
}
else if (deadAmount === gameBigBlindValue && gameBigBlindValue > 0) {
posts.push(`${player}: posts big blind ${formatMoney(deadAmount, currency)}`);
}
else {
posts.push(`${player}: posts dead blind ${formatMoney(deadAmount, currency)}`);
}
}
if (blindAmount > 0 || deadAmount > 0)
processedForBlinds.add(bbIdx);
}
// 3. Other players posting blinds or dead blinds
for (const { idx, player, isInactive } of seatInfo) {
if (isInactive || processedForBlinds.has(idx)) {
continue;
}
const blindAmount = hand.blindsOrStraddles[idx] || 0;
const deadAmount = deadBlinds[idx] || 0;
if (blindAmount > 0 && deadAmount > 0) {
posts.push(`${player}: posts small & big blinds ${formatMoney(blindAmount + deadAmount, currency)}`);
}
else if (blindAmount > 0) {
posts.push(`${player}: posts big blind ${formatMoney(blindAmount, currency)}`);
}
else if (deadAmount > 0) {
if (deadAmount === gameSmallBlindValue && gameSmallBlindValue > 0) {
posts.push(`${player}: posts small blind ${formatMoney(deadAmount, currency)}`);
}
else if (deadAmount === gameBigBlindValue && gameBigBlindValue > 0) {
posts.push(`${player}: posts big blind ${formatMoney(deadAmount, currency)}`);
}
else {
posts.push(`${player}: posts dead blind ${formatMoney(deadAmount, currency)}`);
}
}
}
// Antes
if (Array.isArray(hand.antes) && hand.antes.some((a) => a > 0)) {
for (const { player, idx, isInactive } of seatInfo) {
if (!isInactive && hand.antes[idx] > 0) {
posts.push(`${player}: posts the ante ${formatMoney(hand.antes[idx], currency)}`);
}
}
}
return posts;
}
function generateHoleCardDealNarrations(hand) {
const output = [];
for (const action of hand.actions) {
if (!action.startsWith('d dh'))
continue;
const idx = getActionPlayerIndex(action);
const cards = getActionCards(action);
if (idx != null && cards && cards.length === 2 && !(cards[0] === '??' && cards[1] === '??')) {
output.push(`Dealt to ${hand.players[idx]} [${cards.join(' ')}]`);
}
}
return output;
}
function parseActionsToStreets(handActions) {
const streets = { preflop: [], flop: [], turn: [], river: [] };
let currentStreet = 'preflop';
let board = [];
for (const action of handActions) {
if (action.startsWith('d db')) {
const cards = getActionCards(action);
board = board.concat(cards || []);
if (board.length === 3)
currentStreet = 'flop';
else if (board.length === 4)
currentStreet = 'turn';
else if (board.length === 5)
currentStreet = 'river';
continue;
}
streets[currentStreet].push(action);
}
return { streets, board };
}
function generateShowdownNarrations(hand, table) {
const output = [];
const winnerIndices = new Set(table.players.map((p, idx) => (p.winnings > 0 ? idx : -1)).filter((idx) => idx >= 0));
const showdownOrder = [];
for (const action of hand.actions) {
const match = action.match(/^p(\d+) sm(?: ([^#]+))?/);
if (match) {
const idx = getActionPlayerIndex(action) ?? 0; // Ensure idx is not null
const cards = getActionCards(action) ?? ['??', '??'];
const isWinner = winnerIndices.has(idx);
showdownOrder.push({ idx, cards, isWinner });
}
}
const shownOrMucked = new Set();
for (const { idx, cards, isWinner } of showdownOrder) {
const player = table.players[idx];
shownOrMucked.add(idx);
if (cards &&
cards.length === 2 &&
!cards.includes('??') &&
(isWinner || player.hasShownCards)) {
output.push(`${player.name}: shows [${cards.join(' ')}] ${describeHand(cards, table.board)}`);
}
else {
output.push(`${player.name}: mucks hand`);
}
}
for (let i = 0; i < table.players.length; ++i) {
const player = table.players[i];
if (player.isInactive || player.hasFolded || shownOrMucked.has(i))
continue;
if (player.hasShownCards &&
player.cards &&
player.cards.length === 2 &&
!player.cards.includes('??') &&
winnerIndices.has(i)) {
output.push(`${player.name}: shows [${player.cards.join(' ')}] ${describeHand(player.cards, table.board)}`);
}
else {
output.push(`${player.name}: mucks hand`);
}
}
return output;
}
function generateEndOfHandResolutionNarrations(hand, table, parsedStreets, allStreetResults, currency) {
const output = [];
let lastStreetActions = [];
let finalStreetBets = Array(hand.players.length).fill(0);
let lastStreetKey = 'preflop';
if (parsedStreets.river.length > 0 && allStreetResults.river) {
lastStreetActions = parsedStreets.river;
finalStreetBets = allStreetResults.river.streetBets;
lastStreetKey = 'river';
}
else if (parsedStreets.turn.length > 0 && allStreetResults.turn) {
lastStreetActions = parsedStreets.turn;
finalStreetBets = allStreetResults.turn.streetBets;
lastStreetKey = 'turn';
}
else if (parsedStreets.flop.length > 0 && allStreetResults.flop) {
lastStreetActions = parsedStreets.flop;
finalStreetBets = allStreetResults.flop.streetBets;
lastStreetKey = 'flop';
}
else if (allStreetResults.preflop) {
lastStreetActions = parsedStreets.preflop;
finalStreetBets = allStreetResults.preflop.streetBets;
lastStreetKey = 'preflop';
}
const uncalledBetContenders = new Set();
const foldedPriorToStreet = (playerIndex, streetName, currentTable) => {
if (!currentTable.foldedByStreet)
return false;
const foldedMap = currentTable.foldedByStreet;
if (streetName === 'flop')
return foldedMap.preflop.has(playerIndex);
if (streetName === 'turn')
return (foldedMap.preflop.has(playerIndex) ||
foldedMap.flop.has(playerIndex));
if (streetName === 'river')
return (foldedMap.preflop.has(playerIndex) ||
foldedMap.flop.has(playerIndex) ||
foldedMap.turn.has(playerIndex));
return false;
};
for (let i = 0; i < hand.players.length; i++) {
if (!table.players[i].isInactive && !foldedPriorToStreet(i, lastStreetKey, table)) {
uncalledBetContenders.add(i);
}
}
const { amount: uncalled, playerIndex: uncalledIdx } = calcUncalledBet(finalStreetBets, uncalledBetContenders, lastStreetActions);
if (uncalled > 0 && uncalledIdx != null) {
output.push(`Uncalled bet (${formatMoney(uncalled, currency)}) returned to ${hand.players[uncalledIdx]}`);
}
// Pot collection
const winnersFromWinnings = table.players.filter((p) => p.winnings > 0);
const soleSurvivor = table.players.filter((p) => !p.hasFolded && !p.isInactive);
if (winnersFromWinnings.length > 0) {
for (const winner of winnersFromWinnings) {
if (soleSurvivor.length === 1 && winner === soleSurvivor[0] && !table.isShowdown) {
let actualPotCollected;
if (typeof hand.totalPot === 'number' && hand.totalPot > 0) {
actualPotCollected = hand.totalPot - (table.rake || 0);
}
else {
actualPotCollected =
(typeof table.finalPot === 'number' ? table.finalPot : table.pot) - (table.rake || 0);
}
if (uncalledIdx === winner.idx &&
uncalled > 0 &&
winner.winnings > actualPotCollected) {
output.push(`${winner.name} collected ${formatMoney(actualPotCollected, currency)} from pot`);
}
else if (actualPotCollected >= 0) {
output.push(`${winner.name} collected ${formatMoney(actualPotCollected, currency)} from pot`);
}
else {
output.push(`${winner.name} collected ${formatMoney(winner.winnings, currency)} from pot`);
}
}
else {
output.push(`${winner.name} collected ${formatMoney(winner.winnings, currency)} from pot`);
}
}
}
else if (soleSurvivor.length === 1 && !table.isShowdown) {
const winnerPlayer = soleSurvivor[0];
let collectedAmount;
if (typeof hand.totalPot === 'number' && hand.totalPot > 0) {
collectedAmount = hand.totalPot - (table.rake || 0);
}
else {
collectedAmount =
(typeof table.finalPot === 'number' ? table.finalPot : table.pot) - (table.rake || 0);
}
if (collectedAmount > 0) {
output.push(`${winnerPlayer.name} collected ${formatMoney(collectedAmount, currency)} from pot`);
}
}
return output;
}
// Helper for generateSummaryBlock
function getPlayerSummaryStatusLine(playerTableInfo, // from table.players[idx]
hand, table, currency, playerOriginalIndex) {
if (table.isHandComplete) {
let playerWinAmount = playerTableInfo.winnings;
if (playerWinAmount <= 0 && !table.isShowdown) {
const soleSurvivorForSummary = table.players.filter((p) => !p.hasFolded && !p.isInactive);
if (soleSurvivorForSummary.length === 1 &&
playerTableInfo === soleSurvivorForSummary[0]) {
const calculatedWin = (typeof hand.totalPot === 'number' && hand.totalPot > 0
? hand.totalPot
: typeof table.finalPot === 'number'
? table.finalPot
: table.pot) - (table.rake || 0);
if (calculatedWin > 0) {
playerWinAmount = calculatedWin;
}
}
}
if (playerWinAmount > 0) {
const winAmount = playerWinAmount;
const winningCardsArr = playerTableInfo.cards;
const hasWinningCards = winningCardsArr?.length === 2 && !winningCardsArr.includes('??');
if (hasWinningCards) {
const handDesc = describeHand(winningCardsArr, table.board);
return `showed [${winningCardsArr.join(' ')}] and won (${formatMoney(winAmount, currency)})${handDesc ? ' with ' + handDesc.slice(1, -1) : ''}`;
}
else {
return `collected (${formatMoney(winAmount, currency)})`;
}
}
else if (playerTableInfo.hasFolded) {
const foldedMap = table.foldedByStreet;
if (foldedMap?.preflop.has(playerOriginalIndex)) {
return playerTableInfo.totalBet === 0
? "folded before Flop (didn't bet)"
: 'folded before Flop';
}
else if (foldedMap?.flop.has(playerOriginalIndex)) {
return 'folded on the Flop';
}
else if (foldedMap?.turn.has(playerOriginalIndex)) {
return 'folded on the Turn';
}
else if (foldedMap?.river.has(playerOriginalIndex)) {
return 'folded on the River';
}
else {
return 'folded';
}
}
else if (playerTableInfo.hasShownCards && !playerTableInfo.winnings) {
const shownCardsArr = playerTableInfo.cards;
const hasShownValidCards = shownCardsArr?.length === 2 && !shownCardsArr.includes('??');
if (hasShownValidCards) {
const handDesc = describeHand(shownCardsArr, table.board);
return `showed [${shownCardsArr.join(' ')}] and lost${handDesc ? ' with ' + handDesc.slice(1, -1) : ''}`;
}
else {
return `mucked hand`;
}
}
else {
return `mucked hand`;
}
}
else { // Hand is NOT complete
if (playerTableInfo.hasFolded) {
const foldedMap = table.foldedByStreet;
if (foldedMap?.preflop.has(playerOriginalIndex)) {
return playerTableInfo.totalBet === 0
? "folded before Flop (didn't bet)"
: 'folded before Flop';
}
else if (foldedMap?.flop.has(playerOriginalIndex)) {
return 'folded on the Flop';
}
else if (foldedMap?.turn.has(playerOriginalIndex)) {
return 'folded on the Turn';
}
else if (foldedMap?.river.has(playerOriginalIndex)) {
return 'folded on the River';
}
else {
return 'folded';
}
}
else {
const playerCardsArr = playerTableInfo.cards;
if (playerCardsArr?.length === 2 && !playerCardsArr.includes('??')) {
return `shows ${playerCardsArr.map((card) => `${rankToWord(card[0])} ${card[1] === 's' ? 'of Spades' : card[1] === 'h' ? 'of Hearts' : card[1] === 'd' ? 'of Diamonds' : card[1] === 'c' ? 'of Clubs' : ''}`).join(', ')}, got combination ${describeHand(playerCardsArr, table.board)}`
.replaceAll('(', '')
.replaceAll(')', '');
}
else {
return ''; // Still in hand, no cards or '??'
}
}
}
}
function generateSummaryBlock(hand, table, seatInfo, currency, buttonIdx, sbIdx, bbIdx) {
const output = ['*** SUMMARY ***'];
let summaryPot;
if (typeof hand.totalPot === 'number' && hand.totalPot > 0) {
summaryPot = hand.totalPot;
}
else {
const robustPot = getRobustFinalPot(table.players, hand); // Pass hand to getRobustFinalPot
if (typeof robustPot === 'number') {
summaryPot = robustPot;
}
else {
summaryPot = typeof table.finalPot === 'number' ? table.finalPot : table.pot;
}
}
output.push(`Total pot ${formatMoney(summaryPot, currency)} | Rake ${formatMoney(table.rake || 0, currency)}`);
if (table.board.length > 0) {
output.push(`Board [${table.board.join(' ')}]`);
}
for (const { seat, idx, player, isInactive } of seatInfo) {
let roleLabel = '';
if (idx === buttonIdx)
roleLabel = ' (button)';
if (idx === sbIdx)
roleLabel += ' (small blind)';
if (idx === bbIdx)
roleLabel += ' (big blind)';
let baseSeatStr = `Seat ${seat}: ${player}`;
if (isInactive) {
output.push(baseSeatStr);
continue;
}
const statusDetail = getPlayerSummaryStatusLine(table.players[idx], hand, table, currency, idx);
let finalSeatStr = baseSeatStr;
if (roleLabel) {
finalSeatStr += roleLabel;
}
if (statusDetail) {
finalSeatStr += ` ${statusDetail}`;
}
output.push(finalSeatStr);
}
return output;
}
// --- MAIN NARRATIVE FUNCTION ---
/**
* @deprecated Use Poker.Hand.serialize(hand, 'pokerstars') instead
* This function is kept for backward compatibility
*/
export function phhToNarrative(hand) {
const table = createTable(hand);
const currency = hand.currency || 'USD';
const timeZone = hand.timeZone || 'UTC';
const timestamp = hand.time || hand.timestamp || Date.now();
const seatCount = hand.seatCount || hand.players.length;
// 1. Resolve Hand Setup
const setupInfo = resolveHandSetupInfo(hand, table);
const { seatInfo, buttonIdx, sbIdx, bbIdx, nominalSbValue, nominalBbValueForHeader, seatsForHeader, } = setupInfo;
// 2. Generate Header
const tableName = typeof hand.table === 'string' && hand.table ? hand.table : 'fun time';
const [header, tableLine] = getVenueHeader(hand, seatsForHeader, buttonIdx, sbIdx, bbIdx, currency, nominalSbValue, nominalBbValueForHeader, timestamp, timeZone, String(tableName), seatCount);
const output = [header, tableLine];
// 3. Generate Seat Lines
output.push(...generateSeatLines(seatInfo, currency));
// 4. Generate Post Narrations
output.push(...generatePostNarrations(hand, table, seatInfo, currency, nominalSbValue, nominalBbValueForHeader, sbIdx, bbIdx));
// 5. Parse Actions into Streets
const { streets: parsedStreets, board: parsedBoard } = parseActionsToStreets(hand.actions);
// Update table.board if it was determined by parseActionsToStreets
// This is a bit of a hack; ideally, table creation or an update step would handle board population
// For now, let's ensure narrateStreet uses the parsedBoard if available.
// Actually, createTable already populates table.board correctly based on 'd db' actions.
// So, parsedBoard from parseActionsToStreets is primarily for guiding street transitions here.
// 6. Narrate Hole Cards Dealt
output.push('*** HOLE CARDS ***');
output.push(...generateHoleCardDealNarrations(hand));
// 7. Narrate Each Street
let runningStacks = hand.startingStacks.slice();
const allStreetResults = { preflop: null, flop: null, turn: null, river: null };
const preflopInitialBet = hand.blindsOrStraddles[bbIdx] || 0;
allStreetResults.preflop = narrateStreet(parsedStreets.preflop, hand, table, currency, 'preflop', runningStacks, preflopInitialBet);
if (allStreetResults.preflop) {
output.push(...allStreetResults.preflop.narrationLines);
runningStacks = allStreetResults.preflop.stacksAtStreetEnd;
}
if (table.board.length >= 3) { // Check actual table.board from createTable
output.push(`*** FLOP *** [${table.board.slice(0, 3).join(' ')}]`);
allStreetResults.flop = narrateStreet(parsedStreets.flop, hand, table, currency, 'flop', runningStacks);
if (allStreetResults.flop) {
output.push(...allStreetResults.flop.narrationLines);
runningStacks = allStreetResults.flop.stacksAtStreetEnd;
}
}
if (table.board.length >= 4) {
output.push(`*** TURN *** [${table.board.slice(0, 3).join(' ')}] [${table.board[3]}]`);
allStreetResults.turn = narrateStreet(parsedStreets.turn, hand, table, currency, 'turn', runningStacks);
if (allStreetResults.turn) {
output.push(...allStreetResults.turn.narrationLines);
runningStacks = allStreetResults.turn.stacksAtStreetEnd;
}
}
if (table.board.length === 5) {
output.push(`*** RIVER *** [${table.board.slice(0, 4).join(' ')}] [${table.board[4]}]`);
allStreetResults.river = narrateStreet(parsedStreets.river, hand, table, currency, 'river', runningStacks);
if (allStreetResults.river) {
output.push(...allStreetResults.river.narrationLines);
runningStacks = allStreetResults.river.stacksAtStreetEnd;
}
}
// 8. Narrate Showdown
if (table.isShowdown) {
output.push('*** SHOW DOWN ***');
output.push(...generateShowdownNarrations(hand, table));
}
// 9. Narrate End of Hand Resolution
if (table.isHandComplete) {
output.push(...generateEndOfHandResolutionNarrations(hand, table, parsedStreets, allStreetResults, currency));
}
// 10. Generate Summary Block
output.push(...generateSummaryBlock(hand, table, seatInfo, currency, buttonIdx, sbIdx, bbIdx));
return output.join('\n');
}
// Moved calcUncalledBet outside phhToNarrative to fix linter error
function calcUncalledBet(playerBets, activePlayers, lastActions) {
if (activePlayers.size <= 1 &&
!(activePlayers.size === 1 &&
playerBets[Array.from(activePlayers)[0]] > 0 &&
lastActions.length > 0 &&
getActionType(lastActions[lastActions.length - 1]) !== 'cc')) {
// If only one active player, only proceed if they made a bet that wasn't a call (e.g. an uncalled open bet)
// The check also ensures playerBets[active_player_idx] > 0 to avoid issues with 0 bets.
// And that lastActions is not empty to prevent error on getActionType
return { amount: 0, playerIndex: null };
}
const activePlayerBets = Array.from(activePlayers).map(idx => ({ idx, bet: playerBets[idx] }));
const maxBet = Math.max(...activePlayerBets.map(p => p.bet));
const playersWithMaxBet = activePlayerBets.filter(p => p.bet === maxBet);
// If multiple players have the max bet or no player has a bet, no uncalled portion
if (playersWithMaxBet.length !== 1) {
return { amount: 0, playerIndex: null };
}
// Find the second highest bet from active players
const maxBetPlayer = playersWithMaxBet[0].idx;
const otherActiveBets = activePlayerBets.filter(p => p.idx !== maxBetPlayer).map(p => p.bet);
// If no other active players, return 0
if (otherActiveBets.length === 0) {
return { amount: 0, playerIndex: null };
}
const secondHighest = Math.max(...otherActiveBets);
const uncalledAmount = maxBet - secondHighest;
return {
amount: uncalledAmount > 0 ? uncalledAmount : 0,
playerIndex: uncalledAmount > 0 ? maxBetPlayer : null,
};
}
function narrateStreet(actions, hand, table, currency, street, stacksAtStreetStart, initialCurrentBet = 0) {
const playerCount = hand.players.length;
const stacks = stacksAtStreetStart.slice(); // Use stacks passed from phhToNarrative
const bets = Array(playerCount).fill(0);
let output = [];
let currentBet = initialCurrentBet; // Use initialCurrentBet for preflop BB
let hasBet = initialCurrentBet > 0; // If BB is posted, a bet has been made
// Account for posted blinds/straddles and dead blinds in initial bets and stacks for preflop
if (street === 'preflop') {
const deadBlindsInput = hand._deadBlinds || []; // Handle if _deadBlinds is undefined
for (let i = 0; i < playerCount; i++) {
const liveBlindOrStraddle = hand.blindsOrStraddles[i] || 0;
const deadBlindAmount = deadBlindsInput[i] || 0; // Get dead blind for the player
if (liveBlindOrStraddle > 0) {
// Live blinds/straddles count towards the current bet on the street
bets[i] = liveBlindOrStraddle;
stacks[i] -= liveBlindOrStraddle; // Deduct live portion from stack
}
if (deadBlindAmount > 0) {
stacks[i] -= deadBlindAmount; // Deduct dead portion from stack
// Dead blinds do not typically count towards `bets[i]` for the purpose of
// calculating raises or calls against the current `currentBet`. They are "dead".
}
}
}
// Track folded players by street
if (!table.foldedByStreet) {
table.foldedByStreet = {
preflop: new Set(),
flop: new Set(),
turn: new Set(),
river: new Set(),
};
}
for (const action of actions) {
if (action.startsWith('d dh'))
continue;
const idx = getActionPlayerIndex(action) ?? 0;
const type = getActionType(action);
const player = idx >= 0 ? hand.players[idx] : '';
const isPlayerAllIn = !!table.players[idx].isAllIn;
const isLastPlayerAction = hand.actions.filter(a => !a.includes('sm') && a.includes(`p${idx + 1}`)).at(-1) === action;
if (idx < 0)
continue;
if (type === 'f') {
output.push(`${player}: folds`);
// Track fold street for summary
if (table.foldedByStreet) {
table.foldedByStreet[street].add(idx);
}
continue;
}
if (type === 'cc') {
// Call or check
const amount = getActionAmount(action);
// For preflop, players who previously posted blinds "complete" rather than call
const isBlindCompletion = street === 'preflop' &&
hand.blindsOrStraddles[idx] > 0 &&
currentBet > hand.blindsOrStraddles[idx] &&
amount &&
amount > 0;
// Important check: Is there a current bet to call, regardless of reported amount?
// This fixes issues where cc after a raise should be a call, not a check
if (currentBet > 0 && bets[idx] < currentBet) {
// This is a call
const callAmount = currentBet - bets[idx];
const actualCall = Math.min(callAmount, stacks[idx]);
bets[idx] += actualCall;
stacks[idx] -= actualCall;
let line = `${player}: calls ${formatMoney(actualCall, currency)}`;
if (stacks[idx] === 0 || (isLastPlayerAction && isPlayerAllIn))
line += ' and is all-in';
output.push(line);
}
else if (!amount || amount === 0 || amount === bets[idx]) {
// True check (no money added)
output.push(`${player}: checks${isLastPlayerAction && isPlayerAllIn ? ' and is all-in' : ''}`);
}
else {
// Call with explicit amount
let callAmount = amount - bets[idx]; // Only the additional amount put in
if (callAmount < 0)
callAmount = 0;
// Apply to stack and bets
const actualCall = Math.min(callAmount, stacks[idx]);
bets[idx] += actualCall;
stacks[idx] -= actualCall;
let line = '';
if (isBlindCompletion) {
line = `${player}: calls ${formatMoney(actualCall, currency)}`;
}
else {
line = `${player}: calls ${formatMoney(actualCall, currency)}`;
}
if (stacks[idx] === 0 || (isLastPlayerAction && isPlayerAllIn))
line += ' and is all-in';
output.push(line);
}
continue;
}
if (type === 'cbr') {
// Bet or raise
const amount = getActionAmount(action);
if (amount === undefined)
continue;
const previousBet = bets[idx];
const totalBet = Math.min(amount, previousBet + stacks[idx]);
const putIn = totalBet - previousBet;
bets[idx] = totalBet;
stacks[idx] -= putIn;
let line = '';
if (!hasBet || currentBet === 0) {
// Open bet (first bet on street)
line = `${player}: bets ${formatMoney(putIn, currency)}`;
hasBet = true;
}
else {
// Raise - correctly calculate raise TO and BY amounts
const raiseBy = totalBet - currentBet;
line = `${player}: raises ${raiseBy > 0 ? `${formatMoney(raiseBy, currency)} ` : ''}to ${formatMoney(totalBet, currency)}`;
}
if (stacks[idx] === 0 || (isLastPlayerAction && isPlayerAllIn))
line += ' and is all-in';
output.push(line);
currentBet = Math.max(currentBet, totalBet);
continue;
}
if (type === 'm') {
output.push(`${player} said, "${action.split(' ').slice(2).join(' ')}"`);
continue;
}
}
return { narrationLines: output, streetBets: bets, stacksAtStreetEnd: stacks };
}
// Helper: PokerStars-style hand description
export function describeHand(cards, board) {
// cards: array of 2 strings, e.g. ['Kd', 'Ad']
if (!cards || cards.length !== 2)
return '';
if (!board)
board = [];
const hand = [cards[0], cards[1]];
const boardCards = board.filter(c => c && c.length === 2).map(c => c);
const all = hand.concat(boardCards);
if (all.length < 5)
return '';
// Find the best 5-card hand
const best = getBestCardCombo(all);
const bestStrength = calculateHandStrength(best);
const category = getRankCategory(bestStrength);
switch (category) {
case 'High Card':
return `(high card ${handHighCard(best)})`;
case 'One Pair':
return `(a pair of ${handPairRank(best, 2)})`;
case 'Two Pair':
return `(two pair, ${handPairRank(best, 2)})`;
case 'Three of a Kind':
return `(three of a kind, ${handPairRank(best, 3)})`;
case 'Straight':
return `(a straight, ${handStraightHigh(best)})`;
case 'Flush':
return `(a flush, ${handHighCard(best)} high)`;
case 'Full House':
return `(a full house, ${handPairRank(best, 3)} full of ${handPairRank(best, 2)})`;
case 'Four of a Kind':
return `(four of a kind, ${handPairRank(best, 4)})`;
case 'Straight Flush':
return `(a straight flush, ${handStraightHigh(best)})`;
default:
return `(${category})`;
}
}
// Helper: get the highest card in the hand
function handHighCard(cards) {
const rankOrder = ['2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K', 'A'];
const sorted = cards.slice().sort((a, b) => rankOrder.indexOf(b[0]) - rankOrder.indexOf(a[0]));
return rankToWord(sorted[0][0]);
}
// Helper: get the rank(s) of the pair/trips/quads for description
function handPairRank(cards, count) {
const rankOrder = ['2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K', 'A'];
const counts = {};
for (const c of cards)
counts[c[0]] = (counts[c[0]] || 0) + 1;
const pairs = Object.entries(counts)
.filter(([_, v]) => v === count)
.map(([r]) => r)
.sort((a, b) => rankOrder.indexOf(b) - rankOrder.indexOf(a));
if (pairs.length === 0)
return '';
if (count === 2 && pairs.length >= 2)
return `${rankToPlural(pairs[0])} and ${rankToPlural(pairs[1])}`;
return rankToPlural(pairs[0]);
}
// Pluralize rank for PokerStars-style output
function rankToPlural(r) {
switch (r) {
case 'A':
return 'Aces';
case 'K':
return 'Kings';
case 'Q':
return 'Queens';
case 'J':
return 'Jacks';
case 'T':
return 'Tens';
case '9':
return 'Nines';
case '8':
return 'Eights';
case '7':
return 'Sevens';
case '6':
return 'Sixes';
case '5':
return 'Fives';
case '4':
return 'Fours';
case '3':
return 'Threes';
case '2':
return 'Deuces';
default:
return r;
}
}
// Helper: get the high card of a straight/straight flush
function handStraightHigh(cards) {
const rankOrder = ['2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K', 'A'];
const unique = Array.from(new Set(cards.map(c => c[0])));
const idxs = unique.map(r => rankOrder.indexOf(r)).sort((a, b) => b - a);
if (idxs.includes(12) &&
idxs.includes(0) &&
idxs.includes(1) &&
idxs.includes(2) &&
idxs.includes(3))
return 'Five';
return rankToWord(rankOrder[idxs[0]]);
}
// Helper: rank to PokerStars word
function rankToWord(r) {
switch (r) {
case 'A':
return 'Ace';
case 'K':
return 'King';
case 'Q':
return 'Queen';
case 'J':
return 'Jack';
case 'T':
return 'Ten';
case '9':
return 'Nine';
case '8':
return 'Eight';
case '7':
return 'Seven';
case '6':
return 'Six';
case '5':
return 'Five';
case '4':
return 'Four';
case '3':
return 'Three';
case '2':
return 'Two';
default:
return r;
}
}
// Helper: Find the best 5-card hand from a set of cards
function getBestCardCombo(cards) {
let best = cards.slice(0, 5); // Ensure initial best is typed as Card[]
if (cards.length < 5)
return cards; // Not enough cards for a 5-card hand
let bestStrength = calculateHandStrength(best);
for (let i = 0; i < cards.length - 4; ++i) {
for (let j = i + 1; j < cards.length - 3; ++j) {
for (let k = j + 1; k < cards.length - 2; ++k) {
for (let l = k + 1; l < cards.length - 1; ++l) {
for (let m = l + 1; m < cards.length; ++m) {
const combo = [cards[i], cards[j], cards[k], cards[l], cards[m]];
const strength = calculateHandStrength(combo);
if (strength < bestStrength) {
bestStrength = strength;
best = combo;
}
}
}
}
}
}
return best;
}
// --- Robust uncalled bet and pot calculation --- (Moved near its usage in generateSummaryBlock or keep global)
// Let's move it to be a global helper as it was before, for simplicity in this refactoring step.
function getRobustFinalPot(players, hand) {
const active = players.filter(p => !p.hasFolded && !p.isInactive);
if (active.length !== 1)
return undefined; // Only for single-winner-by-fold
const winner = active[0];
const others = players.filter(p => p !== winner);
const highestOther = Math.max(0, ...others.map(p => p.totalBet || 0));
const winnerTotalBet = winner.totalBet || 0;
const uncalled = winnerTotalBet - highestOther;
let winnerBet = winnerTotalBet;
if (uncalled > 0) {
winnerBet -= uncalled;
}
// Add antes to the pot
const anteSum = Array.isArray(hand.antes) ? hand.antes.reduce((a, b) => a + b, 0) : 0;
const pot = others.reduce((sum, p) => sum + (p.totalBet || 0), 0) + winnerBet + anteSum;
return pot;
}
//# sourceMappingURL=narrative.js.map