@idealic/poker-engine
Version:
Poker game engine and hand evaluator
613 lines (537 loc) • 22.5 kB
text/typescript
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();
});
});
});
});