UNPKG

@idealic/poker-engine

Version:

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

589 lines 22.1 kB
/** * Single Function Parser (PokerStars → PHH) * * @instructions Parse all actions exactly as they appear in the hand history. * Do not attempt to optimize or remove redundant actions during parsing. * Compression of redundant actions should be handled separately by compactActions. */ // Regex patterns for parsing different parts of the hand history const HAND_HEADER_REGEX = /^(.*?) Hand #(\d+).*?(.*?)$/; const TABLE_INFO_REGEX = /Table '([^']+)' (\d+)-max(?: \(Play Money\))? Seat #(\d+) is the button/; const SB_REGEX = /^(.*?): posts small blind ([^\n]+?)$/; const BB_REGEX = /^(.*?): posts big blind ([^\n]+?)$/; const DEAD_REGEX = /^(.*?): posts small & big blinds ([^\n]+?)$/; const SEAT_LINE_REGEX = /^Seat (\d+): (.+) \(([^\n]+?) in chips\)( is sitting out)?/i; const DEALT_TO_REGEX = /Dealt to ([^[]+) \[([^\]]+)\]/; const SHOWS_REGEX = /^(.*?): shows \[([^\]]+)\]/; const MUCKS_REGEX = /^(.*?): mucks hand$/; const SUMMARY_MUCKED = /^Seat \d+: ([^(]+).*?mucked.*?\[([^\]]+)\]/; const STREET_REGEX = /^\*\*\* (FLOP|TURN|RIVER) \*\*\* (\[([^\]]+)\]\s*)*$/; const ACTION_LINE_REGEX = /^(.*?): (folds|checks|calls|bets|raises)(?: ([^\n]+?))?(?: to ([^\n]+?))?$/; const CHAT_REGEX = /^(.*?) said, "([^"]+)"/; // Action compaction/expansion helpers /** * Returns the correct US Eastern time zone abbreviation ("EDT" or "EST") for a given date string. * @param dateStr Date string in PokerStars format (e.g. '11/04/2024 10:23:17 ET') * @returns "EDT" if in daylight saving time, "EST" otherwise */ function getEasternTimeZoneAbbr(dateStr) { // Remove trailing 'ET' or 'DST' if present and trim const cleaned = dateStr.replace(/\s*(ET|DST)$/, '').trim(); // Support both MM/DD/YYYY and YYYY/MM/DD // Try YYYY/MM/DD first, then MM/DD/YYYY let match = cleaned.match(/(\d{4})\/(\d{2})\/(\d{2}) (\d{1,2}):(\d{1,2}):(\d{1,2})/); let year, month, day, hour, min, sec; if (match) { [, year, month, day, hour, min, sec] = match; } else { match = cleaned.match(/(\d{2})\/(\d{2})\/(\d{4}) (\d{1,2}):(\d{1,2}):(\d{1,2})/); if (!match) throw new Error(`Invalid date string: ${dateStr}`); [, month, day, year, hour, min, sec] = match; } // Construct a UTC date for DST calculation const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(min), Number(sec))); const y = date.getUTCFullYear(); // DST starts: second Sunday in March const march = new Date(Date.UTC(y, 2, 1)); const marchDay = march.getUTCDay(); const secondSundayInMarch = 8 + ((7 - marchDay) % 7); const dstStart = new Date(Date.UTC(y, 2, secondSundayInMarch, 7)); // 2am local = 7am UTC // DST ends: first Sunday in November const november = new Date(Date.UTC(y, 10, 1)); const novemberDay = november.getUTCDay(); const firstSundayInNovember = 1 + ((7 - novemberDay) % 7); const dstEnd = new Date(Date.UTC(y, 10, firstSundayInNovember, 6)); // 2am local = 6am UTC return date >= dstStart && date < dstEnd ? 'EDT' : 'EST'; } export function parseHandIdentifiers(line) { const hdr = HAND_HEADER_REGEX.exec(line); if (!hdr) { return { venue: '', id: '', timestamp: 0 }; } const venue = String(hdr[1]); const id = String(hdr[2]); const dateStr = hdr[3].trim().replace(/#\d+/g, ''); const formattedDateStr = dateStr.includes('ET') ? dateStr.replace('ET', getEasternTimeZoneAbbr(dateStr)) : 'UTC'; const timestamp = Date.parse(formattedDateStr.match(/(\d{4}\/\d{2}\/\d{2}|\d{2}\/\d{2}\/\d{4}) \d{1,2}:\d{1,2}:\d{1,2} [ET|EST|EDT]+/)?.[0] ?? 'UTC'); return { venue, id, timestamp }; } export function parseHeaderInfo(lines) { const { venue, id, timestamp } = parseHandIdentifiers(lines[0]); let table = ''; let timeZone = 'UTC'; let currency = 'USD'; let smallBlind = 0, bigBlind = 0; let variant = 'NT'; // Default to No-limit Texas hold 'em let seatCount = 9; // Default to 9-max let buttonSeat = 1; for (const line of lines) { // Check for play money indicator if (line.includes('(Play Money)')) { currency = 'PLAY'; } const tableMatch = TABLE_INFO_REGEX.exec(line); if (tableMatch) { table = tableMatch[1]; seatCount = parseInt(tableMatch[2], 10); buttonSeat = parseInt(tableMatch[3], 10); } const sbM = SB_REGEX.exec(line); if (sbM) smallBlind = parseAmount(sbM[2]); const bbM = BB_REGEX.exec(line); if (bbM) bigBlind = parseAmount(bbM[2]); // Determine variant based on line content if (line.includes("No Limit Hold'em")) variant = 'NT'; else if (line.includes("Fixed Limit Hold'em")) variant = 'FT'; else if (line.includes('Pot Limit Omaha')) variant = 'PO'; else if (line.includes('Fixed Limit Omaha Hi/Lo')) variant = 'FO/8'; else if (line.includes('Fixed Limit Seven Card Stud')) variant = 'F7S'; else if (line.includes('Fixed Limit Razz')) variant = 'FR'; // Add more conditions for other variants as needed } return { id, venue, table, timestamp, timeZone, currency, smallBlind, bigBlind, variant, seatCount, buttonSeat, }; } function parseSeatInfo(lines) { const seatInfos = []; const inactiveIndices = []; let counter = 0; for (const line of lines) { const seatM = SEAT_LINE_REGEX.exec(line); if (seatM) { counter++; const isSittingOut = !!seatM[4]; // Use capture group 4 from the regex const playerInfo = { seat: +seatM[1], seatIndex: counter, name: seatM[2].trim(), stack: parseAmount(seatM[3]), isInactive: isSittingOut ? 1 : 0, }; seatInfos.push(playerInfo); } } seatInfos.sort((a, b) => a.seat - b.seat); const nameToPos = new Map(); seatInfos.forEach((si, idx) => { nameToPos.set(si.name, idx + 1); inactiveIndices[idx] = si.isInactive; }); const players = seatInfos.map(si => si.name); const startingStacks = seatInfos.map(si => si.stack); return { seatInfos, nameToPos, players, startingStacks, inactiveIndices }; } function parseCardInfo(lines) { const holeCards = new Map(); const showdownCards = new Map(); const showdownPlayers = new Set(); for (const line of lines) { const dt = DEALT_TO_REGEX.exec(line); if (dt) { holeCards.set(dt[1].trim(), dt[2].trim()); } const sm = SHOWS_REGEX.exec(line); if (sm) { showdownPlayers.add(sm[1].trim()); showdownCards.set(sm[1].trim(), sm[2].trim()); } const mk = MUCKS_REGEX.exec(line); if (mk) { showdownPlayers.add(mk[1].trim()); } const summ = SUMMARY_MUCKED.exec(line); if (summ) { showdownPlayers.add(summ[1].trim()); if (summ[2].trim()) showdownCards.set(summ[1].trim(), summ[2].trim()); } } return { holeCards, showdownCards, showdownPlayers }; } // Helper functions function parseAmount(amountStr) { if (!amountStr) return 0; return parseFloat(parseFloat(amountStr.replace(/[^\d.]/g, '')).toFixed(5)); } function formatCards(cards) { // Remove any whitespace, brackets, and normalize card format return cards.replace(/[\s+\]\[]/g, '').replace(/10/g, 'T'); } function formatAction(pos, type, amount) { if (amount != undefined) { return `p${pos} ${type} ${amount}`; } return `p${pos} ${type}`; } function formatBoardAction(cards) { // Ensure we're dealing with a clean card sequence const formattedCards = formatCards(cards); return `d db ${formattedCards}`; } function formatShowdownAction(pos, cards) { // Ensure we're dealing with a clean card sequence const formattedCards = formatCards(cards); return `p${pos} sm${cards ? ` ${formattedCards}` : ''}`; } function processActions(lines, nameToPos, seatCount, pendingHoleCards, showdownCards, showdownPlayers, inactiveIndices) { const actions = []; const foldedPlayers = new Set(); let street = 'preflop'; let isShowdown = false; // Deal hole cards first, before any actions // Get all players in position order const playersByPosition = Array.from(nameToPos.entries()) .sort((a, b) => a[1] - b[1]) .map(([name]) => name); // Add deal actions for each player for (let i = 0; i < playersByPosition.length; i++) { if (inactiveIndices[i]) continue; const name = playersByPosition[i]; const pos = i + 1; const cards = pendingHoleCards.has(name) ? formatCards(pendingHoleCards.get(name)) : '????'; actions.push(`d dh p${pos} ${cards}`); } pendingHoleCards.clear(); for (const line of lines) { // Check if we've hit showdown section if (line.startsWith('*** SHOW DOWN ***') || line.includes('shows') || line.includes('mucks')) { isShowdown = true; } // Check for chat messages const chat = CHAT_REGEX.exec(line); if (chat) { const playerName = chat[1].trim(); const message = chat[2].trim(); if (nameToPos.has(playerName)) { const pos = nameToPos.get(playerName); actions.push(`p${pos} m ${message}`); } continue; } // Street transition check const sr = STREET_REGEX.exec(line); if (sr) { street = sr[1].toLowerCase(); // Board dealing if (street === 'flop') { actions.push(formatBoardAction(sr[2])); } else { const newCard = sr[3].split(/\s/g).pop(); if (newCard) { actions.push(formatBoardAction(newCard)); } } continue; } // Otherwise, see if it's an action line const m = ACTION_LINE_REGEX.exec(line); if (!m) continue; const playerName = m[1].trim(); // Skip actions from players who have already folded if (foldedPlayers.has(playerName)) continue; const verb = m[2]; // folds/checks/calls/bets/raises const amountStr = parseAmount(m[4] || m[3]); // numeric amount if present if (!nameToPos.has(playerName)) continue; const pos = nameToPos.get(playerName); // Determine action type and format switch (verb) { case 'folds': { actions.push(formatAction(pos, 'f')); foldedPlayers.add(playerName); break; } case 'checks': { actions.push(formatAction(pos, 'cc')); break; } case 'calls': { const isAllIn = line.toLowerCase().includes('all-in'); actions.push(isAllIn ? formatAction(pos, 'cc', amountStr) : formatAction(pos, 'cc')); break; } case 'bets': case 'raises': { actions.push(formatAction(pos, 'cbr', amountStr)); break; } } } // Add showdown actions if needed if (isShowdown) { showdownPlayers.forEach(player => { if (!foldedPlayers.has(player)) { const pos = nameToPos.get(player); if (pos !== undefined) { const cards = showdownCards.get(player) || ''; actions.push(formatShowdownAction(pos, cards)); } } }); } return { actions }; } // Type guards for game variants function isStudGame(variant) { return variant === 'F7S' || variant === 'F7S/8' || variant === 'FR'; } function isFixedLimitGame(variant) { return variant === 'FT' || variant === 'FO/8' || variant === 'F2L3D' || variant === 'FB'; } /** * Parse PokerStars format hand history * @param handText - Raw PokerStars hand history text * @returns Parsed hand object */ function parsePokerStarsHand(handText) { // 1) Preprocess lines const lines = handText .split(/\n/g) .map(ln => ln.trim()) .filter(Boolean); // 2) Parse header info const { venue, id, table, timestamp, timeZone, currency, smallBlind, bigBlind, variant, seatCount, buttonSeat, } = parseHeaderInfo(lines); // 3) Parse seat info const { seatInfos, nameToPos, players, startingStacks, inactiveIndices } = parseSeatInfo(lines); // Store original seat numbers let seats = seatInfos.sort((a, b) => a.seatIndex - b.seatIndex).map(si => si.seat); // Find the button player's position in our array const seatToPosition = new Map(); // First, create a mapping from seat numbers to positions seatInfos.forEach((si, idx) => { seatToPosition.set(si.seat, idx); }); // Now find the button position using the mapping const buttonPos = seatToPosition.get(buttonSeat); if (buttonPos === undefined) { throw new Error(`Button seat ${buttonSeat} not found in seat info`); } // Set up blinds and straddles array const blindsOrStraddles = Array(players.length).fill(0); const antes = Array(players.length).fill(0); const deadBlinds = Array(players.length).fill(0); // Keep track of which blinds have been posted already let smallBlindPosted = false; // Process all blinds and straddles from the hand history for (const line of lines) { // Small blind const sbM = SB_REGEX.exec(line); if (sbM) { const playerName = sbM[1].trim(); const pos = nameToPos.get(playerName); if (pos !== undefined) { if (smallBlindPosted) { // If SB has already been posted, this is a dead blind deadBlinds[pos - 1] = parseAmount(sbM[2]); } else { blindsOrStraddles[pos - 1] = parseAmount(sbM[2]); smallBlindPosted = true; } } } // Big blind const bbM = BB_REGEX.exec(line); if (bbM) { const playerName = bbM[1].trim(); const pos = nameToPos.get(playerName); if (pos !== undefined) { blindsOrStraddles[pos - 1] = parseAmount(bbM[2]); } } // SB + BB blinds const dbM = DEAD_REGEX.exec(line); if (dbM) { const playerName = dbM[1].trim(); const pos = nameToPos.get(playerName); if (pos !== undefined) { deadBlinds[pos - 1] = smallBlind; blindsOrStraddles[pos - 1] = bigBlind; } } // Straddle const straddleMatch = line.match(/^(.*?): posts straddle ([^\n]+?)$/); if (straddleMatch) { const playerName = straddleMatch[1].trim(); const pos = nameToPos.get(playerName); if (pos !== undefined) { blindsOrStraddles[pos - 1] = parseAmount(straddleMatch[2]); } } // Ante posting const anteMatch = line.match(/^(.*?): posts the ante ([^\n]+?)$/); if (anteMatch) { const playerName = anteMatch[1].trim(); const pos = nameToPos.get(playerName); if (pos !== undefined) { antes[pos - 1] = parseAmount(anteMatch[2]); } } } // 4) Parse card info const { holeCards, showdownCards, showdownPlayers } = parseCardInfo(lines); // 5) Parse rake info let rake = 0; let totalPot = 0; for (const line of lines) { if (line.startsWith('Total pot')) { const match = line.match(/Total pot ([^\n]+)\s*\|\s*Rake ([^\n]+)$/); if (match) { totalPot = parseAmount(match[1]); rake = parseAmount(match[2]); } } } // Track hole cards to deal them in sequence with actions const pendingHoleCards = new Map(); for (const si of seatInfos) { if (holeCards.has(si.name)) { pendingHoleCards.set(si.name, holeCards.get(si.name)); } } // Process all actions const { actions } = processActions(lines, nameToPos, seatCount, pendingHoleCards, showdownCards, showdownPlayers, inactiveIndices); const baseGame = { venue, variant, currency, timestamp, timeZone, players, startingStacks, blindsOrStraddles, antes, actions, year: new Date(timestamp).getFullYear(), month: new Date(timestamp).getMonth() + 1, day: new Date(timestamp).getDate(), time: new Date(timestamp).toTimeString().slice(0, 8), table, id, hand: 0, seatCount, rake, totalPot, seats, }; if (inactiveIndices.some(i => i)) { Object.assign(baseGame, { _inactive: inactiveIndices }); } // Add dead blinds to the game object if any exist if (deadBlinds.some(db => db > 0)) { Object.assign(baseGame, { _deadBlinds: deadBlinds }); } if (isStudGame(variant)) { return { ...baseGame, variant, smallBet: bigBlind, bigBet: bigBlind * 2, bringIn: Math.floor(bigBlind / 4), }; } if (isFixedLimitGame(variant)) { return { ...baseGame, variant, smallBet: bigBlind, bigBet: bigBlind * 2, }; } // For no limit games return { ...baseGame, minBet: bigBlind, }; } /** * Validate JSON parsed object for required Hand fields */ function validateHandObject(obj) { const required = ['players', 'startingStacks', 'blindsOrStraddles', 'antes', 'actions']; for (const field of required) { if (!obj[field]) { throw new Error(`Missing required field: ${field}`); } } if (!Array.isArray(obj.players) || obj.players.length === 0) { throw new Error('players must be a non-empty array'); } if (!Array.isArray(obj.startingStacks) || obj.startingStacks.length !== obj.players.length) { throw new Error('startingStacks must be an array matching players length'); } if (!Array.isArray(obj.blindsOrStraddles) || obj.blindsOrStraddles.length !== obj.players.length) { throw new Error('blindsOrStraddles must be an array matching players length'); } if (!Array.isArray(obj.antes) || obj.antes.length !== obj.players.length) { throw new Error('antes must be an array matching players length'); } if (!Array.isArray(obj.actions)) { throw new Error('actions must be an array'); } } /** * Validate variant-specific fields based on game type */ function validateVariantFields(obj) { const variant = obj.variant; // No-limit variants need minBet const noLimitVariants = ['NT', 'NS', 'PO', 'N2L1D']; if (noLimitVariants.includes(variant)) { if (typeof obj.minBet !== 'number' || obj.minBet <= 0) { throw new Error(`No-limit variant ${variant} requires positive minBet`); } return; } // Fixed-limit variants need smallBet and bigBet const fixedLimitVariants = ['FT', 'FO/8', 'F2L3D', 'FB']; if (fixedLimitVariants.includes(variant)) { if (typeof obj.smallBet !== 'number' || obj.smallBet <= 0) { throw new Error(`Fixed-limit variant ${variant} requires positive smallBet`); } if (typeof obj.bigBet !== 'number' || obj.bigBet <= 0) { throw new Error(`Fixed-limit variant ${variant} requires positive bigBet`); } return; } // Stud variants need smallBet, bigBet, and bringIn const studVariants = ['F7S', 'F7S/8', 'FR']; if (studVariants.includes(variant)) { if (typeof obj.smallBet !== 'number' || obj.smallBet <= 0) { throw new Error(`Stud variant ${variant} requires positive smallBet`); } if (typeof obj.bigBet !== 'number' || obj.bigBet <= 0) { throw new Error(`Stud variant ${variant} requires positive bigBet`); } if (typeof obj.bringIn !== 'number' || obj.bringIn <= 0) { throw new Error(`Stud variant ${variant} requires positive bringIn`); } return; } throw new Error(`Unknown variant: ${variant}`); } /** * Parse JSON format hand * @param jsonText - JSON string representation of a hand * @returns Parsed hand object */ function parseJsonHand(jsonText) { let parsedObj; try { parsedObj = JSON.parse(jsonText); } catch (error) { throw new Error(`Invalid JSON: ${error instanceof Error ? error.message : 'Unknown error'}`); } // Validate required fields validateHandObject(parsedObj); // Set default variant if not provided if (!parsedObj.variant) { parsedObj.variant = 'NT'; // Default to No-limit Texas Hold'em } // Validate variant-specific fields validateVariantFields(parsedObj); // Return the validated object as a Hand return parsedObj; } /** * Parse poker hand from string input in specified format * @param input - String input to parse * @param format - Format type ('pokerstars' or 'json') * @returns Parsed hand object */ export function parseHand(input, format = 'pokerstars') { switch (format) { case 'pokerstars': return parsePokerStarsHand(input); case 'json': return parseJsonHand(input); default: throw new Error(`Unsupported format: ${format}`); } } //# sourceMappingURL=pokerstars.js.map