UNPKG

@idealic/poker-engine

Version:

Professional poker game engine and hand evaluator with built-in iterator utilities

1,078 lines 47.2 kB
/** * 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