@idealic/poker-engine
Version:
Professional poker game engine and hand evaluator with built-in iterator utilities
589 lines • 22.1 kB
JavaScript
/**
* 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