UNPKG

@idealic/poker-engine

Version:

Poker game engine and hand evaluator

673 lines (602 loc) 18.8 kB
#!/usr/bin/env tsx import * as I from '@augceo/iterators'; import { Command } from '@commander-js/extra-typings'; import * as fs from 'fs'; import * as glob from 'glob'; import cluster from 'node:cluster'; import { Readable } from 'node:stream'; import postgres from 'postgres'; import { parseHand, parseHandIdentifiers } from '../formats/pokerstars/parse.ts'; import * as Poker from '../index.ts'; const program = new Command() .name('parse-hands') .description('Parse poker hand history and output stats as CSV or PostgreSQL table') .argument('<output>', 'output file path (for CSV) or table name (for PostgreSQL)') .option('-i, --input <pattern>', 'input file glob pattern (e.g., "files/**/*.txt")', String) .option('-l, --limit <number>', 'limit number of hands to parse', parseInt, Infinity) .option('-p, --progress', 'show progress while parsing') .option('-v, --verbose', 'output extra information') .option('-e, --error-file <path>', 'file to save hand texts that caused parsing errors') .option('--skip-timeouts', 'skip hands that include timeout messages or incomplete showdowns') .option( '-c, --connection <string>', 'PostgreSQL connection string (e.g., postgresql://user:pass@host:port/db)' ) .option('-m, --method <string>', 'PostgreSQL import method (copy or insert)', 'insert') .option( '--max-concurrency <number>', 'maximum number of concurrent database operations', parseInt, 2 ) .version('1.0.0'); program.parse(); const options = program.opts(); const [outputPath] = program.args; if (cluster.isPrimary) { const worker = cluster.fork(); worker.on('exit', (code, signal) => { process.exit(code ?? (signal ? 1 : 0)); }); process.on('SIGINT', () => { console.log('Recieved SIGINT'); return false; }); process.on('SIGTERM', () => { console.log('Recieved SIGTERM'); process.exit(0); }); cluster.on('exit', (_worker, code, signal) => { process.exit(code ?? (signal ? 1 : 0)); }); await new Promise(() => {}); } else { // Handle graceful shutdown on SIGINT (Ctrl+C) process.on('SIGINT', () => { triggerGracefulShutdown(); return false; }); process.on('SIGTERM', () => { triggerGracefulShutdown(); return false; }); } // Setup error file stream if option provided let errorStream: fs.WriteStream | null = null; if (options.errorFile) { errorStream = fs.createWriteStream(options.errorFile); } // Setup PostgreSQL client if table output is requested let sql: postgres.Sql | null = null; // Add after other variable declarations let readCount = 0; let parsedCount = 0; let analyzedCount = 0; let skippedCount = 0; let errorCount = 0; let duplicateCount = 0; let writtenCount = 0; let processedFilesCount = 0; let totalInputSize = 0; const startTime = Date.now(); const seenGameIds = new Set<number>(); const errorCounts: Record<string, number> = {}; let abortController: AbortController | null = null; let statsInterval: NodeJS.Timeout | null = null; // Helper function to escape for TEXT protocol function escapeText(value: any): string { if (value === null || value === undefined) { return '\\N'; } if (value instanceof Date) { return value.toISOString(); } return String(value) .replace(/\\/g, '\\\\') .replace(/\t/g, '\\t') .replace(/\n/g, '\\n') .replace(/\r/g, '\\r'); } // Function to check if games exist in database let filterBatchProcessing = 0; async function filterBatch(gameBatch: string[]) { try { filterBatchProcessing++; //console.log('filter batch', gameBatch.length, filterBatchProcessing, 'in flight'); //await new Promise(resolve => setTimeout(resolve, Math.random() * 1000)); if (!sql) throw new Error('PostgreSQL not initialized'); if (gameBatch.length === 0) return []; // First pass: collect all game IDs and check for duplicates const ids: number[] = []; const uniqueBatch = gameBatch.filter((handText, _index) => { const { hand: id } = parseHandIdentifiers(handText.substring(0, handText.indexOf('\n'))); if (seenGameIds.has(id)) { duplicateCount++; return false; } else { ids.push(id); seenGameIds.add(id); return true; } }); if (uniqueBatch.length === 0) { filterBatchProcessing--; //console.log('filtered', 0); return []; } const { venue } = parseHandIdentifiers( uniqueBatch[0].substring(0, uniqueBatch[0].indexOf('\n')) ); const result = await sql`SELECT id FROM game_states WHERE id = ANY(${ids}) AND venue = ${venue}`; const existingGameIds = new Set(result.map(row => parseInt(row.id))); const filtered = uniqueBatch.filter((_, index) => { const id = ids[index]; seenGameIds.delete(id); if (existingGameIds.has(id)) { duplicateCount++; return false; } return true; }); return filtered; } finally { filterBatchProcessing--; } } // Helper function to format file size function formatFileSize(bytes: number): string { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } // Function to output final stats and clean up function printStats(isFinal = false) { try { if (!isFinal) { process.stdout.write('\u001B[2J\u001B[0;0f'); } // Get output file size for CSV let outputSize = 0; if (isFinal && !options.connection) { try { const stats = fs.statSync(outputPath); outputSize = stats.size; } catch (error) { console.error(`Error getting output file size: ${error}`); } } const duration = (Date.now() - startTime) / 1000; // Convert to seconds const inputPerSecond = duration > 0 ? readCount / duration : 0; const outputPerSecond = duration > 0 ? writtenCount / duration : 0; let errorBreakdown = ''; if (errorCount > 0) { errorBreakdown = '\n' + Object.entries(errorCounts) .map(([key, value]) => ` - ${key}: ${value}`) .join('\n'); } const message = `${isFinal ? '\nSummary:' : 'Progress:'} - Read: ${readCount} hands - Parsed: ${parsedCount} hands - Analyzed: ${analyzedCount} hands - Skipped: ${skippedCount} hands - Duplicates: ${duplicateCount} hands - Written: ${writtenCount} hands - Files processed: ${processedFilesCount} - Input size: ${formatFileSize(totalInputSize)} - Errored: ${errorCount} hands${errorBreakdown} ${isFinal && !options.connection ? `\n- Output size: ${formatFileSize(outputSize)}` : ''} - Duration: ${duration.toFixed(2)} seconds - Input: ${inputPerSecond.toFixed(2)} hands/second - Output: ${outputPerSecond.toFixed(2)} hands/second - Memory: ${Math.floor(process.memoryUsage().rss / 1024 / 1024)} MB `; process.stdout.write(message + '\n'); if (isFinal) { console.timeEnd('Total time'); } } catch (error) { console.error('Error during final stats output:', error); } } function triggerGracefulShutdown() { if (abortController?.signal.aborted) { console.error('\nForce quitting...'); process.exit(1); } process.stdout.write('\nProcess interrupted. Shutting down gracefully...\n'); shutdown().then(() => { process.exit(0); }); return false; } // Function to handle graceful shutdown async function shutdown() { if (abortController?.signal.aborted) return; abortController?.abort(); if (statsInterval) { clearInterval(statsInterval); } try { // Close database connection if (sql) { console.log('Closing connections'); await sql.end(); } //console.log('Closing pool'); // await pool?.end(); //console.log('Closed pool'); // Close error stream if it exists if (errorStream) { errorStream.end(); await new Promise<void>(resolve => errorStream.once('finish', resolve)); } // Output final stats printStats(true); } catch (error) { console.error('Error during shutdown:', error); } } // Handle process exit to ensure clean shutdown //process.on('exit', async () => { // if (!isInterrupted) { // await shutdown(); // } //}); function logError(error: any, text: string, hand: Poker.Hand | null = null) { errorCount++; const errorMessage = String(error.message || error); const errorKey = errorMessage.split(/[:.,\n]/)[0].trim(); errorCounts[errorKey] = (errorCounts[errorKey] || 0) + 1; if (errorStream) { const context = hand ? JSON.stringify(hand, null, 2) + '\n' + text : text; errorStream.write(context + '\n' + error.stack + '\n\n'); } } function processHand(handText: string): readonly [Poker.Hand, Poker.Game] | null { if ( options.skipTimeouts && (handText.includes('has timed out') || handText.includes(' showed and ') || handText.includes('Hand was run twice') || handText.includes('Hand cancelled')) ) { skippedCount++; return null; } //console.log('processHand', handText.length); let hand: Poker.Hand | null = null; try { hand = parseHand(handText); } catch (error: any) { console.log(error); logError(error, handText); return null; } if (!hand) return null; parsedCount++; let game: Poker.Game | null = null; try { game = Poker.Game(hand); analyzedCount++; } catch (error: any) { logError(error, handText, hand); return null; } if (!game) return null; return [hand, game] as const; } // Function to write batch of games async function writeGames(games: Poker.Hand[]) { if (!options.connection || !sql) return; if (options.method === 'copy') { const reserved = await sql.reserve(); try { const columns = [ 'id', 'type', 'variant', 'venue', 'table', 'event', 'url', 'seat_count', 'player_count', 'created_at', 'updated_at', 'finished_at', 'state', ]; async function* generateData() { for (const game of games) { writtenCount++; const createdAt = new Date(game.timestamp || 0); const gameData = [ game.hand, 'poker', game.variant, game.venue, game.table, game.event, game.url, game.seatCount, game.players.length, createdAt, createdAt, createdAt, JSON.stringify(game), ]; yield gameData.map(escapeText).join('\t') + '\n'; } } const readable = Readable.from(generateData()); const copyStream = await reserved`COPY game_states (${sql(columns)}) FROM STDIN`.writable(); await new Promise((resolve, reject) => { readable.pipe(copyStream).on('finish', resolve).on('error', reject); }); } finally { await reserved.release(); } } else { const columns = [ 'id', 'type', 'variant', 'venue', 'table', 'event', 'url', 'seat_count', 'player_count', 'created_at', 'updated_at', 'finished_at', 'state', ]; const columnsData: any[][] = Array.from({ length: columns.length }, () => []); let rowCount = 0; for (const game of games) { writtenCount++; rowCount++; const createdAt = new Date(game.timestamp || 0); const gameValues: any[] = [ game.hand, 'poker', game.variant, game.venue, game.table, game.event, game.url, game.seatCount, game.players.length, createdAt, createdAt, createdAt, JSON.stringify(game), ]; gameValues.forEach((val, i) => { const column = columns[i]; if (val instanceof Date) { val = val.toISOString(); } if ( ['event', 'url', 'variant', 'venue', 'table'].includes(column) && val !== null && typeof val !== 'string' ) { columnsData[i].push(String(val)); } else { columnsData[i].push(val === undefined ? null : val); } }); } if (rowCount > 0) { const casts = [ 'bigint[]', 'text[]', 'text[]', 'text[]', 'text[]', 'text[]', 'text[]', 'bigint[]', 'integer[]', 'integer[]', 'timestamptz[]', 'timestamptz[]', 'timestamptz[]', 'jsonb[]', ]; const placeholders = casts.map((cast, i) => `$${i + 1}::${cast}`).join(', '); const query = `INSERT INTO game_states ("${columns.join( '","' )}") SELECT * FROM unnest(${placeholders}) ON CONFLICT (id, venue) DO NOTHING`; try { await sql.unsafe(query, columnsData, { prepare: true }); } catch (error) { console.error('Error inserting games:', error); } } } } // Function to write batch of stats async function writeStats(tables: Poker.Game[]) { if (!options.connection) { if (process.stdout.writable) { for (const table of tables) { for (const stat of table.stats) { const row = Poker.Stats.columns.map(col => stat[col as keyof typeof stat]); process.stdout.write(row.map(escapeText).join(',') + '\n'); } } } return; } if (!sql) return; if (options.method === 'copy') { const reserved = await sql.reserve(); try { const columnNames = Poker.Stats.getColumnNames(); async function* generateData() { for (const table of tables) { for (const stat of table.stats) { const row = Poker.Stats.columns.map(col => col === 'createdAt' ? new Date(table.gameTimestamp) : stat[col as keyof typeof stat] ); yield row.map(escapeText).join('\t') + '\n'; } } } const readable = Readable.from(generateData()); const copyStream = await reserved`COPY ${sql(outputPath)} (${sql(columnNames)}) FROM STDIN`.writable(); await new Promise((resolve, reject) => { readable.pipe(copyStream).on('finish', resolve).on('error', reject); }); } finally { await reserved.release(); } } else { const columnNames = Poker.Stats.getColumnNames(); const columns = Poker.Stats.columns; const columnsData: any[][] = Array.from({ length: columnNames.length }, () => []); let rowCount = 0; for (const table of tables) { for (const stat of table.stats) { rowCount++; columns.forEach((col, i) => { columnsData[i].push( col == 'createdAt' ? new Date(table.gameTimestamp).toISOString() : stat[col as keyof typeof stat] ); }); } } if (rowCount > 0) { const textColumns = new Set(['player', 'venue', 'gameId', 'table', 'street', 'currency']); const timestampColumns = new Set(['createdAt']); const casts = columns.map(col => { if (textColumns.has(col)) return 'text[]'; if (timestampColumns.has(col)) return 'timestamptz[]'; return 'numeric[]'; }); const placeholders = casts.map((cast, i) => `$${i + 1}::${cast}`).join(', '); const query = `INSERT INTO "${outputPath}" ("${columnNames.join( '","' )}") SELECT * FROM unnest(${placeholders}) ON CONFLICT DO NOTHING`; await sql.unsafe(query, columnsData, { prepare: true }); } } } // Generator function for file paths async function* getFilePaths(pattern: string) { const files = await glob.glob(pattern); if (files.length === 0) { console.error(`Error: No files found matching pattern "${pattern}"`); process.exit(1); } if (options.verbose) { //console.log(`Processing ${files.length} files matching "${pattern}"`); } for (const file of files) { yield file; } } // Function to read file contents async function readFile(file: string) { processedFilesCount++; //console.log('readFile'); try { const stats = await fs.promises.stat(file); totalInputSize += stats.size; //console.log('File', file, formatFileSize(stats.size)); } catch (error) { console.error(`Error getting file size for ${file}: ${error}`); } return fs.createReadStream(file, { encoding: 'utf8' }); } // Generator function to split into hands async function* splitHands(chunks: AsyncIterable<string>): AsyncGenerator<string> { //console.log('splitHands'); let buffer = ''; const hands: string[] = []; let count = 0; for await (const chunk of chunks) { buffer += chunk.replace(/\r/g, ''); let start = 0; let pos = 0; while ((pos = buffer.indexOf('PokerStars Hand #', start + 1)) !== -1) { if (start < pos) { const hand = buffer.substring(start, pos).trim(); if (hand) { count++; yield hand; readCount++; } } start = pos; } buffer = buffer.substring(start); } if (buffer) { count++; yield buffer; hands.push(buffer); } //console.log('splitHands done', count); return hands; } // Main processing function async function processFiles() { console.time('Total time'); if (options.progress) { statsInterval = setInterval(() => printStats(), 250); } if (options.connection) { sql = postgres(options.connection, { max: 10 }); } try { if (!options.input) { throw new Error('Input pattern is required'); } // Create the processing pipeline using rotery const [result, controller] = I.abortable(I => I.pipe( // Get file paths getFilePaths(options.input!), I.map(readFile, 4), I.map(splitHands, 4), I.concat(4, 4), // Find new hands in batches in paralleal I.chunk(5000), I.map(filterBatch, 5), I.concat(5, 1), I.map(processHand), I.filter(I.identity), I.take(options.limit), I.dispatch(([hand, game]) => ({ hand, game }), { hand: I.pipe(I.chunk(2000), I.map(writeGames, 3)), game: I.pipe(I.chunk(500), I.map(writeStats, 3)), }) ) ); abortController = controller; var i = 0; for await (const _stream of result) { if (++i % 1000 === 0) { //console.log('>', i, Math.floor(process.memoryUsage().rss / 1024 / 1024) + 'MB memory used'); } } console.log('DONE'); } finally { await shutdown(); } } // Start processing processFiles().catch(error => { console.error('Error during processing:', error); process.exit(1); });