@idealic/poker-engine
Version:
Poker game engine and hand evaluator
624 lines (530 loc) • 20.1 kB
text/typescript
import { describe, expect, it } from 'vitest';
import * as Poker from '../../../index';
import type { Action } from '../../../types';
import { BASE_HAND, MINIMAL_HAND } from './fixtures/baseGame';
describe('Game API - Edge Cases', () => {
describe('Extreme Stack Sizes', () => {
it('should handle zero starting stacks', () => {
const hand: Poker.Hand = {
...BASE_HAND,
startingStacks: [5, 1000, 0, 1000],
};
// game constructor should throw
// because player with chips 0 or lower than big blind CAN'T act
expect(() => Poker.Game(hand)).toThrow();
});
it('should handle negative starting stacks', () => {
const hand: Poker.Hand = {
...BASE_HAND,
startingStacks: [-5, -1000, 0, 1000],
};
// game constructor should throw
// because player with chips negative CAN'T act
expect(() => Poker.Game(hand)).toThrow();
});
it('should handle fractional stacks if allowed', () => {
const hand: Poker.Hand = {
...BASE_HAND,
startingStacks: [100.5, 200.25, 300.75, 400.1],
};
const game = Poker.Game(hand);
expect(game.players[0].stack).toBe(90.5);
expect(game.players[1].stack).toBe(180.25);
});
});
describe('Player Count Extremes', () => {
it('should set isValid=false for single player game', () => {
const hand: Poker.Hand = {
variant: 'NT',
players: ['Lonely'],
startingStacks: [1000],
blindsOrStraddles: [0],
minBet: 20,
antes: [0],
actions: [],
};
const game = Poker.Game(hand);
expect(game.isPlayable).toBe(false);
});
it('should set isValid=true for normal game', () => {
const game = Poker.Game(BASE_HAND);
expect(game.isPlayable).toBe(true);
});
it('should throw when applying action with less than 2 active players', () => {
const hand: Poker.Hand = {
variant: 'NT',
players: ['Lonely'],
startingStacks: [1000],
blindsOrStraddles: [0],
minBet: 20,
antes: [0],
actions: [],
};
const game = Poker.Game(hand);
expect(game.isPlayable).toBe(false);
// Only 1 player, so activePlayers.length < 2
// Trying to apply an action should throw
const action = 'p1 cc' as Action;
expect(() => Poker.Game.applyAction(game, action)).toThrowError(
/Cannot apply action to invalid game/
);
});
it('should throw when applying action after everyone else folded (1 active player)', () => {
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', // UTG folds
'p4 f', // BTN folds
'p1 f', // SB folds
// p2 wins, hand complete
] as Action[],
};
const game = Poker.Game(hand);
// game started valid but now has < 2 active players
expect(game.isPlayable).toBe(true);
// Try to apply an action for p2
expect(() => Poker.Game.applyAction(game, 'p2 cc')).toThrowError(
/Cannot apply action to invalid game/
);
});
it('should handle maximum players (9)', () => {
const hand: Poker.Hand = {
variant: 'NT',
players: ['P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'P8', 'P9'],
startingStacks: [1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000],
blindsOrStraddles: [0, 0, 0, 0, 0, 0, 0, 10, 20],
antes: [0, 0, 0, 0, 0, 0, 0, 0, 0],
minBet: 20,
actions: [],
};
const game = Poker.Game(hand);
expect(game.players).toHaveLength(9);
});
it('should throw when players exceed default seat count (9)', () => {
const hand: Poker.Hand = {
variant: 'NT',
players: ['P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'P8', 'P9', 'P10'],
startingStacks: [1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000],
blindsOrStraddles: [0, 0, 0, 0, 0, 0, 0, 10, 20, 0],
antes: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
minBet: 20,
actions: [],
};
expect(() => Poker.Game(hand)).toThrowError(/Game cannot have more players than seats/);
});
it('should throw when players exceed explicit seat count', () => {
const hand: Poker.Hand = {
variant: 'NT',
players: ['P1', 'P2', 'P3', 'P4', 'P5', 'P6'],
startingStacks: [1000, 1000, 1000, 1000, 1000, 1000],
blindsOrStraddles: [0, 0, 0, 0, 10, 20],
antes: [0, 0, 0, 0, 0, 0],
minBet: 20,
seatCount: 5, // Limit to 5
actions: [],
};
expect(() => Poker.Game(hand)).toThrowError(/Game cannot have more players than seats/);
});
});
describe('Action Sequence Edge Cases', () => {
it('should handle empty actions array', () => {
const hand: Poker.Hand = {
...BASE_HAND,
actions: [],
};
const game = Poker.Game(hand);
expect(game).toBeDefined();
expect(game.board).toEqual([]);
expect(game.street).toBe('preflop');
});
it('should handle actions array with only dealer actions', () => {
const hand: Poker.Hand = {
...BASE_HAND,
actions: ['d dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd'],
};
const game = Poker.Game(hand);
expect(game.players[0].cards).toEqual(['As', 'Ks']);
expect(game.street).toBe('preflop');
expect(game.nextPlayerIndex).toBeGreaterThanOrEqual(0);
});
it('should handle incomplete action sequences', () => {
const hand: Poker.Hand = {
...BASE_HAND,
actions: [
'd dh p1 AsKs',
'd dh p2 7h2d',
// Missing p3 and p4 hole cards
'p3 cbr 60',
],
};
// Should either handle gracefully or throw
expect(() => Poker.Game(hand)).toBeDefined();
});
it('should handle duplicate actions', () => {
const hand: Poker.Hand = {
...BASE_HAND,
actions: [
'd dh p1 AsKs',
'd dh p1 AsKs', // Duplicate
'd dh p2 7h2d',
],
};
// Should handle or reject duplicates
const game = Poker.Game(hand);
expect(game.players[0].cards).toEqual(['As', 'Ks']);
});
it('should report illegal move when raising less than min-raise', () => {
const hand: Poker.Hand = {
...BASE_HAND,
actions: ['d dh p1 AsKs', 'd dh p2 7h2d', 'd dh p3 QhQc', 'd dh p4 JdTd'],
startingStacks: [1000, 1500, 30, 1200],
};
const game = Poker.Game(hand);
// BB is 20. Min raise is to 40.
const illegalAction: Action = 'p3 cbr 29';
// Attempting to raise less than min (29) when stack allows more should be invalid
expect(Poker.Game.canApplyAction(game, illegalAction)).toBe(false);
// 30 is fine since it's the whole stack - meaning going all in
expect(Poker.Game.canApplyAction(game, 'p3 cbr 30')).toBe(true);
});
});
describe('Illegal Moves', () => {
it('should throw when capped player attempts to raise', () => {
const cappedHand: Poker.Hand = {
...BASE_HAND,
startingStacks: [1000, 1000, 120, 1000],
blindsOrStraddles: [0, 0, 10, 20],
players: ['P1', 'P2', 'P3', 'P4'],
actions: [
'd dh p1 AsKs',
'd dh p2 7h2d',
'd dh p3 QhQc',
'd dh p4 JdTd',
'p1 cbr 100',
'p2 cc',
'p3 cbr 120', // all-in, incomplete raise
'p4 f',
// Action back on P1. P1 is capped.
],
};
const game = Poker.Game(cappedHand);
expect(game.nextPlayerIndex).toBe(0);
// P1 tries to raise to 200 (Illegal)
const illegalAction = 'p1 cbr 200' as Action;
// Ensure canApplyAction returns false
expect(Poker.Game.canApplyAction(game, illegalAction)).toBe(false);
// Ensure applyAction throws
// NOTE: This expectation might fail if the engine is permissive.
// If it fails, we need to decide whether to enforce strictness in engine or update test.
// The requirement was "it should throw".
expect(() => Poker.Game.applyAction(game, illegalAction)).toThrow();
});
});
describe('Blind Structure Edge Cases', () => {
it('should handle no blinds, should throw', () => {
const hand: Poker.Hand = {
...BASE_HAND,
blindsOrStraddles: [0, 0, 0, 0],
};
// this game variant MUST have blinds
expect(() => Poker.Game(hand)).toThrow();
});
it('should handle unusual blind positions, should throw', () => {
const hand: Poker.Hand = {
...BASE_HAND,
blindsOrStraddles: [0, 10, 0, 20], // Blinds not in typical positions
};
// this game variant MUST have blinds in typical positions
expect(() => Poker.Game(hand)).toThrow();
});
});
describe('Card Dealing Edge Cases', () => {
it('should handle invalid card notations gracefully', () => {
const hand: Poker.Hand = {
...BASE_HAND,
actions: [
'd dh p1 XxYy' as Action, // Invalid cards
],
};
let game: Poker.Game;
// Should either handle gracefully or throw
expect(() => (game = Poker.Game(hand))).not.toThrow();
expect(game!.players[0].cards).toEqual(['Xx', 'Yy']);
});
it('should handle duplicate cards in deck', () => {
const hand: Poker.Hand = {
...BASE_HAND,
actions: [
'd dh p1 AsKs',
'd dh p2 AsKs', // Same cards as p1
],
};
let game: Poker.Game;
// Do not check for duplicate cards, any cards is OK
expect(() => (game = Poker.Game(hand))).not.toThrow();
expect(game!.players[0].cards).toEqual(['As', 'Ks']);
expect(game!.players[1].cards).toEqual(['As', 'Ks']);
});
it('should handle too many board cards, should throw', () => {
const hand: Poker.Hand = {
...BASE_HAND,
actions: [
...BASE_HAND.actions,
'd db Td',
'd db 9s',
'd db 8h', // 6th card - invalid
],
};
// Should have max 5 board cards
expect(() => Poker.Game(hand)).toThrow();
});
});
describe('Timing Edge Cases', () => {
it('should handle negative time limits', () => {
const hand: Poker.Hand = {
...BASE_HAND,
timeLimit: -30, // negative time limit is like no time limit
};
const game = Poker.Game(hand);
expect(Poker.Game.getTimeLeft(game)).toBe(Infinity);
});
it('should handle zero time limit', () => {
const hand: Poker.Hand = {
...BASE_HAND,
timeLimit: 0,
};
const game = Poker.Game(hand);
expect(Poker.Game.getTimeLeft(game)).toBe(Infinity);
});
it('should handle very large time limits', () => {
const hand: Poker.Hand = {
...BASE_HAND,
timeLimit: Number.MAX_SAFE_INTEGER,
};
const game = Poker.Game(hand);
const timeLeft = Poker.Game.getTimeLeft(game);
expect(timeLeft).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER * 1000);
});
it('should handle timestamps in the future', () => {
const futureTime = Date.now() + 1000000;
const hand: Poker.Hand = {
...BASE_HAND,
actions: [`d dh p1 AsKs #${futureTime}`],
};
const game = Poker.Game(hand);
expect(game.lastTimestamp).toBe(futureTime);
});
it('should handle very old timestamps', () => {
const oldTime = 0; // Unix epoch
const hand: Poker.Hand = {
...BASE_HAND,
actions: [`d dh p1 AsKs #${oldTime}`],
};
const now = Date.now(); // invalid timestamp is being replaced with current time
const game = Poker.Game(hand);
expect(game.lastTimestamp).toBeGreaterThan(now - 1000);
expect(game.lastTimestamp).toBeLessThan(now + 1000);
});
});
describe('Special Characters in Player Names', () => {
it('should handle unicode characters', () => {
const hand: Poker.Hand = {
...BASE_HAND,
players: ['Alice', '🎮Player', '中文名', 'Пользователь'],
};
const game = Poker.Game(hand);
expect(game.players[1].name).toBe('🎮Player');
expect(game.players[2].name).toBe('中文名');
expect(Poker.Game.getPlayerIndex(game, '🎮Player')).toBe(1);
});
it('should handle empty player names', () => {
const hand: Poker.Hand = {
...BASE_HAND,
players: ['', 'Bob', 'Ch arlie', 'David'],
};
const game = Poker.Game(hand);
expect(game.players[0].name).toBe('');
expect(Poker.Game.getPlayerIndex(game, '')).toBe(0);
});
it('should handle very long player names', () => {
const longName = 'A'.repeat(1000);
const hand: Poker.Hand = {
...BASE_HAND,
players: [longName, 'Bob', 'Charlie', 'David'],
};
const game = Poker.Game(hand);
expect(game.players[0].name).toBe(longName);
expect(Poker.Game.getPlayerIndex(game, longName)).toBe(0);
});
});
describe('Rake Edge Cases', () => {
it('should handle 100% rake', () => {
const hand: Poker.Hand = {
...BASE_HAND,
rakePercentage: 1.0,
};
const game = Poker.Game(hand);
const finished = Poker.Game.finish(game, hand);
if (finished.rake !== undefined) {
// All pot goes to rake
expect(finished.rake).toBeLessThanOrEqual(finished.totalPot || 0);
}
});
it('should handle negative rake', () => {
const hand: Poker.Hand = {
...BASE_HAND,
rakePercentage: -0.1,
};
const game = Poker.Game(hand);
const finished = Poker.Game.finish(game, hand);
if (finished.rake !== undefined) {
// Should treat as 0 or handle specially
expect(finished.rake).toBeGreaterThanOrEqual(0);
}
});
it('should handle fixed rake amount', () => {
const hand: Poker.Hand = {
...BASE_HAND,
rake: 5,
rakePercentage: undefined,
};
const game = Poker.Game(hand);
const finished = Poker.Game.finish(game, hand);
if (finished.rake !== undefined) {
expect(finished.rake).toBe(5);
}
});
});
describe('Action Validation Edge Cases', () => {
it('should handle malformed action strings', () => {
const game = Poker.Game(BASE_HAND);
// Various malformed actions
//expect(Poker.Game.canApplyAction(game, '' as Action)).toBe(false);
//expect(Poker.Game.canApplyAction(game, ' ' as Action)).toBe(false);
//expect(Poker.Game.canApplyAction(game, 'p' as Action)).toBe(false);
expect(Poker.Game.canApplyAction(game, 'p3' as Action)).toBe(false);
//expect(Poker.Game.canApplyAction(game, 'cbr 100' as Action)).toBe(false);
//expect(Poker.Game.canApplyAction(game, '3 cbr 100' as Action)).toBe(false);
});
it('should handle out-of-range player indices', () => {
const game = Poker.Game(BASE_HAND);
expect(Poker.Game.canApplyAction(game, 'p0 cc' as Action)).toBe(false);
expect(Poker.Game.canApplyAction(game, 'p5 cc' as Action)).toBe(false);
expect(Poker.Game.canApplyAction(game, 'p-1 cc' as Action)).toBe(false);
expect(Poker.Game.canApplyAction(game, 'p99 cc' as Action)).toBe(false);
});
it('should handle invalid action types', () => {
const game = Poker.Game(BASE_HAND);
expect(Poker.Game.canApplyAction(game, 'p3 xyz' as Action)).toBe(false);
expect(Poker.Game.canApplyAction(game, 'p3 call' as Action)).toBe(false);
expect(Poker.Game.canApplyAction(game, 'p3 raise 100' as Action)).toBe(false);
expect(Poker.Game.canApplyAction(game, 'p3 CHECK' as Action)).toBe(false);
});
it('should handle invalid amounts', () => {
const game = Poker.Game(BASE_HAND);
expect(Poker.Game.canApplyAction(game, 'p3 cbr -100' as Action)).toBe(false);
expect(Poker.Game.canApplyAction(game, 'p3 cbr 0' as Action)).toBe(false);
// Non-numeric or infinite should be rejected too if strict, but previous behavior accepted them?
// getActionAmount returns 0 for NaN/invalid.
// And 0 < bigBlind, so it should be rejected now.
expect(Poker.Game.canApplyAction(game, 'p3 cbr abc' as Action)).toBe(false);
// Infinity handling: getActionAmount might return Infinity or 0?
// parseInt('Infinity') is NaN -> 0. So rejected.
expect(Poker.Game.canApplyAction(game, 'p3 cbr Infinity' as Action)).toBe(false);
expect(Poker.Game.canApplyAction(game, 'p3 cbr NaN' as Action)).toBe(false);
});
});
describe('State Recovery Edge Cases', () => {
it('should handle contradictory state', () => {
const hand: Poker.Hand = {
...BASE_HAND,
actions: [
'd dh p1 AsKs',
'd dh p2 7h2d',
'p1 f', // Alice folds
'p1 cbr 100', // Alice bets after folding - contradiction
] as Action[],
};
// Should handle or reject contradictory actions
expect(() => Poker.Game(hand)).toBeDefined();
});
it('should handle actions after hand complete', () => {
const hand: Poker.Hand = {
...MINIMAL_HAND,
actions: [
'd dh p1 AsKs',
'd dh p2 7h2d',
'p1 cbr 100',
'p2 f', // Hand complete
'p1 cc', // Action after complete
] as Action[],
};
// Should handle or ignore actions after completion
expect(() => Poker.Game(hand)).toBeDefined();
});
});
describe('Null and Undefined Handling', () => {
it('should handle undefined optional fields', () => {
const hand: Poker.Hand = {
variant: 'NT',
players: ['Alice', 'Bob'],
startingStacks: [1000, 1000],
antes: [0, 0],
blindsOrStraddles: [10, 20],
minBet: 20,
actions: [],
// All optional fields undefined
seed: undefined,
timeLimit: undefined,
rake: undefined,
rakePercentage: undefined,
currency: undefined,
author: undefined,
};
const game = Poker.Game(hand);
expect(game).toBeDefined();
expect(Poker.Game.getTimeLeft(game)).toBe(Infinity);
});
it('should handle null values gracefully', () => {
const game = Poker.Game(BASE_HAND);
// Various null/undefined inputs
expect(Poker.Game.getPlayerIndex(game, null as any)).toBe(-1);
expect(Poker.Game.getPlayerIndex(game, undefined as any)).toBe(-1);
expect(Poker.Game.hasActed(game, null as any)).toBe(false);
expect(Poker.Game.hasActed(game, undefined as any)).toBe(false);
expect(Poker.Game.canApplyAction(game, null as any)).toBe(false);
expect(Poker.Game.canApplyAction(game, undefined as any)).toBe(false);
});
});
describe('Concurrent Modifications', () => {
it('should handle rapid successive actions', () => {
let game = Poker.Game(BASE_HAND);
// Simulate rapid action application
const actions: Action[] = ['p3 cc', 'p4 cc'];
for (const action of actions) {
if (Poker.Game.canApplyAction(game, action)) {
game = Poker.Game.applyAction(game, action);
}
}
expect(game).toBeDefined();
expect(game.street).toBeDefined();
});
it('should maintain consistency across multiple operations', () => {
const game = Poker.Game(BASE_HAND);
// Multiple query operations shouldn't affect each other
const index1 = Poker.Game.getPlayerIndex(game, 'Alice');
const showdown1 = game.isShowdown;
const hasActed1 = Poker.Game.hasActed(game, 'Charlie');
const index2 = Poker.Game.getPlayerIndex(game, 'Alice');
const showdown2 = game.isShowdown;
const hasActed2 = Poker.Game.hasActed(game, 'Charlie');
expect(index1).toBe(index2);
expect(showdown1).toBe(showdown2);
expect(hasActed1).toBe(hasActed2);
});
});
});