@idealic/poker-engine
Version:
Poker game engine and hand evaluator
530 lines (478 loc) • 20.5 kB
text/typescript
import { describe, expect, it } from 'vitest';
import { Game } from '../../Game';
import { findFirstToActForStreet, getNextEligiblePlayerIndex } from '../../game/position';
import { isAwaitingDealer } from '../../game/progress';
import { Hand } from '../../Hand';
import type { Action } from '../../types';
describe('Action Helpers', () => {
describe('Player Order and Action Flow', () => {
it('should handle heads-up (2 players) dynamics', () => {
/*
* Tests the unique heads-up dynamics where BTN is also SB:
* - BTN/SB posts 10, BB posts 20
* - Preflop action starts with BTN/SB (since BB posted highest blind)
* - Postflop BB acts first (first active after button)
* - Only two positions to track, which makes it a special case
*/
const game = Game({
variant: 'NT',
players: ['p1', 'p2'],
startingStacks: [1000, 1000],
blindsOrStraddles: [10, 20], // p1 is SB (10), p2 is BB (20)
antes: [0, 0],
actions: [
'd dh p1 AhKh', // BTN/SB
'd dh p2 QhJh', // BB
],
minBet: 20,
});
// Verify button position - in heads-up, button is SB
expect(game.buttonIndex).toBe(0); // p1 is BTN/SB
// Check first to act for each street
expect(findFirstToActForStreet(game, 'preflop')).toBe(0); // BTN/SB acts first preflop
expect(findFirstToActForStreet(game, 'flop')).toBe(1); // BB acts first postflop (first after button)
expect(findFirstToActForStreet(game, 'turn')).toBe(1); // BB acts first postflop
expect(findFirstToActForStreet(game, 'river')).toBe(1); // BB acts first postflop
// Check next player for each position
expect(getNextEligiblePlayerIndex(game, 0)).toBe(1); // From BTN/SB to BB
expect(getNextEligiblePlayerIndex(game, 1)).toBe(0); // From BB wraps to BTN/SB
// Verify current player - preflop starts with BTN/SB
expect(Game.getCurrentPlayerIndex(game)).toBe(0); // BTN/SB acts first preflop
});
it('should handle 3-handed dynamics with action flow', () => {
/*
* Tests the minimal full ring dynamics with 3 players:
* - p1 is BTN (no blind)
* - p2 is SB (10)
* - p3 is BB (20)
* - Preflop starts after BB (UTG/BTN), postflop starts after button (SB)
*/
const game = Game({
variant: 'NT',
players: ['p1', 'p2', 'p3'],
startingStacks: [1000, 1000, 1000],
blindsOrStraddles: [0, 10, 20], // BTN (0), SB (10), BB (20)
antes: [0, 0, 0],
actions: [
'd dh p1 AhKh', // BTN
'd dh p2 QhJh', // SB
'd dh p3 ThTd', // BB
'p1 cc', // BTN calls
'p2 cc', // SB completes
'p3 cc', // BB checks
'd db AcKcQc', // Flop dealt
],
minBet: 20,
});
// Verify button position - in ring game, button is 2 positions before BB
expect(game.buttonIndex).toBe(0); // p1 is BTN
// Check first to act for each street
expect(findFirstToActForStreet(game, 'preflop')).toBe(0); // BTN acts first preflop (first after BB)
expect(findFirstToActForStreet(game, 'flop')).toBe(1); // SB acts first postflop (first after button)
expect(findFirstToActForStreet(game, 'turn')).toBe(1); // SB acts first postflop
expect(findFirstToActForStreet(game, 'river')).toBe(1); // SB acts first postflop
// Check next player for each position
expect(getNextEligiblePlayerIndex(game, 0)).toBe(1); // From BTN to SB
expect(getNextEligiblePlayerIndex(game, 1)).toBe(2); // From SB to BB
expect(getNextEligiblePlayerIndex(game, 2)).toBe(0); // From BB wraps to BTN
// Verify current player - after flop is dealt, SB acts first
expect(Game.getCurrentPlayerIndex(game)).toBe(1); // SB acts first postflop
});
it('should handle 5-handed with all-ins and folds', () => {
/*
* Tests complex scenario with mixed stack depths and actions:
* Seat order: BTN -> SB -> BB -> UTG -> CO
* - p1 is BTN (no blind)
* - p2 is SB (10)
* - p3 is BB (20)
* - p4 is UTG
* - p5 is CO
* - Tests action flow when multiple players are all-in or folded
*/
const game = Game({
variant: 'NT',
players: ['p1', 'p2', 'p3', 'p4', 'p5'],
startingStacks: [1000, 100, 1000, 100, 1000],
blindsOrStraddles: [0, 10, 20, 0, 0], // BTN (0), SB (10), BB (20), UTG (0), CO (0)
antes: [0, 0, 0, 0, 0],
actions: [
'd dh p1 AhKh', // BTN
'd dh p2 QhJh', // SB
'd dh p3 ThTd', // BB
'd dh p4 9h9d', // UTG
'd dh p5 8h8d', // CO
'p4 cc', // UTG calls
'p5 cc', // CO calls
'p1 cc', // BTN calls
'p2 cbr 100', // SB all-in
'p3 f', // BB folds
'p4 f', // UTG folds
],
minBet: 20,
});
// Verify button position
expect(game.buttonIndex).toBe(0); // p1 is BTN
// Check first to act for each street
expect(findFirstToActForStreet(game, 'preflop')).toBe(3); // UTG acts first preflop
expect(findFirstToActForStreet(game, 'flop')).toBe(1); // First after button (SB)
expect(findFirstToActForStreet(game, 'turn')).toBe(1); // First after button (SB)
expect(findFirstToActForStreet(game, 'river')).toBe(1); // First after button (SB)
// Check next player for each position
expect(getNextEligiblePlayerIndex(game, 0)).toBe(4); // From BTN to CO (skip all-in SB and folded BB/UTG)
expect(getNextEligiblePlayerIndex(game, 4)).toBe(0); // From CO wraps to BTN
// Verify current player - after SB all-in and BB/UTG fold
expect(Game.getCurrentPlayerIndex(game)).toBe(4); // CO to act on SB's all-in
});
it('should handle mixed stack sizes with multiple side pots', () => {
/*
* Tests complex scenario with multiple all-ins creating side pots:
* Seat order: BTN -> SB -> BB -> UTG
* - p1 is BTN (no blind)
* - p2 is SB (10)
* - p3 is BB (20)
* - p4 is UTG
* - Tests action when players have different stack sizes
*/
const game = Game({
variant: 'NT',
players: ['p1', 'p2', 'p3', 'p4'],
startingStacks: [50, 100, 200, 1000],
blindsOrStraddles: [0, 10, 20, 0], // BTN (0), SB (10), BB (20), UTG (0)
antes: [0, 0, 0, 0],
actions: [
'd dh p1 AhKh', // BTN
'd dh p2 QhJh', // SB
'd dh p3 ThTd', // BB
'd dh p4 9h9d', // UTG
'p4 cc', // UTG calls
'p1 cc', // BTN calls
'p2 cc', // SB completes
'p3 cbr 50', // BB raises
'p4 cbr 100', // UTG raises more
'p1 cbr 50', // BTN all-in for less
'p2 cbr 100', // SB all-in exact
'p3 cc', // BB calls
],
minBet: 20,
});
// Verify button position
expect(game.buttonIndex).toBe(0); // p1 is BTN
// Check theoretical first to act for each street (purely positional)
expect(findFirstToActForStreet(game, 'preflop')).toBe(3); // UTG (3 after button)
expect(findFirstToActForStreet(game, 'flop')).toBe(1); // First after button (SB)
expect(findFirstToActForStreet(game, 'turn')).toBe(1); // First after button (SB)
expect(findFirstToActForStreet(game, 'river')).toBe(1); // First after button (SB)
// Check next eligible player for each position
expect(getNextEligiblePlayerIndex(game, 0)).toBe(2); // From BTN to BB (skip all-in SB)
expect(getNextEligiblePlayerIndex(game, 2)).toBe(3); // From BB to UTG
expect(getNextEligiblePlayerIndex(game, 3)).toBe(2); // From UTG wraps to BB (skip all-ins)
// Verify current player - after BB calls UTG's raise
expect(Game.getCurrentPlayerIndex(game)).toBe(-1); // No more action needed, ready for flop
});
it('should handle flop action with all players active', () => {
/*
* Tests player order on the flop with all players active:
* - p1 is BTN (no blind)
* - p2 is SB (10)
* - p3 is BB (20)
* - p4 is UTG
*
* Player order:
* Preflop: UTG -> BTN -> SB -> BB
* Postflop: SB -> BB -> UTG -> BTN
*
* Initial state:
* - All players called preflop
* - Flop is dealt
* - No player has acted on flop yet
*/
const game = Game({
variant: 'NT',
players: ['p1', 'p2', 'p3', 'p4'],
startingStacks: [1000, 1000, 1000, 1000],
blindsOrStraddles: [0, 10, 20, 0], // BTN (0), SB (10), BB (20), UTG (0)
antes: [0, 0, 0, 0],
actions: [
'd dh p1 AhKh', // BTN
'd dh p2 QhJh', // SB
'd dh p3 ThTd', // BB
'd dh p4 9h9d', // UTG
'p4 cc', // UTG calls
'p1 cc', // BTN calls
'p2 cc', // SB completes
'p3 cc', // BB checks
'd db AcKcQc', // Flop dealt
],
minBet: 20,
});
// Verify button position
expect(game.buttonIndex).toBe(0); // p1 is BTN
// Check first to act for each street
expect(findFirstToActForStreet(game, 'preflop')).toBe(3); // UTG acts first preflop
expect(findFirstToActForStreet(game, 'flop')).toBe(1); // SB acts first postflop
expect(findFirstToActForStreet(game, 'turn')).toBe(1); // SB acts first postflop
expect(findFirstToActForStreet(game, 'river')).toBe(1); // SB acts first postflop
// Check next eligible player for each position
expect(getNextEligiblePlayerIndex(game, 0)).toBe(1); // BTN -> SB
expect(getNextEligiblePlayerIndex(game, 1)).toBe(2); // SB -> BB
expect(getNextEligiblePlayerIndex(game, 2)).toBe(3); // BB -> UTG
expect(getNextEligiblePlayerIndex(game, 3)).toBe(0); // UTG -> BTN
// Verify current player - flop just dealt
expect(Game.getCurrentPlayerIndex(game)).toBe(1); // SB acts first on flop
expect(game.street).toBe('flop');
});
it('should handle turn action with some players folded', () => {
/*
* Tests player order on the turn after some players folded on flop:
* - p1 is BTN (no blind)
* - p2 is SB (10)
* - p3 is BB (20)
* - p4 is UTG
*
* Player order after folds:
* - BB and UTG folded on flop
* - Only BTN and SB remain
* - SB acts first postflop (first after button)
* - Action skips folded players
*
* Initial state:
* - All players called preflop
* - On flop: SB bet, BB folded, UTG folded, BTN called
* - Turn is dealt
*/
const game = Game({
variant: 'NT',
players: ['p1', 'p2', 'p3', 'p4'],
startingStacks: [1000, 1000, 1000, 1000],
blindsOrStraddles: [0, 10, 20, 0], // BTN (0), SB (10), BB (20), UTG (0)
antes: [0, 0, 0, 0],
actions: [
'd dh p1 AhKh', // BTN
'd dh p2 QhJh', // SB
'd dh p3 ThTd', // BB
'd dh p4 9h9d', // UTG
'p4 cc', // UTG calls
'p1 cc', // BTN calls
'p2 cc', // SB completes
'p3 cc', // BB checks
'd db AcKcQc', // Flop dealt
'p2 cbr 100', // SB bets
'p3 f', // BB folds
'p4 f', // UTG folds
'p1 cc', // BTN calls
'd db 7c', // Turn dealt
],
minBet: 20,
});
// Verify button position
expect(game.buttonIndex).toBe(0); // p1 is BTN
// Check first to act for each street
expect(findFirstToActForStreet(game, 'preflop')).toBe(3); // UTG acts first preflop
expect(findFirstToActForStreet(game, 'flop')).toBe(1); // SB acts first postflop
expect(findFirstToActForStreet(game, 'turn')).toBe(1); // SB acts first postflop
expect(findFirstToActForStreet(game, 'river')).toBe(1); // SB acts first postflop
// Check next eligible player for each position
expect(getNextEligiblePlayerIndex(game, 0)).toBe(1); // BTN -> SB (only active players)
expect(getNextEligiblePlayerIndex(game, 1)).toBe(0); // SB -> BTN (skip folded BB and UTG)
// Verify current player - turn just dealt
expect(Game.getCurrentPlayerIndex(game)).toBe(1); // SB acts first on turn
expect(game.street).toBe('turn');
});
it('should handle river action with multiple active players', () => {
/*
* Tests player order on the river with multiple active players:
* - p1 is BTN (no blind)
* - p2 is SB (10)
* - p3 is BB (20)
* - p4 is UTG
*
* Player order:
* Preflop: UTG -> BTN -> SB -> BB
* Postflop: SB -> BB -> UTG -> BTN
*
* Initial state:
* - All players called preflop
* - All players checked flop
* - All players checked turn
* - River just dealt
* - No player has acted on river yet
*/
const game = Game({
variant: 'NT',
players: ['p1', 'p2', 'p3', 'p4'],
startingStacks: [1000, 1000, 1000, 1000],
blindsOrStraddles: [0, 10, 20, 0], // BTN (0), SB (10), BB (20), UTG (0)
antes: [0, 0, 0, 0],
actions: [
'd dh p1 AhKh', // BTN
'd dh p2 QhJh', // SB
'd dh p3 ThTd', // BB
'd dh p4 9h9d', // UTG
'p4 cc', // UTG calls
'p1 cc', // BTN calls
'p2 cc', // SB completes
'p3 cc', // BB checks
'd db AcKcQc', // Flop dealt
'p2 cc', // SB checks
'p3 cc', // BB checks
'p4 cc', // UTG checks
'p1 cc', // BTN checks
'd db 7c', // Turn dealt
'p2 cc', // SB checks
'p3 cc', // BB checks
'p4 cc', // UTG checks
'p1 cc', // BTN checks
'd db 2h', // River dealt
],
minBet: 20,
});
// Verify button position
expect(game.buttonIndex).toBe(0); // p1 is BTN
// Check first to act for each street
expect(findFirstToActForStreet(game, 'preflop')).toBe(3); // UTG acts first preflop
expect(findFirstToActForStreet(game, 'flop')).toBe(1); // SB acts first postflop
expect(findFirstToActForStreet(game, 'turn')).toBe(1); // SB acts first postflop
expect(findFirstToActForStreet(game, 'river')).toBe(1); // SB acts first postflop
// Check next eligible player for each position
expect(getNextEligiblePlayerIndex(game, 0)).toBe(1); // BTN -> SB
expect(getNextEligiblePlayerIndex(game, 1)).toBe(2); // SB -> BB
expect(getNextEligiblePlayerIndex(game, 2)).toBe(3); // BB -> UTG
expect(getNextEligiblePlayerIndex(game, 3)).toBe(0); // UTG -> BTN
// Verify current player - river just dealt
expect(Game.getCurrentPlayerIndex(game)).toBe(1); // SB acts first on river
expect(game.street).toBe('river');
});
it('should handle flop action with multiple all-ins', () => {
/*
* Tests player order when multiple players go all-in on the flop:
* Seat order: BTN -> SB -> BB -> UTG
* - p1 is BTN (no blind)
* - p2 is SB (10) - stack 500
* - p3 is BB (20) - stack 200
* - p4 is UTG - stack 100
*
* Initial state:
* - All players called preflop
* - Flop is dealt
* - SB went all-in for 480
* - BB went all-in for less (180)
* - UTG went all-in for even less (80)
* - BTN has not acted yet
*/
const game = Game({
variant: 'NT',
players: ['p1', 'p2', 'p3', 'p4'],
startingStacks: [1000, 500, 200, 100],
blindsOrStraddles: [0, 10, 20, 0], // BTN (0), SB (10), BB (20), UTG (0)
antes: [0, 0, 0, 0],
actions: [
'd dh p1 AhKh', // BTN
'd dh p2 QhJh', // SB
'd dh p3 ThTd', // BB
'd dh p4 9h9d', // UTG
'p4 cc', // UTG calls
'p1 cc', // BTN calls
'p2 cc', // SB completes
'p3 cc', // BB checks
'd db AcKcQc', // Flop dealt
'p2 cbr 480', // SB all-in (+460)
'p3 cbr 180', // BB all-in for less
'p4 cbr 80', // UTG all-in for even less
],
minBet: 20,
});
// Verify button position
expect(game.buttonIndex).toBe(0); // p1 is BTN
// Check theoretical first to act for each street (purely positional)
expect(findFirstToActForStreet(game, 'preflop')).toBe(3); // UTG (3 after button)
expect(findFirstToActForStreet(game, 'flop')).toBe(1); // First after button (SB)
expect(findFirstToActForStreet(game, 'turn')).toBe(1); // First after button (SB)
expect(findFirstToActForStreet(game, 'river')).toBe(1); // First after button (SB)
// Check next eligible player considering all-ins
expect(getNextEligiblePlayerIndex(game, 0)).toBe(-1); // BTN has no next player (all others all-in)
expect(getNextEligiblePlayerIndex(game, 1)).toBe(0); // SB -> BTN (only non-all-in)
expect(getNextEligiblePlayerIndex(game, 2)).toBe(0); // BB -> BTN (only non-all-in)
expect(getNextEligiblePlayerIndex(game, 3)).toBe(0); // UTG -> BTN (only non-all-in)
// Verify current player - after multiple all-ins
expect(Game.getCurrentPlayerIndex(game)).toBe(0); // BTN to act on all-ins
expect(game.street).toBe('flop');
});
it('should handle heads-up preflop action with raises', () => {
/*
* Tests heads-up preflop dynamics with raises:
* Seat order: BTN/SB -> BB
* - p1 is BTN/SB (10)
* - p2 is BB (20)
*
* Action sequence:
* - BTN/SB raises to 60
* - BB re-raises to 120
* - BTN/SB calls
* - Flop is dealt
*/
const game = Game({
variant: 'NT',
players: ['p1', 'p2'],
startingStacks: [1000, 1000],
blindsOrStraddles: [10, 20], // p1 is SB (10), p2 is BB (20)
antes: [0, 0],
actions: [
'd dh p1 AhKh', // BTN/SB
'd dh p2 QhJh', // BB
'p1 cbr 60', // BTN/SB raises
'p2 cbr 120', // BB re-raises
'p1 cc', // BTN/SB calls
'd db AcKcQc', // Flop dealt
],
minBet: 20,
});
// Verify button position
expect(game.buttonIndex).toBe(0); // p1 is BTN/SB
// Check theoretical first to act for each street (purely positional)
expect(findFirstToActForStreet(game, 'preflop')).toBe(0); // BTN/SB acts first in heads-up preflop
expect(findFirstToActForStreet(game, 'flop')).toBe(1); // First after button (BB)
expect(findFirstToActForStreet(game, 'turn')).toBe(1); // First after button (BB)
expect(findFirstToActForStreet(game, 'river')).toBe(1); // First after button (BB)
// Check next eligible player for each position
expect(getNextEligiblePlayerIndex(game, 0)).toBe(1); // From BTN/SB to BB
expect(getNextEligiblePlayerIndex(game, 1)).toBe(0); // From BB wraps to BTN/SB
// Verify current player - flop just dealt
expect(Game.getCurrentPlayerIndex(game)).toBe(1); // BB acts first postflop
expect(game.street).toBe('flop');
});
});
it('should continue dealing after flop in all-in situations', () => {
// Setup a game with 3 players, different stack depths
const hand: Hand = {
variant: 'NT',
players: ['p1', 'p2', 'p3'],
startingStacks: [50, 100, 200],
blindsOrStraddles: [0, 10, 20],
antes: [],
minBet: 20,
actions: [],
};
// Initial actions up to flop
const preFlop = [
'd dh p1 3h9d',
'd dh p2 Ks9h',
'd dh p3 7hQh',
'p1 cbr 50', // BTN all-in
'p2 cbr 100', // SB all-in
'p3 cc', // BB calls
] as Action[];
// Create game state after preflop
let game = Game(hand, preFlop);
// Verify preflop state is correct for dealer to act
expect(game.isBettingComplete).toBe(true);
expect(game.players.map(p => p.isAllIn)).toEqual([true, true, false]);
expect(game.street).toBe('preflop');
// Add flop action
const withFlop = [...preFlop, 'd db 6d8dTc'] as Action[];
game = Game(hand, withFlop);
// Key test: After flop is dealt, dealer should still need to act
expect(game.street).toBe('flop');
expect(game.board).toHaveLength(3);
expect(isAwaitingDealer(game)).toBe(true); // This should be true but is false
});
});