diff --git a/src/__fixtures__/hands/pokerstars.ts b/src/__fixtures__/hands/pokerstars.ts new file mode 100644 index 0000000..6129510 --- /dev/null +++ b/src/__fixtures__/hands/pokerstars.ts @@ -0,0 +1,115 @@ +export const HAND_CASH_NO_SHOWDOWN = `*********** # 2 ************** +PokerStars Hand #247743239964: Hold'em No Limit ($0.25/$0.50 USD) - 2023/12/28 22:34:00 ET +Table 'Sirona' 6-max Seat #5 is the button +Seat 1: natek526 ($35.21 in chips) +Seat 2: PennStateWins ($53.85 in chips) +Seat 3: mhuggins ($50 in chips) +Seat 4: NutritionalFact ($80.17 in chips) +Seat 5: maketheca11 ($73.33 in chips) +Seat 6: omgmuffins ($49.50 in chips) +omgmuffins: posts small blind $0.25 +natek526: posts big blind $0.50 +*** HOLE CARDS *** +Dealt to mhuggins [3s Qh] +PennStateWins: folds +mhuggins: folds +NutritionalFact: raises $0.50 to $1 +maketheca11: folds +omgmuffins: calls $0.75 +natek526: folds +*** FLOP *** [9d 3d Qc] +omgmuffins: checks +NutritionalFact: bets $1.19 +omgmuffins: calls $1.19 +*** TURN *** [9d 3d Qc] [Qd] +omgmuffins: checks +NutritionalFact: checks +*** RIVER *** [9d 3d Qc Qd] [Js] +omgmuffins: bets $3.50 +NutritionalFact: folds +Uncalled bet ($3.50) returned to omgmuffins +omgmuffins collected $4.64 from pot +omgmuffins: doesn't show hand +*** SUMMARY *** +Total pot $4.88 | Rake $0.24 +Board [9d 3d Qc Qd Js] +Seat 1: natek526 (big blind) folded before Flop +Seat 2: PennStateWins folded before Flop (didn't bet) +Seat 3: mhuggins folded before Flop (didn't bet) +Seat 4: NutritionalFact folded on the River +Seat 5: maketheca11 (button) folded before Flop (didn't bet) +Seat 6: omgmuffins (small blind) collected ($4.64)`; + +export const HAND_CASH_ALL_IN = `*********** # 97 ************** +PokerStars Hand #247743232339: Hold'em No Limit ($0.50/$1.00 USD) - 2023/12/28 22:13:14 ET +Table 'Skat' 6-max Seat #4 is the button +Seat 1: gvillian03 ($23.76 in chips) +Seat 2: mhuggins ($258.53 in chips) +Seat 3: Schwalke ($156.36 in chips) +Seat 4: $$Top_Boy$$ OG ($97 in chips) +Seat 5: MadOreos ($176.06 in chips) +Seat 6: MiltonBradley ($105.75 in chips) +MadOreos: posts small blind $0.50 +MiltonBradley: posts big blind $1 +*** HOLE CARDS *** +Dealt to mhuggins [As 8c] +gvillian03: raises $1.50 to $2.50 +mhuggins: folds +Schwalke: folds +$$Top_Boy$$ OG: folds +MadOreos: raises $10 to $12.50 +MiltonBradley: folds +gvillian03: calls $10 +*** FLOP *** [Th 6d 6c] +MadOreos: bets $16.30 +gvillian03: calls $11.26 and is all-in +Uncalled bet ($5.04) returned to MadOreos +*** TURN *** [Th 6d 6c] [Kh] +*** RIVER *** [Th 6d 6c Kh] [7h] +*** SHOW DOWN *** +MadOreos: shows [Ad Qd] (a pair of Sixes) +gvillian03: shows [5h Ah] (a flush, Ace high) +gvillian03 collected $46.09 from pot +*** SUMMARY *** +Total pot $48.52 | Rake $2.43 +Board [Th 6d 6c Kh 7h] +Seat 1: gvillian03 showed [5h Ah] and won ($46.09) with a flush, Ace high +Seat 2: mhuggins folded before Flop (didn't bet) +Seat 3: Schwalke folded before Flop (didn't bet) +Seat 4: $$Top_Boy$$ OG (button) folded before Flop (didn't bet) +Seat 5: MadOreos (small blind) showed [Ad Qd] and lost with a pair of Sixes +Seat 6: MiltonBradley (big blind) folded before Flop`; + +export const HAND_TOURNAMENT_BOUNTY_AND_PLACEMENT = `*********** # 238 ************** +PokerStars Hand #247678901194: Tournament #3429918960, $4.55+$4.55+$0.90 USD Hold'em No Limit - Level XXVI (2000/4000) - 2023/12/25 18:48:09 ET +Table '3429918960 5' 9-max Seat #1 is the button +Seat 1: mhuggins (755175 in chips, $148.95 bounty) +Seat 2: hammy6955 (184825 in chips, $38.62 bounty) +mhuggins: posts the ante 600 +hammy6955: posts the ante 600 +mhuggins: posts small blind 2000 +hammy6955: posts big blind 4000 +*** HOLE CARDS *** +Dealt to mhuggins [4s 2c] +mhuggins: raises 8000 to 12000 +hammy6955: raises 24000 to 36000 +mhuggins: calls 24000 +*** FLOP *** [Jd 4h 7c] +hammy6955: bets 24000 +mhuggins: calls 24000 +*** TURN *** [Jd 4h 7c] [4c] +hammy6955: bets 124225 and is all-in +mhuggins: calls 124225 +*** RIVER *** [Jd 4h 7c 4c] [Qd] +*** SHOW DOWN *** +hammy6955: shows [Ah Th] (a pair of Fours) +mhuggins: shows [4s 2c] (three of a kind, Fours) +mhuggins collected 369650 from pot +mhuggins wins $19.31 for eliminating hammy6955 and their own bounty increases by $19.31 to $168.26 +hammy6955 finished the tournament in 2nd place and received $150.40. +mhuggins wins the tournament and receives $150.56 - congratulations! +*** SUMMARY *** +Total pot 369650 | Rake 0 +Board [Jd 4h 7c 4c Qd] +Seat 1: mhuggins (button) (small blind) showed [4s 2c] and won (369650) with three of a kind, Fours +Seat 2: hammy6955 (big blind) showed [Ah Th] and lost with a pair of Fours`; diff --git a/src/grammar/PokerStars.g4 b/src/grammar/PokerStars.g4 new file mode 100644 index 0000000..b10e92c --- /dev/null +++ b/src/grammar/PokerStars.g4 @@ -0,0 +1,121 @@ +grammar PokerStars; + +// file entry +handHistory: line | ((line EOL)+ line); +line: + ( lineHandIndex + | lineCashGameMeta + | lineTournamentMeta + | lineTableMeta + | linePlayer + | linePostSmallBlind + | linePostBigBlind + | linePostAnte + | lineStreet + | lineDeal + | lineAction + | lineUncalled + | lineShowdown + | lineResult + | lineTotalPot + | lineBoard + | lineActionSummary + | lineAwardBounty + | lineTournamentPlacement + | lineMisc + ); +lineHandIndex: '*'+ '#' INT '*'+; +lineCashGameMeta: 'PokerStars' 'Hand' '#' handNumber ':' variant bettingStructure '(' blinds currency ')' '-' timestamp; +lineTournamentMeta: 'PokerStars' 'Hand' '#' handNumber ':' 'Tournament' '#' tournamentNumber ',' buyIn ('+' bountyChip)? '+' entryFee currency variant bettingStructure '-' 'Level' levelNumber '(' blinds ')' '-' timestamp; +lineTableMeta: 'Table' '\'' tableName '\'' tableSize 'Seat' '#' INT 'is' 'the' 'button'; +linePlayer: 'Seat' INT ':' playerName '(' playerChips playerBounty? ')'; +linePostSmallBlind: playerName ':' 'posts' 'small' 'blind' currencyValue; +linePostBigBlind: playerName ':' 'posts' 'big' 'blind' currencyValue; +linePostAnte: playerName ':' 'posts' 'the' 'ante' currencyValue; +lineStreet: '*'+ STREET_HEADING '*'+ cardCollection*; +lineDeal: 'Dealt' 'to' playerName cardCollection; +lineAction: playerName ':' action; +lineUncalled: 'Uncalled' 'bet' '(' currencyValue ')' 'returned' 'to' playerName; +lineResult: playerName 'collected' currencyValue 'from' 'pot'; +lineShowdown: playerName ':' + ( 'doesn\'t' 'show' 'hand' + | 'mucks' 'hand' + | 'shows' cardCollection '(' handStrength ('-' handStrengthKicker)? ')' + ); +lineTotalPot: 'Total' 'pot' currencyValue '|' 'Rake' currencyValue; +lineBoard: 'Board' cardCollection; +lineActionSummary: 'Seat' INT ':' playerName '(' position ')' actionSummary; +lineAwardBounty: playerName 'wins' currencyValue 'for' 'eliminating' playerName 'and' 'their' 'own' 'bounty' 'increases' 'by' currencyValue 'to' currencyValue; +lineTournamentPlacement: playerName 'finished' 'the' 'tournament' 'in' placement ('nd' | 'st' | 'rd' | 'th') 'place' 'and' 'received' currencyValue '.'; +lineMisc: + ( playerName 'leaves' 'the' 'table' + | playerName 'joins' 'the' 'table' 'at' 'seat' '#' INT + | playerName 'will' 'be' 'allowed' 'to' 'play' 'after' 'the' 'button' + ); + +handNumber: INT; +tournamentNumber: INT; +variant: 'Hold\'em'; +bettingStructure: 'No Limit'; +blinds: currencyValue '/' currencyValue; +currencyValue: '$' DECIMAL; +currency: WORD; +timestamp: INT '/' INT '/' INT INT ':' INT ':' INT timezone; +timezone: WORD; +tableName: (~'\'')+; +tableSize: INT '-' 'max'; +playerName: USERNAME; +playerChips: currencyValue 'in' 'chips'; +playerBounty: ',' currencyValue 'in' 'bounty'; +cardCollection: '[' cards ']'; +cards: CARD+; +position: 'small blind' | 'big blind' | 'dealer'; +actionSummary: + ( 'folded' 'before' 'Flop' ('(' 'didn\t' 'bet' ')')? | + | 'folded' 'on' 'the' ('Flop' | 'Turn' | 'River') + | 'collected' '(' currencyValue ')' + | 'showed' cardCollection 'and' 'won' '(' currencyValue ')' 'with' handStrength + | 'showed' cardCollection 'and' 'lost' 'with' handStrength + | 'mucked' cardCollection? + ); +action: + ( actionBet + | actionCall + | actionCheck + | actionFold + | actionRaise + ); +actionBet: 'bets' currencyValue allIn?; +actionCall: 'calls' currencyValue allIn?; +actionCheck: 'checks'; +actionFold: 'folds'; +actionRaise: 'raises' currencyValue 'to' currencyValue allIn?; +allIn: 'and' 'is' 'all-in'; +handStrength: + ( 'high' 'card' WORD + | 'a' 'pair' 'of' WORD + | 'two' 'pair' ',' WORD+ + | 'three' 'of' 'a' 'kind' ',' WORD + | 'a' 'straight' ',' WORD+ + | 'a' 'flush' ',' WORD+ + | 'a' 'full' 'house' ',' WORD+ + | 'four' 'of' 'a' 'kind' ',' WORD // TODO: determine if this is correct + | 'a' 'straight' 'flush' ',' WORD // TODO: determine if this is correct + | 'a' 'royal' 'flush' ',' WORD // TODO: determine if this is correct + ); +handStrengthKicker: WORD ('+' WORD)? 'kicker'; +buyIn: currencyValue; +bountyChip: currencyValue; +entryFee: currencyValue; +levelNumber: ROMAN_NUMERALS; +placement: INT; + +INT: [0-9]+; +DECIMAL: (INT ',')* INT ('.' INT)?; +USERNAME: [a-z0-9$_ ]+; +STREET_HEADING: 'HOLE CARDS' | 'FLOP' | 'TURN' | 'RIVER' | 'SHOW DOWN' | 'SUMMARY'; +ROMAN_NUMERALS: [ivxlcdm]+; +CARD: [2-9tjqka][cdhs]; +WORD: [a-z]+; +EOL: '\r' | '\n' | '\r\n'; +WS: [ \t]+ -> skip; diff --git a/src/networks/ignition/IgnitionActionVisitor.ts b/src/networks/ignition/IgnitionActionVisitor.ts index 683c420..40a2e24 100644 --- a/src/networks/ignition/IgnitionActionVisitor.ts +++ b/src/networks/ignition/IgnitionActionVisitor.ts @@ -2,7 +2,7 @@ import { AbstractParseTreeVisitor } from 'antlr4ts/tree/AbstractParseTreeVisitor import { LineActionContext } from '~/grammar/IgnitionParser'; import { IgnitionVisitor } from '~/grammar/IgnitionVisitor'; import { Action } from '~/types'; -import { IgnitionChipCountVisitor } from './IgnitionChipCountVisitor'; +import { getChipCount } from './getChipCount'; import { NotImplementedError } from './types'; export class IgnitionActionVisitor @@ -22,7 +22,7 @@ export class IgnitionActionVisitor const allInAction = action.actionAllIn(); if (allInAction) { - const amount = new IgnitionChipCountVisitor().visit(allInAction.chipCount()).toString(); + const amount = getChipCount(allInAction.chipCount()); const playerName = ctx.position().text; // This all-in action could actually represent a bet or a call. Unfortunately, @@ -34,26 +34,22 @@ export class IgnitionActionVisitor const allInRaiseAction = action.actionAllInRaise(); if (allInRaiseAction) { - const amount = new IgnitionChipCountVisitor() - .visit(allInRaiseAction.chipCount()[0]) - .toString(); - const totalBet = new IgnitionChipCountVisitor() - .visit(allInRaiseAction.chipCount()[1]) - .toString(); + const amount = getChipCount(allInRaiseAction.chipCount()[0]); + const totalBet = getChipCount(allInRaiseAction.chipCount()[1]); const playerName = ctx.position().text; return [{ type: 'raise', playerName, amount, totalBet, isAllIn: true }]; } const betAction = action.actionBet(); if (betAction) { - const amount = new IgnitionChipCountVisitor().visit(betAction.chipCount()).toString(); + const amount = getChipCount(betAction.chipCount()); const playerName = ctx.position().text; return [{ type: 'bet', playerName, amount, isAllIn: false }]; } const callAction = action.actionCall(); if (callAction) { - const amount = new IgnitionChipCountVisitor().visit(callAction.chipCount()).toString(); + const amount = getChipCount(callAction.chipCount()); const playerName = ctx.position().text; return [{ type: 'call', playerName, amount, isAllIn: false }]; } @@ -72,15 +68,15 @@ export class IgnitionActionVisitor const raiseAction = action.actionRaise(); if (raiseAction) { - const amount = new IgnitionChipCountVisitor().visit(raiseAction.chipCount()[0]).toString(); - const totalBet = new IgnitionChipCountVisitor().visit(raiseAction.chipCount()[1]).toString(); + const amount = getChipCount(raiseAction.chipCount()[0]); + const totalBet = getChipCount(raiseAction.chipCount()[1]); const playerName = ctx.position().text; return [{ type: 'raise', playerName, amount, totalBet, isAllIn: false }]; } const anteAction = action.actionAnte(); if (anteAction) { - const amount = new IgnitionChipCountVisitor().visit(anteAction.chipCount()).toString(); + const amount = getChipCount(anteAction.chipCount()); const playerName = ctx.position().text; return [{ type: 'post', postType: 'ante', playerName, amount }]; } diff --git a/src/networks/ignition/IgnitionChipCountVisitor.ts b/src/networks/ignition/IgnitionChipCountVisitor.ts deleted file mode 100644 index acb6175..0000000 --- a/src/networks/ignition/IgnitionChipCountVisitor.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { AbstractParseTreeVisitor } from 'antlr4ts/tree/AbstractParseTreeVisitor'; -import BigNumber from 'bignumber.js'; -import { ChipCountContext } from '~/grammar/IgnitionParser'; -import { IgnitionVisitor } from '~/grammar/IgnitionVisitor'; -import { NotImplementedError } from './types'; - -// TODO: we're assuming commas are always thousands separators, but that may depend on locale -const parseNumber = (num: string) => new BigNumber(num.replace(',', '')); - -export class IgnitionChipCountVisitor - extends AbstractParseTreeVisitor - implements IgnitionVisitor -{ - protected defaultResult(): BigNumber { - throw new NotImplementedError(); - } - - visitChipCount(ctx: ChipCountContext): BigNumber { - return parseNumber(ctx.value().text); - } -} diff --git a/src/networks/ignition/IgnitionHandHistoryVisitor.ts b/src/networks/ignition/IgnitionHandHistoryVisitor.ts index a8bb7ed..4f40efc 100644 --- a/src/networks/ignition/IgnitionHandHistoryVisitor.ts +++ b/src/networks/ignition/IgnitionHandHistoryVisitor.ts @@ -8,6 +8,7 @@ import { HandStrengthContext, LineActionContext, LineAwardBountyContext, + LineBigBlindContext, LineHandsDealtContext, LineMetaCashContext, LineMetaTournamentContext, @@ -26,12 +27,12 @@ import { VariantContext, } from '~/grammar/IgnitionParser'; import { IgnitionVisitor } from '~/grammar/IgnitionVisitor'; -import { BettingStructure, Site, Street, TournamentSpeed, Variant } from '~/types'; +import { BettingStructure, Street, TournamentSpeed, Variant } from '~/types'; import { IgnitionActionVisitor } from './IgnitionActionVisitor'; -import { IgnitionChipCountVisitor } from './IgnitionChipCountVisitor'; -import { Line } from './types'; +import { getChipCount } from './getChipCount'; +import { IgnitionSite, Line } from './types'; -const getSite = (ctx: SiteContext): Site => { +const getSite = (ctx: SiteContext): IgnitionSite => { switch (ctx.text) { case 'Bodog': return 'bodog'; @@ -249,7 +250,7 @@ export class IgnitionHandHistoryVisitor } public visitLineSmallBlind(ctx: LineSmallBlindContext): Line[] { - const chipCount = new IgnitionChipCountVisitor().visit(ctx.chipCount()).toString(); + const chipCount = getChipCount(ctx.chipCount()); const playerName = 'Small Blind'; return [ { type: 'smallBlind', chipCount }, @@ -260,8 +261,8 @@ export class IgnitionHandHistoryVisitor ]; } - public visitLineBigBlind(ctx: LineSmallBlindContext): Line[] { - const chipCount = new IgnitionChipCountVisitor().visit(ctx.chipCount()).toString(); + public visitLineBigBlind(ctx: LineBigBlindContext): Line[] { + const chipCount = getChipCount(ctx.chipCount()); const playerName = 'Big Blind'; return [ { type: 'bigBlind', chipCount }, @@ -274,7 +275,7 @@ export class IgnitionHandHistoryVisitor public visitLinePost(ctx: LinePostContext): Line[] { const chipCountContext = ctx.chipCount(); - const chipCount = new IgnitionChipCountVisitor().visit(chipCountContext).toString(); + const chipCount = getChipCount(chipCountContext); // If a user posts without waiting for the big blind in a cash game, // it should not be considered an ante. @@ -295,7 +296,7 @@ export class IgnitionHandHistoryVisitor } public visitLineUncalled(ctx: LineUncalledContext): Line[] { - const chipCount = new IgnitionChipCountVisitor().visit(ctx.chipCount()).toString(); + const chipCount = getChipCount(ctx.chipCount()); const playerName = ctx.position().text; return [{ type: 'action', action: { type: 'return-bet', playerName, amount: chipCount } }]; } @@ -305,7 +306,7 @@ export class IgnitionHandHistoryVisitor const positionContext = ctx.position(); const name = positionContext.text; const positionIndex = getPositionIndex(positionContext); - const chipCount = new IgnitionChipCountVisitor().visit(ctx.chipCount()).toString(); + const chipCount = getChipCount(ctx.chipCount()); const isHero = !!ctx.ME(); const isAnonymous = !isHero; @@ -369,7 +370,7 @@ export class IgnitionHandHistoryVisitor public visitLineResult(ctx: LineResultContext): Line[] { const playerName = ctx.position().text; const isSidePot = !!ctx.SIDEPOT(); - const chipCount = new IgnitionChipCountVisitor().visit(ctx.chipCount()).toString(); + const chipCount = getChipCount(ctx.chipCount()); return [ { type: 'action', action: { type: 'award-pot', playerName, amount: chipCount, isSidePot } }, ]; @@ -377,7 +378,7 @@ export class IgnitionHandHistoryVisitor public visitLineAwardBounty(ctx: LineAwardBountyContext): Line[] { const playerName = ctx.position().text; - const amount = new IgnitionChipCountVisitor().visit(ctx.chipCount()).toString(); + const amount = getChipCount(ctx.chipCount()); return [{ type: 'action', action: { type: 'award-bounty', playerName, amount } }]; } @@ -391,13 +392,13 @@ export class IgnitionHandHistoryVisitor const playerName = ctx.position().text; const prizeCash = ctx.tournamentPrizeCash(); if (prizeCash) { - const amount = new IgnitionChipCountVisitor().visit(prizeCash.chipCount()).toString(); + const amount = getChipCount(prizeCash.chipCount()); return [{ type: 'action', action: { type: 'tournament-award', playerName, amount } }]; } const prizeTicket = ctx.tournamentPrizeTicket(); if (prizeTicket) { - const amount = new IgnitionChipCountVisitor().visit(prizeTicket.chipCount()).toString(); + const amount = getChipCount(prizeTicket.chipCount()); return [{ type: 'action', action: { type: 'tournament-award', playerName, amount } }]; } diff --git a/src/networks/ignition/getChipCount.ts b/src/networks/ignition/getChipCount.ts new file mode 100644 index 0000000..280b12e --- /dev/null +++ b/src/networks/ignition/getChipCount.ts @@ -0,0 +1,6 @@ +import BigNumber from 'bignumber.js'; +import { ChipCountContext } from '~/grammar/IgnitionParser'; + +// TODO: we're assuming commas are always thousands separators, but that may depend on locale +export const getChipCount = (ctx: ChipCountContext) => + new BigNumber(ctx.text.replace(',', '')).toString(); diff --git a/src/networks/ignition/parseHand.ts b/src/networks/ignition/parseHand.ts index b35fed0..1a8c524 100644 --- a/src/networks/ignition/parseHand.ts +++ b/src/networks/ignition/parseHand.ts @@ -2,7 +2,7 @@ import omit from 'lodash/omit'; import type { SetOptional } from 'type-fest'; import { IgnitionLexer } from '~/grammar/IgnitionLexer'; import { IgnitionParser } from '~/grammar/IgnitionParser'; -import { GameInfoBase, HandHistory, TableSize } from '~/types'; +import { GameInfoBase, HandHistory, ParseHandOptions, TableSize } from '~/types'; import { OmitStrict } from '~/types/OmitStrict'; import { getParser } from '~/utils/getParser'; import { getPosition } from '~/utils/getPosition'; @@ -114,6 +114,7 @@ const getPlayers = (lines: LineDictionary, tableSize: TableSize): HandHistory['p position: getPosition(player.positionIndex, tableSize), seatNumber: player.seatNumber, chipStack: player.chipCount, + bounty: '0', isHero: player.isHero, isAnonymous: player.isAnonymous, })); @@ -138,7 +139,7 @@ const getActions = (lines: LineDictionary): HandHistory['actions'] => { }); }; -export const parseHand = ({ hand, filename }: { hand: string; filename?: string }): HandHistory => { +export const parseHand = ({ hand, filename }: ParseHandOptions): HandHistory => { const parser = getParser(hand, { lexer: IgnitionLexer, parser: IgnitionParser }); const context = parser.handHistory(); diff --git a/src/networks/ignition/types.ts b/src/networks/ignition/types.ts index 371af26..5008b69 100644 --- a/src/networks/ignition/types.ts +++ b/src/networks/ignition/types.ts @@ -1,5 +1,7 @@ import { Action, BettingStructure, Site, TournamentSpeed, Variant } from '~/types'; +export type IgnitionSite = Site & ('bodog' | 'bovada' | 'ignition'); + interface BaseLine { type: string; } @@ -12,7 +14,7 @@ export interface LineAction extends BaseLine { interface LineMetaBase extends BaseLine { type: 'meta'; gameType: string; - site: Site & ('bodog' | 'bovada' | 'ignition'); + site: IgnitionSite; handNumber: string; tableNumber: string; variant: Variant; @@ -34,15 +36,6 @@ interface LineMetaTournament extends LineMetaBase { export type LineMeta = LineMetaCash | LineMetaTournament; -export interface CashGame { - type: 'cash'; - fastFold: boolean; -} - -export interface TournamentGame { - type: 'tournament'; -} - export interface LinePlayer extends BaseLine { type: 'player'; name: string; diff --git a/src/networks/pokerstars/PokerStarsActionVisitor.ts b/src/networks/pokerstars/PokerStarsActionVisitor.ts new file mode 100644 index 0000000..99748cd --- /dev/null +++ b/src/networks/pokerstars/PokerStarsActionVisitor.ts @@ -0,0 +1,66 @@ +import { AbstractParseTreeVisitor } from 'antlr4ts/tree/AbstractParseTreeVisitor'; +import { LineActionContext } from '~/grammar/PokerStarsParser'; +import { PokerStarsVisitor } from '~/grammar/PokerStarsVisitor'; +import { Action } from '~/types'; +import { getChipCount } from './getChipCount'; +import { NotImplementedError } from './types'; + +export class PokerStarsActionVisitor + extends AbstractParseTreeVisitor + implements PokerStarsVisitor +{ + protected defaultResult(): Action[] { + return []; + } + + protected aggregateResult(aggregate: Action[], nextResult: Action[]): Action[] { + return [...aggregate, ...nextResult]; + } + + visitLineAction(ctx: LineActionContext): Action[] { + const action = ctx.action(); + + const betAction = action.actionBet(); + if (betAction) { + const amount = getChipCount(betAction.currencyValue()); + const playerName = ctx.playerName().text; + return [{ type: 'bet', playerName, amount, isAllIn: !!betAction.allIn() }]; + } + + const callAction = action.actionCall(); + if (callAction) { + const amount = getChipCount(callAction.currencyValue()); + const playerName = ctx.playerName().text; + return [{ type: 'call', playerName, amount, isAllIn: !!callAction.allIn() }]; + } + + const checkAction = action.actionCheck(); + if (checkAction) { + const playerName = ctx.playerName().text; + return [{ type: 'check', playerName }]; + } + + const foldAction = action.actionFold(); + if (foldAction) { + const playerName = ctx.playerName().text; + return [{ type: 'fold', playerName }]; + } + + const raiseAction = action.actionRaise(); + if (raiseAction) { + const amount = getChipCount(raiseAction.currencyValue()[0]); + const totalBet = getChipCount(raiseAction.currencyValue()[1]); + const playerName = ctx.playerName().text; + return [{ type: 'raise', playerName, amount, totalBet, isAllIn: !!raiseAction.allIn() }]; + } + + // const anteAction = action.actionAnte(); + // if (anteAction) { + // const amount = getChipCount(anteAction.chipCount()); + // const playerName = ctx.playerName().text; + // return [{ type: 'post', postType: 'ante', playerName, amount }]; + // } + + throw new NotImplementedError(); + } +} diff --git a/src/networks/pokerstars/PokerStarsHandHistoryVisitor.ts b/src/networks/pokerstars/PokerStarsHandHistoryVisitor.ts new file mode 100644 index 0000000..8893b4a --- /dev/null +++ b/src/networks/pokerstars/PokerStarsHandHistoryVisitor.ts @@ -0,0 +1,333 @@ +import { Card, HandStrength, assertCard } from '@poker-apprentice/types'; +import { ParserRuleContext } from 'antlr4ts'; +import { Interval } from 'antlr4ts/misc/Interval'; +import { AbstractParseTreeVisitor } from 'antlr4ts/tree/AbstractParseTreeVisitor'; +import { TerminalNode } from 'antlr4ts/tree/TerminalNode'; +import { + BettingStructureContext, + HandStrengthContext, + LineActionContext, + LineAwardBountyContext, + LineCashGameMetaContext, + LineDealContext, + LinePlayerContext, + LinePostAnteContext, + LinePostBigBlindContext, + LinePostSmallBlindContext, + LineResultContext, + LineShowdownContext, + LineStreetContext, + LineTableMetaContext, + LineTournamentMetaContext, + LineTournamentPlacementContext, + LineUncalledContext, + VariantContext, +} from '~/grammar/PokerStarsParser'; +import { PokerStarsVisitor } from '~/grammar/PokerStarsVisitor'; +import { BettingStructure, Street, Variant } from '~/types'; +import { PokerStarsActionVisitor } from './PokerStarsActionVisitor'; +import { getChipCount } from './getChipCount'; +import { parseRomanNumeral } from './parseRomanNumeral'; +import { Line } from './types'; + +const getSubstring = (ctx: ParserRuleContext): string => { + const { start, stop } = ctx; + if (!start.inputStream || !stop || start.startIndex < 0 || stop.stopIndex < 0) { + return start.text ?? ''; + } + return start.inputStream.getText(Interval.of(start.startIndex, stop.stopIndex)); +}; + +const getVariant = (ctx: VariantContext): Variant => { + switch (ctx.text) { + case 'HOLDEM': + case 'HOLDEMZonePoker': + return 'holdem'; + case 'OMAHA': + case 'OMAHAZonePoker': + return 'omaha'; + case 'OMAHA HiLo': + return 'omaha-8'; + default: + throw new Error(`Unexpected variant: "${ctx.text}"`); + } +}; + +const getBettingStructure = (ctx: BettingStructureContext): BettingStructure => { + switch (ctx.text) { + case 'Limit': + return 'limit'; + case 'No Limit': + return 'no limit'; + case 'Pot Limit': + return 'pot limit'; + default: + throw new Error(`Unexpected betting structure: "${ctx.text}"`); + } +}; + +const getStreet = (ctx: TerminalNode): Street | undefined => { + switch (ctx.text) { + case 'HOLE CARDS': + return 'preflop'; + case 'FLOP': + return 'flop'; + case 'TURN': + return 'turn'; + case 'RIVER': + return 'river'; + case 'SHOW DOWN': + return undefined; + case 'SUMMARY': + return undefined; + default: + throw new Error(`Unexpected street: "${ctx.text}"`); + } +}; + +const getHandStrength = (ctx: HandStrengthContext | undefined): HandStrength | undefined => { + if (!ctx) { + return undefined; + } + + switch (ctx.text) { + case 'high card': + return HandStrength.HighCard; + case 'a pair': + return HandStrength.OnePair; + case 'two pair': + return HandStrength.TwoPair; + case 'three of a kind': + return HandStrength.ThreeOfAKind; + case 'a straight': + return HandStrength.Straight; + case 'a flush': + return HandStrength.Flush; + case 'a full house': + return HandStrength.FullHouse; + case 'Four of a kind': + return HandStrength.FourOfAKind; + case 'a straight flush': + return HandStrength.StraightFlush; + case 'a royal flush': + return HandStrength.RoyalFlush; + default: + throw new Error(`Unexpected hand strength: "${ctx.text}"`); + } +}; + +export class PokerStarsHandHistoryVisitor + extends AbstractParseTreeVisitor + implements PokerStarsVisitor +{ + protected defaultResult(): Line[] { + return []; + } + + protected aggregateResult(aggregate: Line[], nextResult: Line[]): Line[] { + return [...aggregate, ...nextResult]; + } + + public visitLineAction(ctx: LineActionContext): Line[] { + const actions = new PokerStarsActionVisitor().visit(ctx); + return actions.map((action) => ({ type: 'action', action })); + } + + public visitLineCashGameMeta(ctx: LineCashGameMetaContext): Line[] { + const handNumber = ctx.handNumber().text; + const currency = ctx.currency().text; + + const variantContext = ctx.variant(); + const variant = getVariant(variantContext); + + const bettingStructure = getBettingStructure(ctx.bettingStructure()); + + const text = getSubstring(ctx.timestamp()); + const t = text.split(/\D/).map(Number); + const timestamp = new Date(t[0], t[1] - 1, t[2], t[3], t[4], t[5]); + + return [ + { + type: 'gameMeta', + gameType: 'cash', + site: 'pokerstars', + currency, + handNumber, + variant, + bettingStructure, + timestamp, + }, + ]; + } + + public visitLineTournamentMeta(ctx: LineTournamentMetaContext): Line[] { + const tournamentNumber = ctx.tournamentNumber().text; + const handNumber = ctx.handNumber().text; + const currency = ctx.currency().text; + + const variantContext = ctx.variant(); + const variant = getVariant(variantContext); + + const bettingStructure = getBettingStructure(ctx.bettingStructure()); + + const level = parseRomanNumeral(ctx.levelNumber().text); + + const buyIn = getChipCount(ctx.buyIn().currencyValue()); + const entryFee = getChipCount(ctx.entryFee().currencyValue()); + + const bountyFeeContext = ctx.bountyChip(); + const bountyFee = bountyFeeContext && getChipCount(bountyFeeContext.currencyValue()); + + const text = getSubstring(ctx.timestamp()); + const t = text.split(/\D/).map(Number); + const timestamp = new Date(t[0], t[1] - 1, t[2], t[3], t[4], t[5]); + + return [ + { + type: 'gameMeta', + gameType: 'tournament', + site: 'pokerstars', + currency, + tournamentNumber, + handNumber, + variant, + bettingStructure, + level, + buyIn: buyIn.plus(bountyFee ?? 0).toString(), + entryFee: entryFee.toString(), + timestamp, + }, + ]; + } + + public visitLineTableMeta(ctx: LineTableMetaContext): Line[] { + const tableSize = Number(ctx.tableSize().text); + const tableName = ctx.tableName().text; + return [{ type: 'tableMeta', tableSize, tableName }]; + } + + public visitPostLineSmallBlind(ctx: LinePostSmallBlindContext): Line[] { + const chipCount = getChipCount(ctx.currencyValue()).toString(); + const playerName = ctx.playerName().text; + return [ + { type: 'smallBlind', chipCount }, + { + type: 'action', + action: { type: 'post', amount: chipCount, playerName, postType: 'blind' }, + }, + ]; + } + + public visitPostLineBigBlind(ctx: LinePostBigBlindContext): Line[] { + const chipCount = getChipCount(ctx.currencyValue()).toString(); + const playerName = ctx.playerName().text; + return [ + { type: 'bigBlind', chipCount }, + { + type: 'action', + action: { type: 'post', amount: chipCount, playerName, postType: 'blind' }, + }, + ]; + } + + public visitLinePostAnte(ctx: LinePostAnteContext): Line[] { + const chipCount = getChipCount(ctx.currencyValue()).toString(); + const playerName = ctx.playerName().text; + return [ + { + type: 'action', + action: { type: 'post', amount: chipCount, playerName, postType: 'ante' }, + }, + ]; + } + + public visitLineUncalled(ctx: LineUncalledContext): Line[] { + const chipCount = getChipCount(ctx.currencyValue()).toString(); + const playerName = ctx.playerName().text; + return [{ type: 'action', action: { type: 'return-bet', playerName, amount: chipCount } }]; + } + + public visitLinePlayer(ctx: LinePlayerContext): Line[] { + const seatNumber = Number(ctx.INT().text); + const name = ctx.playerName().text; + const chipCount = getChipCount(ctx.playerChips().currencyValue()).toString(); + const bountyContext = ctx.playerBounty(); + const bounty = bountyContext && getChipCount(bountyContext.currencyValue()).toString(); + return [{ type: 'player', name, seatNumber, positionIndex: seatNumber - 1, chipCount, bounty }]; + } + + public visitLineStreet(ctx: LineStreetContext): Line[] { + const street = getStreet(ctx.STREET_HEADING()); + if (!street || street === 'preflop') { + return []; + } + + const cardStrings = + ctx + .cardCollection() + .at(-1) + ?.cards() + .CARD() + .map((card) => card.text) ?? []; + + const cards = cardStrings.map((card) => { + assertCard(card); + return card as Card; + }); + + return [{ type: 'action', action: { type: 'deal-board', street, cards } }]; + } + + public visitLineDeal(ctx: LineDealContext): Line[] { + const playerName = ctx.playerName().text; + const cardStrings = + ctx + .cardCollection() + .cards() + .CARD() + .map((card) => card.text) ?? []; + const cards = cardStrings.map((card) => { + assertCard(card); + return card as Card; + }); + + return [{ type: 'action', action: { type: 'deal-hand', playerName, cards } }]; + } + + public visitLineShowdown(ctx: LineShowdownContext): Line[] { + const playerName = ctx.playerName().text; + const handStrengthContext = ctx.handStrength(); + const handStrength = getHandStrength(handStrengthContext); + return [ + { + type: 'action', + action: { type: 'showdown', playerName, handStrength, mucked: !handStrengthContext }, + }, + ]; + } + + public visitLineResult(ctx: LineResultContext): Line[] { + const playerName = ctx.playerName().text; + const isSidePot = false; + const chipCount = getChipCount(ctx.currencyValue()).toString(); + return [ + { type: 'action', action: { type: 'award-pot', playerName, amount: chipCount, isSidePot } }, + ]; + } + + public visitLineAwardBounty(ctx: LineAwardBountyContext): Line[] { + const playerName = ctx.playerName()[0].text; + const chipCount = getChipCount(ctx.currencyValue()[0]).toString(); + return [{ type: 'action', action: { type: 'award-bounty', playerName, amount: chipCount } }]; + } + + public visitLineTournamentPlacement(ctx: LineTournamentPlacementContext): Line[] { + const playerName = ctx.playerName().text; + const placement = Number(ctx.placement().text); + const amount = getChipCount(ctx.currencyValue()).toString(); + return [ + { type: 'action', action: { type: 'tournament-placement', playerName, placement } }, + { type: 'action', action: { type: 'tournament-award', playerName, amount } }, + ]; + } +} diff --git a/src/networks/pokerstars/getChipCount.ts b/src/networks/pokerstars/getChipCount.ts new file mode 100644 index 0000000..f417174 --- /dev/null +++ b/src/networks/pokerstars/getChipCount.ts @@ -0,0 +1,6 @@ +import BigNumber from 'bignumber.js'; +import { CurrencyValueContext } from '~/grammar/PokerStarsParser'; + +// TODO: we're assuming commas are always thousands separators, but that may depend on locale +export const getChipCount = (ctx: CurrencyValueContext): BigNumber => + new BigNumber(ctx.text.replace(',', '')); diff --git a/src/networks/pokerstars/parseHand.test.ts b/src/networks/pokerstars/parseHand.test.ts new file mode 100644 index 0000000..82ba5b1 --- /dev/null +++ b/src/networks/pokerstars/parseHand.test.ts @@ -0,0 +1,20 @@ +import { + HAND_CASH_ALL_IN, + HAND_CASH_NO_SHOWDOWN, + HAND_TOURNAMENT_BOUNTY_AND_PLACEMENT, +} from '~/__fixtures__/hands/pokerstars'; +import { parseHand } from './parseHand'; + +describe('parseHand', () => { + it('parses all-ins', () => { + expect(parseHand({ hand: HAND_CASH_ALL_IN })).toMatchInlineSnapshot(); + }); + + it('parses no showdown', () => { + expect(parseHand({ hand: HAND_CASH_NO_SHOWDOWN })).toMatchInlineSnapshot(); + }); + + it('parses tournament bounties and placement awards', () => { + expect(parseHand({ hand: HAND_TOURNAMENT_BOUNTY_AND_PLACEMENT })).toMatchInlineSnapshot(); + }); +}); diff --git a/src/networks/pokerstars/parseHand.ts b/src/networks/pokerstars/parseHand.ts new file mode 100644 index 0000000..ac6079f --- /dev/null +++ b/src/networks/pokerstars/parseHand.ts @@ -0,0 +1,130 @@ +import { IgnitionLexer } from '~/grammar/IgnitionLexer'; +import { IgnitionParser } from '~/grammar/IgnitionParser'; +import { GameInfoBase, HandHistory, ParseHandOptions, TableSize } from '~/types'; +import { OmitStrict } from '~/types/OmitStrict'; +import { getParser } from '~/utils/getParser'; +import { getPosition } from '~/utils/getPosition'; +import { Dictionary, groupBy } from '~/utils/groupBy'; +import { PokerStarsHandHistoryVisitor } from './PokerStarsHandHistoryVisitor'; +// import { TournamentFilenameMeta, parseFilename } from './parseFilename'; +import { + Line, + LineAction, + LineBigBlind, + LineGameMeta, + LinePlayer, + LineSmallBlind, + LineTableMeta, +} from './types'; + +type LineDictionary = Dictionary; + +class LineNotFoundError extends Error {} + +const getInfo = (lines: LineDictionary): HandHistory['info'] => { + const meta: LineGameMeta | undefined = lines.gameMeta?.[0]; + if (!meta) { + throw new LineNotFoundError('Missing meta information'); + } + + const table: LineTableMeta | undefined = lines.tableMeta?.[0]; + if (!table) { + throw new LineNotFoundError('Missing table information'); + } + + // the big blind is always required, the small blind is not + const smallBlind: LineSmallBlind | undefined = lines.smallBlind?.[0]; + const bigBlind: LineBigBlind | undefined = lines.bigBlind?.[0]; + const blinds: string[] = []; + if (smallBlind) { + blinds.push(smallBlind.chipCount); + } + if (!bigBlind) { + throw new LineNotFoundError('Missing big blind information'); + } + blinds.push(bigBlind.chipCount); + + // the ante value should be the same for every player, so we can just extract the first value + const ante = lines.ante?.[0].chipCount ?? '0'; + + const baseInfo: OmitStrict = { + type: meta.gameType, + blinds, + ante, + currency: 'USD', + variant: meta.variant, + handNumber: meta.handNumber, + tableNumber: table.tableName, + site: meta.site, + tableSize: table.tableSize, + timestamp: meta.timestamp, + }; + + if (meta.gameType === 'cash') { + return { + ...baseInfo, + type: 'cash', + bettingStructure: meta.bettingStructure, + // isFastFold: meta.fastFold, + }; + } + + return { + ...baseInfo, + ...filenameInfo, + type: 'tournament', + tournamentNumber: meta.tournamentNumber, + tournamentStart: filenameInfo.tournamentStart ?? meta.timestamp, + level: meta.level, + speed: meta.speed ?? filenameInfo.speed, + }; +}; + +const getPlayers = (lines: LineDictionary, tableSize: TableSize): HandHistory['players'] => { + const players: LinePlayer[] = lines.player ?? []; + + return players.map((player) => ({ + name: player.name, + positionIndex: player.positionIndex, + position: getPosition(player.positionIndex, tableSize), + seatNumber: player.seatNumber, + chipStack: player.chipCount, + bounty: player.bounty, + isHero: player.isHero, + isAnonymous: false, + })); +}; + +const getActions = (lines: LineDictionary): HandHistory['actions'] => { + const actions: LineAction[] = lines.action ?? []; + if (actions.some(({ type }) => type !== 'action')) { + throw new LineNotFoundError('Missing actions'); + } + + return actions.map(({ action }, index) => { + // Reconcile 'bet' all-ins that should be treated as 'call' all-ins. (Bovada does not + // differentiate these actions in their hand histories.) + if (action.type === 'bet' && action.isAllIn) { + const previousAction = actions[index - 1]?.action; + if (previousAction?.type === 'bet' || previousAction?.type === 'raise') { + return { ...action, type: 'call' }; + } + } + return action; + }); +}; + +export const parseHand = ({ hand }: ParseHandOptions): HandHistory => { + const parser = getParser(hand, { lexer: IgnitionLexer, parser: IgnitionParser }); + const context = parser.handHistory(); + + const visitor = new PokerStarsHandHistoryVisitor(); + const lines = visitor.visit(context); + const groupedLines = groupBy(lines, 'type'); + + const info = getInfo(groupedLines); + const players = getPlayers(groupedLines, info.tableSize); + const actions = getActions(groupedLines); + + return { info, players, actions }; +}; diff --git a/src/networks/pokerstars/parseRomanNumeral.test.ts b/src/networks/pokerstars/parseRomanNumeral.test.ts new file mode 100644 index 0000000..27f806a --- /dev/null +++ b/src/networks/pokerstars/parseRomanNumeral.test.ts @@ -0,0 +1,20 @@ +import { parseRomanNumeral } from './parseRomanNumeral'; + +describe('parseRomanNumeral', () => { + it('parses individual roman numerals', () => { + expect(parseRomanNumeral('I')).toBe(1); + expect(parseRomanNumeral('V')).toBe(5); + expect(parseRomanNumeral('X')).toBe(10); + expect(parseRomanNumeral('L')).toBe(50); + expect(parseRomanNumeral('C')).toBe(100); + expect(parseRomanNumeral('D')).toBe(500); + expect(parseRomanNumeral('M')).toBe(1000); + }); + + it('parses complex combinations of roman numerals', () => { + expect(parseRomanNumeral('IV')).toBe(4); + expect(parseRomanNumeral('IM')).toBe(999); + expect(parseRomanNumeral('CMXCIX')).toBe(999); + expect(parseRomanNumeral('MCMLXXXVIII')).toBe(1988); + }); +}); diff --git a/src/networks/pokerstars/parseRomanNumeral.ts b/src/networks/pokerstars/parseRomanNumeral.ts new file mode 100644 index 0000000..617f9dd --- /dev/null +++ b/src/networks/pokerstars/parseRomanNumeral.ts @@ -0,0 +1,33 @@ +const ROMAN_NUMERICAL_VALUES = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 } as const; + +type ROMAN_NUMERAL = keyof typeof ROMAN_NUMERICAL_VALUES; + +// eslint-disable-next-line jsdoc/require-jsdoc +function assertRomanNumeral(str: string | undefined): asserts str is ROMAN_NUMERAL { + if (str === undefined || !Object.hasOwn(ROMAN_NUMERICAL_VALUES, str)) { + throw new Error(`Unrecognized roman numeral: "${str}"`); + } +} + +const getRomanNumeral = (str: string | undefined): ROMAN_NUMERAL => { + const romanNumeral = str?.[0]; + assertRomanNumeral(romanNumeral); + return romanNumeral; +}; + +export const parseRomanNumeral = (romanNumeral: string): number => { + const romanNumerals = romanNumeral.toUpperCase().split(''); + let num = 0; + while (romanNumerals.length) { + const currentRoman = getRomanNumeral(romanNumerals.shift()); + const nextRoman = romanNumerals.length === 0 ? undefined : getRomanNumeral(romanNumerals[0]); + const currentValue = ROMAN_NUMERICAL_VALUES[currentRoman]; + const decrement = nextRoman && currentValue < ROMAN_NUMERICAL_VALUES[nextRoman]; + if (decrement) { + num -= currentValue; + } else { + num += currentValue; + } + } + return num; +}; diff --git a/src/networks/pokerstars/types.ts b/src/networks/pokerstars/types.ts new file mode 100644 index 0000000..7ffb2de --- /dev/null +++ b/src/networks/pokerstars/types.ts @@ -0,0 +1,79 @@ +import { Action, BettingStructure, Site, Variant } from '~/types'; + +export type PokerStarsSite = Site & 'pokerstars'; + +interface BaseLine { + type: string; +} + +export interface LineAction extends BaseLine { + type: 'action'; + action: Action; +} + +interface LineGameMetaBase extends BaseLine { + type: 'gameMeta'; + gameType: string; + site: PokerStarsSite; + currency: string; + handNumber: string; + variant: Variant; + bettingStructure: BettingStructure; + timestamp: Date; +} + +interface LineGameMetaCash extends LineGameMetaBase { + gameType: 'cash'; +} + +interface LineMetaTournament extends LineGameMetaBase { + gameType: 'tournament'; + tournamentNumber: string; + level: number; + // speed: TournamentSpeed | undefined; + buyIn: string; + entryFee: string; +} + +export type LineGameMeta = LineGameMetaCash | LineMetaTournament; + +export interface LineTableMeta { + type: 'tableMeta'; + tableName: string; + tableSize: number; +} + +export interface LinePlayer extends BaseLine { + type: 'player'; + name: string; + positionIndex: number; + seatNumber: number; + chipCount: string; + bounty: string | undefined; +} + +export interface LineSmallBlind extends BaseLine { + type: 'smallBlind'; + chipCount: string; +} + +export interface LineBigBlind extends BaseLine { + type: 'bigBlind'; + chipCount: string; +} + +export interface LineAnte extends BaseLine { + type: 'ante'; + chipCount: string; +} + +export type Line = + | LineAction + | LineGameMeta + | LineTableMeta + | LinePlayer + | LineSmallBlind + | LineBigBlind + | LineAnte; + +export class NotImplementedError extends Error {} diff --git a/src/parseHand.ts b/src/parseHand.ts index b6562d3..2611165 100644 --- a/src/parseHand.ts +++ b/src/parseHand.ts @@ -10,6 +10,8 @@ const getParser = async (site: Site) => { case 'bovada': case 'ignition': return (await import('./networks/ignition/parseHand')).parseHand; + case 'pokerstars': + return (await import('./networks/pokerstars/parseHand')).parseHand; default: return assertNever(site); } diff --git a/src/parseSite.test.ts b/src/parseSite.test.ts index bf12e3e..f22f44e 100644 --- a/src/parseSite.test.ts +++ b/src/parseSite.test.ts @@ -1,6 +1,7 @@ import { HAND as HAND_BODOG } from './__fixtures__/hands/bodog'; import { HAND_ALL_IN as HAND_BOVADA } from './__fixtures__/hands/bovada'; import { HAND as HAND_IGNITION } from './__fixtures__/hands/ignition'; +import { HAND_CASH_NO_SHOWDOWN as HAND_POKERSTARS } from './__fixtures__/hands/pokerstars'; import { InvalidSiteError } from './errors/InvalidSiteError'; import { parseSite } from './parseSite'; @@ -17,6 +18,10 @@ describe('parseSite', () => { expect(parseSite(HAND_IGNITION)).toEqual('ignition'); }); + it('parses pokerstars hand', () => { + expect(parseSite(HAND_POKERSTARS)).toEqual('pokerstars'); + }); + it('throws unrecognized hand', () => { expect(() => parseSite('something random and unknown\r\nline two')).toThrow(InvalidSiteError); }); diff --git a/src/parseSite.ts b/src/parseSite.ts index 5054ae3..c79848c 100644 --- a/src/parseSite.ts +++ b/src/parseSite.ts @@ -1,23 +1,25 @@ import { InvalidSiteError } from './errors/InvalidSiteError'; import { Site } from './types'; -const getFirstLine = (str: string) => { - const [firstLine] = str.replace(/^\s+/g, '').split(/[\r\n]+/g, 2); - return firstLine; -}; +const invert = (input: Record) => + Object.fromEntries(Object.entries(input).map(([key, value]) => [value, key])) as Record; + +const SITE_STRINGS: Record = { + bodog: 'Bodog', + bovada: 'Bovada', + ignition: 'Ignition', + pokerstars: 'PokerStars', +} as const; + +const SITE_LOOKUP = invert(SITE_STRINGS); export const parseSite = (hand: string): Site => { - const siteMeta = getFirstLine(hand); + const regex = new RegExp(`(?${Object.values(SITE_STRINGS).join('|')})`, 'm'); + const groups = hand.match(regex)?.groups ?? {}; + const site: Site | undefined = SITE_LOOKUP[groups.site]; - if (siteMeta.match(/^Bodog\b/)) { - return 'bodog'; + if (!site) { + throw new InvalidSiteError(hand); } - if (siteMeta.match(/^Bovada\b/)) { - return 'bovada'; - } - if (siteMeta.match(/^Ignition\b/)) { - return 'ignition'; - } - - throw new InvalidSiteError(siteMeta); + return site; }; diff --git a/src/types.ts b/src/types.ts index 467922c..a434c2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ import { Card, HandStrength } from '@poker-apprentice/types'; /** * The name of the poker site. */ -export type Site = 'bodog' | 'bovada' | 'ignition'; +export type Site = 'bodog' | 'bovada' | 'ignition' | 'pokerstars'; /** * The name of the poker variant. @@ -45,7 +45,7 @@ export type Street = 'preflop' | 'flop' | 'turn' | 'river'; /** * The supported table sizes. */ -export type TableSize = 2 | 6 | 8 | 9; +export type TableSize = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; /** * Information related to the poker game as a whole. @@ -191,6 +191,10 @@ export interface Player { * The number of chips the player had at the start of a poker hand. */ chipStack: string; + /** + * The bounty awarded for stacking the player in a poker hand. + */ + bounty: string; /** * Whether or not the player is the hero. */ diff --git a/src/utils/getPosition.ts b/src/utils/getPosition.ts index f6eac14..b001987 100644 --- a/src/utils/getPosition.ts +++ b/src/utils/getPosition.ts @@ -2,7 +2,11 @@ import { Position, TableSize } from '../types'; const POSITIONS_BY_TABLE_SIZE: Record = { 2: ['BB', 'SB'], + 3: ['BTN', 'SB', 'BB'], + 4: ['BTN', 'SB', 'BB', 'UTG'], + 5: ['BTN', 'SB', 'BB', 'UTG', 'CO'], 6: ['BTN', 'SB', 'BB', 'UTG', 'MP', 'CO'], + 7: ['BTN', 'SB', 'BB', 'UTG', 'MP', 'HJ', 'CO'], 8: ['BTN', 'SB', 'BB', 'UTG', 'UTG+1', 'MP', 'HJ', 'CO'], 9: ['BTN', 'SB', 'BB', 'UTG', 'UTG+1', 'MP', 'LJ', 'HJ', 'CO'], };