@idealic/poker-engine
Version:
Professional poker game engine and hand evaluator with built-in iterator utilities
242 lines • 9.47 kB
JavaScript
import { parseHand } from './formats/pokerstars';
import { phhToNarrative } from './formats/narrative';
import { mergeGames } from './utils/state';
/**
* Hand constructor/guard function that creates a validated Hand object.
* Acts as a factory function with sensible defaults and validation.
*
* @param props - Partial Hand properties to create a complete Hand
* @returns A validated Hand object
*
* @example
* const hand = Hand({
* variant: 'NT',
* players: ['Alice', 'Bob'],
* startingStacks: [1000, 1000],
* blindsOrStraddles: [10, 20],
* minBet: 20
* });
*/
export function Hand(props) {
// Provide sensible defaults
const defaults = {
variant: 'NT',
currency: 'USD',
actions: [],
time: new Date().toISOString(),
timeZone: 'UTC',
};
// Merge props with defaults
const hand = { ...defaults, ...props };
// Validate required fields
if (!hand.players || !Array.isArray(hand.players) || hand.players.length === 0) {
throw new Error('Hand requires a non-empty players array');
}
if (!hand.startingStacks || !Array.isArray(hand.startingStacks)) {
throw new Error('Hand requires startingStacks array');
}
if (hand.startingStacks.length !== hand.players.length) {
throw new Error('startingStacks must match players array length');
}
if (!hand.blindsOrStraddles || !Array.isArray(hand.blindsOrStraddles)) {
throw new Error('Hand requires blindsOrStraddles array');
}
if (hand.blindsOrStraddles.length !== hand.players.length) {
throw new Error('blindsOrStraddles must match players array length');
}
if (!hand.antes || !Array.isArray(hand.antes)) {
// Provide default antes if not specified
hand.antes = new Array(hand.players.length).fill(0);
}
if (hand.antes.length !== hand.players.length) {
throw new Error('antes must match players array length');
}
// Variant-specific validation
const noLimitVariants = ['NT', 'NS', 'PO', 'N2L1D'];
const fixedLimitVariants = [
'FT',
'FO/8',
'F7S',
'F7S/8',
'FR',
'F2L3D',
'FB',
];
const studVariants = ['F7S', 'F7S/8', 'FR'];
if (noLimitVariants.includes(hand.variant)) {
if (typeof hand.minBet !== 'number' || hand.minBet <= 0) {
throw new Error(`No-limit variant ${hand.variant} requires positive minBet`);
}
// Ensure other betting structure fields are not present
delete hand.smallBet;
delete hand.bigBet;
delete hand.bringIn;
}
else if (studVariants.includes(hand.variant)) {
if (typeof hand.smallBet !== 'number' || hand.smallBet <= 0) {
throw new Error(`Stud variant ${hand.variant} requires positive smallBet`);
}
if (typeof hand.bigBet !== 'number' || hand.bigBet <= 0) {
throw new Error(`Stud variant ${hand.variant} requires positive bigBet`);
}
if (typeof hand.bringIn !== 'number' || hand.bringIn <= 0) {
throw new Error(`Stud variant ${hand.variant} requires positive bringIn`);
}
// Ensure minBet is not present
delete hand.minBet;
}
else if (fixedLimitVariants.includes(hand.variant)) {
if (typeof hand.smallBet !== 'number' || hand.smallBet <= 0) {
throw new Error(`Fixed-limit variant ${hand.variant} requires positive smallBet`);
}
if (typeof hand.bigBet !== 'number' || hand.bigBet <= 0) {
throw new Error(`Fixed-limit variant ${hand.variant} requires positive bigBet`);
}
// Ensure other betting structure fields are not present
delete hand.minBet;
delete hand.bringIn;
}
else {
throw new Error(`Unknown variant: ${hand.variant}`);
}
return hand;
}
/**
* Hand namespace for methods that operate on Hand objects.
*/
(function (Hand) {
/**
* Parse a hand from string format (PokerStars or JSON)
* @param input - String representation of the hand
* @param format - Format type ('pokerstars' or 'json')
* @returns Parsed Hand object
*/
Hand.parse = parseHand;
/**
* Export a hand with an optional player perspective, censoring cards appropriately
* @param hand - The hand to export
* @param playerId - Optional player ID for perspective (shows only their cards and shown cards)
* @returns The hand with cards censored from the player's perspective
*/
function output(hand, playerId) {
if (playerId === undefined) {
return hand;
}
// Add author field
const result = { ...hand, author: playerId };
// Find the player's position (p1, p2, etc.)
const authorPlayerIndex = hand.players.indexOf(playerId);
if (authorPlayerIndex === -1) {
// Invalid player ID, return unchanged but with author
return result;
}
const authorPlayerPosition = authorPlayerIndex + 1; // Convert to 1-based position
// Track which players have shown their cards
const playersWhoShowed = new Set();
for (const action of hand.actions) {
// Look for show/muck actions: "p{n} sm {cards}"
const showMatch = action.match(/^p(\d+)\s+sm\s+/);
if (showMatch) {
const playerPosition = parseInt(showMatch[1]);
playersWhoShowed.add(playerPosition);
}
}
// Process actions to censor cards
const censoredActions = hand.actions.map(action => {
// Look for dealer hole card actions: "d dh p{n} {cards}"
const dealMatch = action.match(/^d\s+dh\s+p(\d+)\s+(.+)$/);
if (dealMatch) {
const playerPosition = parseInt(dealMatch[1]);
const cards = dealMatch[2];
// Show cards if:
// 1. It's the author player's cards
// 2. The player has shown their cards
if (playerPosition === authorPlayerPosition || playersWhoShowed.has(playerPosition)) {
return action; // Keep original action
}
else {
// Hide cards with ????
return `d dh p${playerPosition} ????`;
}
}
// Return all other actions unchanged
return action;
});
return { ...result, actions: censoredActions };
}
Hand.output = output;
/**
* Check if two hands are equal
* @param oldHand - First hand to compare
* @param newHand - Second hand to compare
* @returns True if hands are equal, false otherwise
*/
function isHandsEqual(oldHand, newHand) {
// If both hands are the same reference, they're identical
if (oldHand === newHand) {
return true;
}
// If either hand is null/undefined, they're equal only if both are exactly the same falsy value
if (!oldHand || !newHand) {
return oldHand === newHand;
}
// Compare the entire object structure for equality
// This is more comprehensive than just checking specific fields
try {
const oldHandStr = JSON.stringify(oldHand);
const newHandStr = JSON.stringify(newHand);
return oldHandStr === newHandStr;
}
catch (error) {
// If JSON.stringify fails, consider them different
return false;
}
}
Hand.isHandsEqual = isHandsEqual;
/**
* Serialize a hand to string format
* @param input - Hand to serialize
* @param format - Output format ('pokerstars' or 'json')
* @returns Serialized hand as string
*/
function serialize(input, format = 'pokerstars') {
if (format === 'pokerstars') {
return phhToNarrative(input);
}
if (format === 'json') {
try {
// Use a Set to track circular references more reliably
const seen = new WeakSet();
const result = JSON.stringify(input, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular Reference]';
}
seen.add(value);
}
return value;
});
return result;
}
catch (error) {
// If JSON.stringify fails completely, return error info
return JSON.stringify({
error: 'Failed to serialize hand to JSON',
message: error instanceof Error ? error.message : 'Unknown error',
inputType: typeof input,
inputConstructor: input?.constructor?.name || 'unknown',
});
}
}
throw new Error(`Unsupported format: ${format}`);
}
Hand.serialize = serialize;
})(Hand || (Hand = {}));
// Add 'export' alias for backward compatibility with the original API
// This must be done after the namespace declaration
Hand.export = Hand.output;
Hand.merge = mergeGames;
// Import extensions to register them
// This demonstrates the module augmentation pattern
import './formats/custom';
//# sourceMappingURL=Hand.js.map