UNPKG

@idealic/poker-engine

Version:

Poker game engine and hand evaluator

613 lines (537 loc) 22.5 kB
import { describe, expect, it } from 'vitest'; import * as Poker from '../../../index'; import type { Action } from '../../../types'; import { BASE_HAND, MINIMAL_HAND, SHOWDOWN_HAND } from './fixtures/baseGame'; describe('Game API - Validation', () => { describe('canApplyAction', () => { describe('Player Turn Validation', () => { it('allows only the correct next player to act', () => { const hand: Poker.Hand = { ...BASE_HAND, actions: ['d dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 60'], }; const game = Poker.Game(hand); // Next to act: p4 expect(Poker.Game.canApplyAction(game, 'p4 cc 60')).toBe(true); expect(Poker.Game.canApplyAction(game, 'p4 f')).toBe(true); // Everyone else is out of turn expect(Poker.Game.canApplyAction(game, 'p1 cc 60')).toBe(false); expect(Poker.Game.canApplyAction(game, 'p2 cc 60')).toBe(false); expect(Poker.Game.canApplyAction(game, 'p3 cc 60')).toBe(false); }); it('expects a dealer action before any player actions', () => { const hand: Poker.Hand = { ...BASE_HAND, actions: [], }; const game = Poker.Game(hand); // First action must be a dealer deal action expect(Poker.Game.canApplyAction(game, 'd dh p1 AsKs')).toBe(true); expect(Poker.Game.canApplyAction(game, 'p1 cbr 60')).toBe(false); }); }); describe('Betting Action Validation', () => { it('does not enforce a specific call amount against the current bet', () => { const hand: Poker.Hand = { ...BASE_HAND, actions: [ ...BASE_HAND.actions, 'p3 cbr 100', // p3 bets 100 ], }; const game = Poker.Game(hand); // All of these are currently treated as valid calls, // regardless of amount or whether the amount is omitted. expect(Poker.Game.canApplyAction(game, 'p4 cc 100')).toBe(true); expect(Poker.Game.canApplyAction(game, 'p4 cc 50')).toBe(true); expect(Poker.Game.canApplyAction(game, 'p4 cc 200')).toBe(true); expect(Poker.Game.canApplyAction(game, 'p4 cc')).toBe(true); }); it('enforces a minimum raise size based on the previous raise', () => { const hand: Poker.Hand = { ...BASE_HAND, actions: ['d dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 60'], }; const game = Poker.Game(hand); // Previous raise size is (60 - big blind). // Valid: total bet >= 100 expect(Poker.Game.canApplyAction(game, 'p4 cbr 120')).toBe(true); expect(Poker.Game.canApplyAction(game, 'p4 cbr 200')).toBe(true); expect(Poker.Game.canApplyAction(game, 'p4 cbr 100')).toBe(true); // Too small: below min raise expect(Poker.Game.canApplyAction(game, 'p4 cbr 80')).toBe(false); }); it('rejects non-all-in raises smaller than the minimum raise size', () => { const hand: Poker.Hand = { ...BASE_HAND, // p3 raises to 60 from a 20 BB => raise size 40. // Next min total bet = 60 + 40 = 100. actions: ['d dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 60'], }; const game = Poker.Game(hand); // 80 is a raise of 20 (< 40 min raise) and not all-in => invalid expect(Poker.Game.canApplyAction(game, 'p4 cbr 80')).toBe(false); // 100 = 60 + 40 => valid min raise expect(Poker.Game.canApplyAction(game, 'p4 cbr 100')).toBe(true); }); it('handles incomplete raise reopening rules correctly', () => { // Scenario 1: short all-in does NOT reopen betting for the original bettor const hand: Poker.Hand = { ...BASE_HAND, startingStacks: [120, 1000, 1000, 1000], actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 100', 'p4 cc 100', 'p1 cbr 120', // all-in, incomplete raise 'p2 cc 120', ], }; const game = Poker.Game(hand); // p3 already bet 100 and now faces an incomplete raise to 120, // so p3 is capped to call/fold. expect(Poker.Game.canApplyAction(game, 'p3 cbr 220')).toBe(false); expect(Poker.Game.canApplyAction(game, 'p3 cbr 320')).toBe(false); expect(Poker.Game.canApplyAction(game, 'p3 cc 120')).toBe(true); // Scenario 2: a later full raise reopens action for the original bettor const handReopened: Poker.Hand = { ...hand, actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 100', 'p4 cc 100', 'p1 cbr 120', // all-in, still incomplete 'p2 cbr 300', // full raise ], }; const gameReopened = Poker.Game(handReopened); // Now facing a full raise to 300, p3 may raise again. expect(Poker.Game.canApplyAction(gameReopened, 'p3 cbr 500')).toBe(true); }); it('always allows the active player to fold', () => { const hand: Poker.Hand = { ...BASE_HAND, actions: [...BASE_HAND.actions, 'p3 cbr 100'], }; const game = Poker.Game(hand); expect(Poker.Game.canApplyAction(game, 'p4 f')).toBe(true); }); }); describe('Stack Size Validation', () => { it('does not enforce stack-size limits on bet amounts yet', () => { const hand: Poker.Hand = { ...BASE_HAND, startingStacks: [100, 150, 80, 120], actions: ['d dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd'], }; const game = Poker.Game(hand); // p3 has only 80 chips, but validator currently accepts both: expect(Poker.Game.canApplyAction(game, 'p3 cbr 80')).toBe(true); // all-in expect(Poker.Game.canApplyAction(game, 'p3 cbr 100')).toBe(true); // exceeds stack, still accepted }); it('handles all-in bet followed by a valid call', () => { const hand: Poker.Hand = { ...BASE_HAND, startingStacks: [100, 150, 80, 120], actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 80', // p3 all-in ], }; const game = Poker.Game(hand); // p4 has enough chips to call all-in expect(Poker.Game.canApplyAction(game, 'p4 cc 80')).toBe(true); // nextPlayerIndex is defined by implementation; for this setup it's 3 (p4). expect(game.nextPlayerIndex).toBe(3); }); }); describe('Show/Muck Validation', () => { it('rejects explicit show actions during showdown', () => { const game = Poker.Game(SHOWDOWN_HAND); // SHOWDOWN_HAND already represents a completed showdown state. expect(Poker.Game.canApplyAction(game, 'p3 sm QhQc')).toBe(false); expect(Poker.Game.canApplyAction(game, 'p4 sm JdTd')).toBe(false); }); it('rejects show actions before showdown', () => { const game = Poker.Game(BASE_HAND); expect(Poker.Game.canApplyAction(game, 'p3 sm QhQc')).toBe(false); expect(Poker.Game.canApplyAction(game, 'p4 sm JdTd')).toBe(false); }); it('allows muck actions at showdown when appropriate', () => { const game = Poker.Game({ ...SHOWDOWN_HAND, actions: [...SHOWDOWN_HAND.actions.slice(0, -2)], }); expect(Poker.Game.canApplyAction(game, 'p3 m')).toBe(true); expect(Poker.Game.canApplyAction(game, 'p4 m')).toBe(true); }); }); describe('Game State Validation', () => { it('rejects further actions after the hand is complete', () => { const hand: Poker.Hand = { ...BASE_HAND, actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 f', 'p4 f', 'p1 cbr 100', 'p2 f', // only p1 remains ], }; const game = Poker.Game(hand); // Hand should be complete and not a showdown. expect(game.isComplete).toBe(true); expect(game.isShowdown).toBe(false); // No further player or dealer actions should be allowed. expect(Poker.Game.canApplyAction(game, 'p1 cc')).toBe(false); expect(Poker.Game.canApplyAction(game, 'd db AhKhQd')).toBe(false); }); it('rejects player actions and accepts dealer actions during dealer phase', () => { const hand: Poker.Hand = { ...BASE_HAND, actions: [...BASE_HAND.actions, 'p3 cc', 'p4 cc'], }; const game = Poker.Game(hand); // Implementation uses nextPlayerIndex === -1 to indicate dealer is to act. expect(game.nextPlayerIndex).toBe(-1); expect(Poker.Game.canApplyAction(game, 'p3 cbr 100')).toBe(false); expect(Poker.Game.canApplyAction(game, 'd db Td')).toBe(true); }); }); describe('Action Format Validation', () => { it('rejects malformed or unknown actions', () => { const game = Poker.Game(BASE_HAND); expect(Poker.Game.canApplyAction(game, '' as Action)).toBe(false); expect(Poker.Game.canApplyAction(game, 'invalid' as Action)).toBe(false); expect(Poker.Game.canApplyAction(game, 'p99 cc' as Action)).toBe(false); expect(Poker.Game.canApplyAction(game, 'p3 xyz 100' as Action)).toBe(false); }); it('validates player indices against the hand definition', () => { const game = Poker.Game(MINIMAL_HAND, ['d dh p1 AsKs', 'd dh p2 7h2d']); // Only p1 and p2 exist. expect(Poker.Game.canApplyAction(game, 'p1 f')).toBe(true); expect(Poker.Game.canApplyAction(game, 'p3 f')).toBe(false); expect(Poker.Game.canApplyAction(game, 'p0 f')).toBe(false); }); }); describe('Fixed Limit Validation', () => { it.skip('should enforce fixed limit betting structure', () => { // SCENARIO: Fixed limit variant with smallBet = 10 means BB = 10, SB = 5 // INPUT: FT variant with smallBet: 10, bigBet: 20, blindsOrStraddles: [5, 10, 0, 0] // EXPECTED: Game is created and fixed limit betting rules apply const hand: Poker.Hand = { ...BASE_HAND, variant: 'FT', smallBet: 10, bigBet: 20, minBet: undefined, blindsOrStraddles: [5, 10, 0, 0], // Fixed limit: BB = smallBet (10), SB = 5 actions: ['d dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd'], }; const game = Poker.Game(hand); if (game.variant === 'FT') { // All of these are just probing the current (lenient) validation. expect(Poker.Game.canApplyAction(game, 'p3 cbr 10')).toBe(false); expect(Poker.Game.canApplyAction(game, 'p3 cbr 20')).toBe(false); expect(Poker.Game.canApplyAction(game, 'p3 cbr 50')).toBe(true); } }); }); describe('Special Situations', () => { it('skips all-in players when determining next to act (side pots)', () => { const hand: Poker.Hand = { ...BASE_HAND, startingStacks: [100, 500, 300, 400], actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 100', 'p4 cc 100', 'p1 cbr 100', // p1 all-in 'p2 cc 100', 'd db AhKhQd', ], }; const game = Poker.Game(hand); expect(game.players[0].isAllIn).toBe(true); expect(game.nextPlayerIndex).not.toBe(0); }); it('accepts actions that include timestamps', () => { const now = Date.now(); const hand: Poker.Hand = { ...BASE_HAND, actions: [ `d dh p1 AsKs #${now}`, `d dh p2 7h2d #${now + 100}`, `d dh p3 QhQc #${now + 200}`, `d dh p4 JdTd #${now + 300}`, ], }; const game = Poker.Game(hand); expect(Poker.Game.canApplyAction(game, `p3 cbr 60 #${now + 400}`)).toBe(true); }); it('handles a minimal heads-up fixture without allowing these preflop call actions', () => { const game = Poker.Game(MINIMAL_HAND); expect(game.players).toHaveLength(2); // Whichever seat is next to act, both "p1 cc 20" and "p2 cc 20" // are currently treated as invalid first actions in this fixture. const validAction = game.nextPlayerIndex === 0 ? 'p1 cc 20' : 'p2 cc 20'; const invalidAction = game.nextPlayerIndex === 0 ? 'p2 cc 20' : 'p1 cc 20'; expect(Poker.Game.canApplyAction(game, validAction as Action)).toBe(false); expect(Poker.Game.canApplyAction(game, invalidAction as Action)).toBe(false); }); }); describe('Complex Raise Scenarios', () => { it('does not reopen action for original raiser after incomplete raise call (short stack in between)', () => { const hand: Poker.Hand = { ...BASE_HAND, // Short stack in SB seat (p1). startingStacks: [75, 1000, 1000, 1000], actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 60', // UTG opens to 60 'p4 cc 60', // next player calls 60 'p1 cbr 75', // SB all-in 75 (incomplete raise) 'p2 f', // BB folds ], }; const game = Poker.Game(hand); // Original raiser p3 may call 75 but may not raise further. expect(Poker.Game.canApplyAction(game, 'p3 cbr 150')).toBe(false); expect(Poker.Game.canApplyAction(game, 'p3 cc 75')).toBe(true); }); it('allows a player facing a cold incomplete raise to raise', () => { const hand: Poker.Hand = { ...BASE_HAND, startingStacks: [1000, 1000, 1000, 135], actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 100', // open to 100 'p4 cbr 135', // short-stacked incomplete raise to 135 ], }; const game = Poker.Game(hand); // SB (p1) has not acted on 100 yet, and can raise facing 135. expect(Poker.Game.canApplyAction(game, 'p1 cbr 300')).toBe(true); }); it('reopens betting if a later player makes a full raise after an incomplete raise', () => { const hand: Poker.Hand = { ...BASE_HAND, blindsOrStraddles: [50, 100, 0, 0], minBet: 100, startingStacks: [370, 1000, 1000, 1000], actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 300', // UTG raises to 300 'p4 cc 300', // BTN calls 'p1 cbr 370', // SB all-in 370 (incomplete raise) 'p2 cbr 600', // BB full raise to 600 ], }; const game = Poker.Game(hand); // Original raiser p3 is now allowed to raise again. expect(Poker.Game.canApplyAction(game, 'p3 cbr 1000')).toBe(true); }); it("allows BB to raise facing an incomplete all-in if they haven't acted yet", () => { const hand: Poker.Hand = { ...BASE_HAND, blindsOrStraddles: [50, 100, 0, 0], minBet: 100, startingStacks: [1000, 1000, 1000, 300], actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 250', // open to 250 'p4 cbr 300', // short all-in 300 (incomplete) 'p1 f', // SB folds ], }; const game = Poker.Game(hand); // BB (p2) has only posted the blind so far and can raise. expect(Poker.Game.canApplyAction(game, 'p2 cbr 500')).toBe(true); }); it('keeps betting capped after multiple incomplete raises', () => { const hand: Poker.Hand = { ...BASE_HAND, players: ['SB', 'BB', 'UTG', 'MP1', 'MP2', 'BTN'], startingStacks: [1000, 1000, 1000, 110, 130, 1000], blindsOrStraddles: [10, 20, 0, 0, 0, 0], actions: [ 'd dh p1 7s2h', 'd dh p2 8s3h', 'd dh p3 AsKs', 'd dh p4 9s9h', 'd dh p5 TsTh', 'd dh p6 JsJh', 'p3 cbr 80', // UTG raise 'p4 cbr 110', // MP1 short all-in (incomplete) 'p5 cbr 130', // MP2 short all-in (incomplete) 'p6 cc 150', // BTN calls current max 'p1 f', 'p2 f', ], }; const game = Poker.Game(hand); // UTG cannot reopen the betting because no full raise occurred. expect(Poker.Game.canApplyAction(game, 'p3 cbr 300')).toBe(false); expect(Poker.Game.canApplyAction(game, 'p3 cc 160')).toBe(true); }); it('reopens betting when a player completes exactly the minimum raise', () => { const hand: Poker.Hand = { ...BASE_HAND, blindsOrStraddles: [100, 200, 0, 0], minBet: 200, startingStacks: [1000, 1000, 1200, 650], actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 500', // UTG raises to 500 (+300) 'p4 cbr 650', // short all-in, incomplete raise 'p1 cbr 800', // SB completes the full raise size 'p2 f', ], }; const game = Poker.Game(hand); // Original raiser p3 can raise again as betting is reopened. expect(Poker.Game.canApplyAction(game, 'p3 cbr 1200')).toBe(true); }); it('does not reopen betting if intervening player only overcalls after an incomplete raise', () => { const hand: Poker.Hand = { ...BASE_HAND, startingStacks: [50, 1000, 1000, 1000], actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 90', // open 'p4 cc 90', // call 'p1 cbr 50', // short all-in below current bet 'p2 cc 90', // overcall ], }; const game = Poker.Game(hand); // Original raiser p3 is still capped. expect(Poker.Game.canApplyAction(game, 'p3 cbr 200')).toBe(false); }); it('allows BB to raise after a discounted call versus an incomplete raise', () => { const hand: Poker.Hand = { ...BASE_HAND, blindsOrStraddles: [50, 100, 0, 0], startingStacks: [1000, 1000, 1000, 60], minBet: 100, actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', 'p3 cbr 250', // open 'p4 cbr 60', // short all-in, less than bet 'p1 f', // SB folds ], }; const game = Poker.Game(hand); // BB has a discount (posted 100) and has never acted on the 250 yet. expect(Poker.Game.canApplyAction(game, 'p2 cbr 500')).toBe(true); }); it('allows a player who checked to raise when later facing an incomplete bet/raise postflop', () => { const hand: Poker.Hand = { ...BASE_HAND, startingStacks: [160, 1000, 1000, 1000], actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', // preflop limp pot 'p3 cc 20', 'p4 cc 20', 'p1 cc 20', 'p2 cc 20', 'd db AhKhQd', // flop 'p1 cc', // SB check 'p2 cc', // BB check 'p3 cc', // UTG check 'p4 cbr 100', // BTN bets 100 'p1 cbr 140', // SB all-in 140 (incomplete raise) 'p2 cc 140', // BB calls 140 ], }; const game = Poker.Game(hand); // UTG (p3) had only checked previously and now faces 140 for the first time. expect(Poker.Game.canApplyAction(game, 'p3 cbr 300')).toBe(true); }); it('caps a player who already bet when facing an incomplete raise postflop', () => { const hand: Poker.Hand = { ...BASE_HAND, startingStacks: [260, 1000, 1000, 1000], actions: [ 'd dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd', // preflop limp pot 'p3 cc 20', 'p4 cc 20', 'p1 cc 20', 'p2 cc 20', 'd db AhKhQd', // flop 'p1 cc', 'p2 cc', 'p3 cbr 200', // UTG bets 200 'p4 cc 200', // BTN calls 'p1 cbr 240', // SB all-in 240 (incomplete raise) 'p2 f', ], }; const game = Poker.Game(hand); // UTG (p3) already bet 200 and is now capped versus the incomplete raise. expect(Poker.Game.canApplyAction(game, 'p3 cbr 400')).toBe(false); }); }); describe('Return Value Consistency', () => { it('always returns a boolean from canApplyAction', () => { const game = Poker.Game(BASE_HAND); const result1 = Poker.Game.canApplyAction(game, 'p3 cc'); const result2 = Poker.Game.canApplyAction(game, 'invalid' as Action); const result3 = Poker.Game.canApplyAction(game, null as any); expect(typeof result1).toBe('boolean'); expect(typeof result2).toBe('boolean'); expect(typeof result3).toBe('boolean'); }); it('never throws for invalid or malformed actions', () => { const game = Poker.Game(BASE_HAND); expect(() => Poker.Game.canApplyAction(game, '' as Action)).not.toThrow(); expect(() => Poker.Game.canApplyAction(game, undefined as any)).not.toThrow(); expect(() => Poker.Game.canApplyAction(game, 'p99 cbr 1000000' as Action)).not.toThrow(); }); }); }); });