@idealic/poker-engine
Version:
Poker game engine and hand evaluator
1,496 lines (1,261 loc) • 98.8 kB
text/typescript
import { beforeEach, describe, expect, it } from 'vitest';
import { Hand } from '../../../Hand';
import type { NoLimitHand } from '../../../types';
describe('Hand.joinHand - Player joining table', () => {
let baseHand: NoLimitHand;
let emptyTableHand: NoLimitHand;
let midGameHand: NoLimitHand;
beforeEach(() => {
// Setup base hand for testing
baseHand = {
variant: 'NT',
players: ['Alice', 'Bob'],
startingStacks: [100, 100],
blindsOrStraddles: [1, 2],
antes: [0, 0],
minBet: 2,
seatCount: 6,
actions: [],
};
emptyTableHand = {
variant: 'NT',
players: [],
startingStacks: [],
blindsOrStraddles: [],
antes: [],
minBet: 2,
seatCount: 6,
actions: [],
};
midGameHand = {
...baseHand,
actions: ['d dh p1 AsKs', 'd dh p2 7c7d', 'p1 cc', 'p2 cbr 10'],
};
});
describe('when player joins', () => {
it('should add new player to all player-related arrays', () => {
// Scenario: Charlie joins a 2-player table
// Input: baseHand with Alice and Bob, Charlie wants to join with 150 chips
// Expected: All arrays expanded to include Charlie, _intents: [0, 0, 0]
const player = {
playerName: 'Charlie',
buyIn: 150,
};
const result = Hand.join(baseHand, player);
expect(result.players).toEqual(['Alice', 'Bob', 'Charlie']);
expect(result.startingStacks).toEqual([100, 100, 150]);
expect(result.blindsOrStraddles).toEqual([1, 2, 0]);
expect(result.antes).toEqual([0, 0, 0]);
expect(result._intents).toEqual([0, 0, 0]);
expect(result._inactive).toEqual([0, 0, 2]);
// Client sets _inactive for local operations, but server retains control via merge()
});
it('should preserve existing player data', () => {
// Scenario: New player joins without affecting existing players
// Input: Table with Alice (100) and Bob (100), Charlie joins with 150
// Expected: Alice and Bob data unchanged, Charlie added correctly
const player = {
playerName: 'Charlie',
buyIn: 150,
};
const result = Hand.join(baseHand, player);
// Original players unchanged
expect(result.players[0]).toBe('Alice');
expect(result.players[1]).toBe('Bob');
expect(result.startingStacks[0]).toBe(100);
expect(result.startingStacks[1]).toBe(100);
// New player added
expect(result.players[2]).toBe('Charlie');
expect(result.startingStacks[2]).toBe(150);
});
it('should set new player as inactive (state 2) in _inactive field', () => {
// Scenario: Client adds new player, sets _inactive locally
// Input: joinHand with player data, existing _inactive: [0, 0]
// Expected: _inactive expanded with new player marked as inactive (2)
const handWithInactive = {
...baseHand,
_inactive: [0, 0],
};
const player = {
playerName: 'Charlie',
buyIn: 100,
};
const result = Hand.join(handWithInactive, player);
// _inactive expanded with new player marked as inactive (state 2)
expect(result._inactive).toEqual([0, 0, 2]);
// _intents is set to 0 for new player (ready to play)
expect(result._intents).toEqual([0, 0, 0]);
});
it('should expand _deadBlinds to match _inactive length', () => {
// Scenario: Client adds new player, _deadBlinds must match _inactive length
// Input: joinHand with player data
// Expected: _deadBlinds expanded with 0 for new player (validation requirement)
const handWithDeadBlinds = {
...baseHand,
_deadBlinds: [5, 10],
};
const player = {
playerName: 'Charlie',
buyIn: 100,
};
const result = Hand.join(handWithDeadBlinds, player);
// _deadBlinds expanded to match _inactive array length
expect(result._deadBlinds).toEqual([5, 10, 0]);
expect(result._intents).toEqual([0, 0, 0]);
});
it('should maintain immutability of original hand', () => {
// Scenario: Ensure input hand is not mutated
// Input: baseHand passed to joinHand
// Expected: baseHand remains unchanged, new hand returned
const originalPlayers = [...baseHand.players];
const originalStacks = [...baseHand.startingStacks];
const player = {
playerName: 'Charlie',
buyIn: 150,
};
const result = Hand.join(baseHand, player);
// Original hand unchanged
expect(baseHand.players).toEqual(originalPlayers);
expect(baseHand.startingStacks).toEqual(originalStacks);
// New hand has changes
expect(result.players.length).toBe(3);
expect(result).not.toBe(baseHand);
});
});
describe('when configuring player data', () => {
it('should accept custom buy-in amount', () => {
// Scenario: Player specifies desired buy-in
// Input: player object with buyIn: 250
// Expected: startingStacks includes 250 for new player
const player = {
playerName: 'Charlie',
buyIn: 250,
};
const result = Hand.join(baseHand, player);
expect(result.startingStacks).toEqual([100, 100, 250]);
expect(result.players).toEqual(['Alice', 'Bob', 'Charlie']);
});
it('should accept seat selection preference', () => {
// Scenario: Player wants specific seat
// Input: player object with seat: 4 on 6-max table
// Expected: seats array includes seat 4 for new player
const handWithSeats = {
...baseHand,
seats: [1, 2],
};
const player = {
playerName: 'Charlie',
buyIn: 100,
seat: 4,
};
const result = Hand.join(handWithSeats, player);
expect(result.seats).toEqual([1, 2, 4]);
expect(result.players).toEqual(['Alice', 'Bob', 'Charlie']);
});
it('should accept venue ID for new player', () => {
// Scenario: Player has venue-specific ID
// Input: player object with venueId: 'user123'
// Expected: _venueIds array includes 'user123'
// Note: venueId is managed by server, not provided in JoinHand
const handWithVenueIds = {
...baseHand,
_venueIds: ['alice-id', 'bob-id'],
};
const player = {
playerName: 'Charlie',
buyIn: 100,
};
const result = Hand.join(handWithVenueIds, player);
// Client can't set venue ID, server will handle it
expect(result._venueIds).toEqual(['alice-id', 'bob-id']);
expect(result.players).toEqual(['Alice', 'Bob', 'Charlie']);
});
it('should use player name as provided', () => {
// Scenario: Player joins with specific username
// Input: player object with name: 'Charlie'
// Expected: players array includes 'Charlie'
const player = {
playerName: 'CharliePoker123',
buyIn: 100,
};
const result = Hand.join(baseHand, player);
expect(result.players).toEqual(['Alice', 'Bob', 'CharliePoker123']);
expect(result.players[2]).toBe('CharliePoker123');
});
});
describe('when arrays need initialization', () => {
it('should create _intents array if missing', () => {
// Scenario: Legacy hand without _intents field
// Input: baseHand without _intents, new player joining
// Expected: _intents created as [0, 0, 0]
const legacyHand = {
variant: 'NT' as const,
players: ['Alice', 'Bob'],
startingStacks: [100, 100],
blindsOrStraddles: [1, 2],
antes: [0, 0],
minBet: 2,
seatCount: 6,
actions: [],
// No _intents field
};
const player = {
playerName: 'Charlie',
buyIn: 100,
};
const result = Hand.join(legacyHand, player);
expect(result._intents).toBeDefined();
expect(result._intents).toEqual([0, 0, 0]);
});
it('should expand existing _intents array', () => {
// Scenario: Hand with existing _intents
// Input: baseHand with _intents: [0, 2], new player joining
// Expected: _intents becomes [0, 2, 0]
const handWithIntents = {
...baseHand,
_intents: [0, 2], // Bob is paused
};
const player = {
playerName: 'Charlie',
buyIn: 100,
};
const result = Hand.join(handWithIntents, player);
expect(result._intents).toEqual([0, 2, 0]);
expect(result.players).toEqual(['Alice', 'Bob', 'Charlie']);
});
it('should handle missing antes array', () => {
// Scenario: Hand without antes defined
// Input: baseHand without antes field
// Expected: antes created with zeros for all players
const handWithoutAntes: Hand = {
variant: 'NT' as const,
players: ['Alice', 'Bob'],
startingStacks: [100, 100],
blindsOrStraddles: [1, 2],
// No antes
minBet: 2,
seatCount: 6,
actions: [],
} as unknown as Hand; // podhak
const player = {
playerName: 'Charlie',
buyIn: 100,
};
const result = Hand.join(handWithoutAntes, player);
expect(result.antes).toBeDefined();
expect(result.antes).toEqual([0, 0, 0]);
});
it('should handle missing seats array', () => {
// Scenario: Hand without seat assignments
// Input: baseHand without seats field
// Expected: Either seats remain undefined or created if player specifies seat
const player = {
playerName: 'Charlie',
buyIn: 100,
seat: 5,
};
const result = Hand.join(baseHand, player);
// If player specifies seat, create seats array
expect(result.seats).toBeDefined();
expect(result.seats).toContain(5);
});
it('should handle missing _venueIds array', () => {
// Scenario: Hand without venue IDs
// Input: baseHand without _venueIds
// Expected: _venueIds created only if player provides venueId
const player = {
playerName: 'Charlie',
buyIn: 100,
};
const result = Hand.join(baseHand, player);
// _venueIds handled by server, not created by client
expect(result._venueIds).toBeUndefined();
});
});
describe('when validation fails', () => {
it('should reject if player name is missing', () => {
// Scenario: Invalid player object without name
// Input: player object with no name field
// Expected: hand returned unchanged
const player = {
playerName: '', // Empty name
buyIn: 100,
};
const result = Hand.join(baseHand, player);
// Should return unchanged hand
expect(result).toBe(baseHand);
expect(result.players).toEqual(['Alice', 'Bob']);
});
it('should reject negative buy-in amount', () => {
// Scenario: Invalid buy-in amount
// Input: player object with buyIn: -50
// Expected: hand returned unchanged
const player = {
playerName: 'Charlie',
buyIn: -50,
};
const result = Hand.join(baseHand, player);
// Should return unchanged hand
expect(result).toBe(baseHand);
expect(result.players).toEqual(['Alice', 'Bob']);
});
it('should reject duplicate player names', () => {
// Scenario: Player name already exists
// Input: Try to add 'Alice' when Alice already playing
// Expected: hand returned unchanged
const player = {
playerName: 'Alice', // Already exists
buyIn: 100,
};
const result = Hand.join(baseHand, player);
// Should return unchanged hand
expect(result).toBe(baseHand);
expect(result.players).toEqual(['Alice', 'Bob']);
expect(result.players.length).toBe(2);
});
it('should reject seat outside table limits', () => {
// Scenario: Invalid seat selection
// Input: seat: 7 on 6-max table
// Expected: hand returned unchanged
const player = {
playerName: 'Charlie',
buyIn: 100,
seat: 7, // Out of bounds for 6-max
};
const result = Hand.join(baseHand, player);
// Should return unchanged hand
expect(result).toBe(baseHand);
expect(result.players).toEqual(['Alice', 'Bob']);
expect(result.seats).toBeUndefined();
});
it('should reject duplicate seat selection', () => {
// Scenario: Seat already occupied
// Input: seats: [1, 3], new player wants seat 3
// Expected: hand returned unchanged
const handWithSeats = {
...baseHand,
seats: [1, 3],
};
const player = {
playerName: 'Charlie',
buyIn: 100,
seat: 3, // Already occupied by Bob
};
const result = Hand.join(handWithSeats, player);
// Should return unchanged hand
expect(result).toBe(handWithSeats);
expect(result.players).toEqual(['Alice', 'Bob']);
expect(result.seats).toEqual([1, 3]);
});
it('should reject if table is full', () => {
// Scenario: Table at maximum capacity
// Input: 6 players on 6-max table, 7th trying to join
// Expected: hand returned unchanged
const fullTableHand = {
...baseHand,
players: ['P1', 'P2', 'P3', 'P4', 'P5', 'P6'],
startingStacks: [100, 100, 100, 100, 100, 100],
blindsOrStraddles: [0, 0, 0, 0, 10, 20],
antes: [0, 0, 0, 0, 0, 0],
seatCount: 6,
};
const player = {
playerName: 'P7',
buyIn: 100,
};
const result = Hand.join(fullTableHand, player);
// Should return unchanged hand
expect(result).toBe(fullTableHand);
expect(result.players.length).toBe(6);
expect(result.players).not.toContain('P7');
});
});
describe('in edge cases', () => {
it('should handle joining empty table as first player', () => {
// Scenario: First player joining empty table
// Input: emptyTableHand, first player joins
// Expected: All arrays initialized with single player
const player = {
playerName: 'FirstPlayer',
buyIn: 100,
};
const result = Hand.join(emptyTableHand, player);
expect(result.players).toEqual(['FirstPlayer']);
expect(result.startingStacks).toEqual([100]);
expect(result.blindsOrStraddles).toEqual([0]); // Blinds assigned by advance()
expect(result.antes).toEqual([0]);
expect(result._intents).toEqual([0]);
expect(result).not.toBe(emptyTableHand);
});
it('should handle fractional buy-in amounts', () => {
// Scenario: Player buys in with decimal amount
// Input: player with buyIn: 99.50
// Expected: startingStacks includes 99.50
const player = {
playerName: 'Charlie',
buyIn: 99.5,
};
const result = Hand.join(baseHand, player);
expect(result.players).toEqual(['Alice', 'Bob', 'Charlie']);
expect(result.startingStacks).toEqual([100, 100, 99.5]);
expect(result.startingStacks[2]).toBe(99.5);
});
it('should handle very long player names', () => {
// Scenario: Player with unusually long name
// Input: player with 50+ character name
// Expected: Name accepted as-is
const longName = 'PlayerWithAnExtremelyLongNameThatExceedsFiftyCharactersForTesting';
const player = {
playerName: longName,
buyIn: 100,
};
const result = Hand.join(baseHand, player);
expect(result.players).toEqual(['Alice', 'Bob', longName]);
expect(result.players[2]).toBe(longName);
expect(result.players[2].length).toBeGreaterThan(50);
});
it('should handle joining during active hand', () => {
// Scenario: Player joins while hand in progress
// Input: midGameHand with actions, new player joins
// Expected: Player added with _intents: 0 (ready to play), won't participate until next hand
const player = {
playerName: 'Charlie',
buyIn: 100,
};
const result = Hand.join(midGameHand, player);
expect(result.players).toEqual(['Alice', 'Bob', 'Charlie']);
expect(result.startingStacks).toEqual([100, 100, 100]);
expect(result._intents).toEqual([0, 0, 0]);
// Actions remain unchanged - Charlie doesn't participate
expect(result.actions).toEqual(midGameHand.actions);
});
});
});
describe('Hand.quitHand - Player leaving table', () => {
let activeHand: NoLimitHand;
let pausedHand: NoLimitHand;
beforeEach(() => {
activeHand = {
variant: 'NT',
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [0, 1, 2],
antes: [0, 0, 0],
minBet: 2,
author: 'Bob',
actions: [],
_intents: [0, 0, 0],
_inactive: [0, 0, 0],
_deadBlinds: [0, 0, 0],
};
pausedHand = {
...activeHand,
_intents: [0, 2, 0], // Bob is paused
_inactive: [0, 1, 0],
};
});
describe('when player quits', () => {
it('should set author player _intents to 3', () => {
// Scenario: Active player decides to leave
// Input: activeHand with author: 'Bob', _intents: [0, 0, 0]
// Expected: _intents: [0, 3, 0], Bob marked for removal
const result = Hand.quit(activeHand);
expect(result._intents).toEqual([0, 3, 0]);
expect(result.author).toBe('Bob');
});
it('should NOT modify _inactive field', () => {
// Scenario: Quit intent without touching server fields
// Input: Any hand state with _inactive values
// Expected: _inactive remains completely unchanged
const result = Hand.quit(activeHand);
expect(result._inactive).toEqual([0, 0, 0]);
expect(result._intents).toEqual([0, 3, 0]);
});
it('should NOT modify _deadBlinds field', () => {
// Scenario: Player with dead blinds quits
// Input: _deadBlinds: [0, 10, 0], Bob quits
// Expected: _deadBlinds remains [0, 10, 0]
const handWithDebt = {
...activeHand,
_deadBlinds: [0, 10, 0],
};
const result = Hand.quit(handWithDebt);
expect(result._deadBlinds).toEqual([0, 10, 0]);
expect(result._intents).toEqual([0, 3, 0]);
});
it('should NOT modify other players _intents', () => {
// Scenario: One player quits, others unaffected
// Input: _intents: [0, 0, 2], Bob (index 1) quits
// Expected: _intents: [0, 3, 2], only Bob's intent changed
const handWithIntents = {
...activeHand,
_intents: [0, 0, 2],
};
const result = Hand.quit(handWithIntents);
expect(result._intents).toEqual([0, 3, 2]);
});
it('should preserve immutability', () => {
// Scenario: Original hand not mutated
// Input: activeHand passed to quitHand
// Expected: activeHand unchanged, new hand returned
const originalIntents = [...(activeHand._intents as number[])];
const result = Hand.quit(activeHand);
expect(activeHand._intents).toEqual(originalIntents);
expect(result).not.toBe(activeHand);
expect(result._intents).toEqual([0, 3, 0]);
});
});
describe('when validating author', () => {
it('should find author by name in players array', () => {
// Scenario: Author field matches player name
// Input: author: 'Charlie', players include 'Charlie'
// Expected: Charlie's _intents set to 3
const handWithCharlie = {
...activeHand,
author: 'Charlie',
};
const result = Hand.quit(handWithCharlie);
expect(result._intents).toEqual([0, 0, 3]);
expect(result.author).toBe('Charlie');
});
it('should return unchanged if author not found', () => {
// Scenario: Invalid author name
// Input: author: 'David', not in players array
// Expected: Hand returned unchanged
const handWithInvalidAuthor = {
...activeHand,
author: 'David', // Not in players array
};
const result = Hand.quit(handWithInvalidAuthor);
expect(result).toBe(handWithInvalidAuthor);
expect(result._intents).toEqual([0, 0, 0]);
});
it('should return unchanged if no author field', () => {
// Scenario: Missing author field
// Input: Hand without author property
// Expected: Hand returned unchanged
const handWithoutAuthor = {
...activeHand,
};
delete (handWithoutAuthor as any).author;
const result = Hand.quit(handWithoutAuthor);
expect(result).toBe(handWithoutAuthor);
expect(result._intents).toEqual([0, 0, 0]);
});
it('should handle author as player index', () => {
// Scenario: Author specified as index (if supported)
// Input: author: 1 (Bob's index)
// Expected: _intents[1] set to 3
const handWithIndexAuthor = {
...activeHand,
author: 1 as any, // Bob's index
};
const result = Hand.quit(handWithIndexAuthor);
// If index is supported, Bob's intent should be set to 3
// Otherwise, hand returned unchanged
if (result !== handWithIndexAuthor) {
expect(result._intents).toEqual([0, 3, 0]);
} else {
expect(result).toBe(handWithIndexAuthor);
}
});
});
describe('when transitioning states', () => {
it('should transition from active (0) to leaving (3)', () => {
// Scenario: Active player quits
// Input: _intents: [0, 0, 0], Bob quits
// Expected: _intents: [0, 3, 0]
const result = Hand.quit(activeHand);
expect(result._intents).toEqual([0, 3, 0]);
expect(result.author).toBe('Bob');
});
it('should transition from paused (2) to leaving (3)', () => {
// Scenario: Paused player decides to leave
// Input: _intents: [0, 2, 0], Bob quits
// Expected: _intents: [0, 3, 0]
const result = Hand.quit(pausedHand);
expect(result._intents).toEqual([0, 3, 0]);
expect(result.author).toBe('Bob');
});
it('should transition from wait-BB (1) to leaving (3)', () => {
// Scenario: Player waiting for BB decides to leave
// Input: _intents: [0, 1, 0], Bob quits
// Expected: _intents: [0, 3, 0]
const waitingHand = {
...activeHand,
_intents: [0, 1, 0], // Bob waiting for BB
};
const result = Hand.quit(waitingHand);
expect(result._intents).toEqual([0, 3, 0]);
});
it('should handle already leaving state (3 to 3)', () => {
// Scenario: Player already marked for leaving
// Input: _intents: [0, 3, 0], Bob quits again
// Expected: _intents: [0, 3, 0], no change
const leavingHand = {
...activeHand,
_intents: [0, 3, 0], // Bob already leaving
};
const result = Hand.quit(leavingHand);
expect(result._intents).toEqual([0, 3, 0]);
});
});
describe('in edge cases', () => {
it('should handle missing _intents array', () => {
// Scenario: Legacy hand without _intents
// Input: Hand without _intents field
// Expected: _intents created as [0, 3, 0] for Bob
const legacyHand = {
...activeHand,
};
delete (legacyHand as any)._intents;
const result = Hand.quit(legacyHand);
expect(result._intents).toBeDefined();
expect(result._intents).toEqual([0, 3, 0]); // Bob's intent set to 3
});
it('should handle single player leaving', () => {
// Scenario: Last player at table quits
// Input: One player table, player quits
// Expected: _intents: [3]
const singlePlayerHand = {
variant: 'NT' as const,
players: ['Alice'],
startingStacks: [100],
blindsOrStraddles: [0],
antes: [0],
minBet: 2,
author: 'Alice',
actions: [],
_intents: [0],
};
const result = Hand.quit(singlePlayerHand);
expect(result._intents).toEqual([3]);
expect(result.players).toEqual(['Alice']);
});
it('should preserve all non-intent fields', () => {
// Scenario: Complex hand state preserved
// Input: Hand with actions, stacks, etc.
// Expected: Only _intents modified, everything else unchanged
const complexHand = {
...activeHand,
actions: ['d dh p1 AsKs', 'd dh p2 7c7d', 'p1 cc'],
minBet: 20,
seatCount: 9,
_inactive: [0, 1, 0],
_deadBlinds: [0, 10, 0],
};
const result = Hand.quit(complexHand);
expect(result._intents).toEqual([0, 3, 0]);
// All other fields preserved
expect(result.actions).toEqual(complexHand.actions);
expect(result.minBet).toBe(20);
expect(result.seatCount).toBe(9);
expect(result._inactive).toEqual([0, 1, 0]);
expect(result._deadBlinds).toEqual([0, 10, 0]);
});
});
});
describe('Hand.pauseHand - Player taking break', () => {
let activeHand: NoLimitHand;
beforeEach(() => {
activeHand = {
variant: 'NT',
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [0, 1, 2],
antes: [0, 0, 0],
minBet: 2,
author: 'Alice',
actions: [],
_intents: [0, 0, 0],
};
});
describe('when player pauses', () => {
it('should set author player _intents to 2', () => {
// Scenario: Player takes immediate break
// Input: activeHand with author: 'Alice', _intents: [0, 0, 0]
// Expected: _intents: [2, 0, 0], Alice marked for pause
const result = Hand.pause(activeHand);
expect(result._intents).toEqual([2, 0, 0]);
expect(result.author).toBe('Alice');
});
it('should NOT modify _inactive field', () => {
// Scenario: Express pause intent without server control
// Input: Any _inactive state
// Expected: _inactive unchanged (server will handle)
const handWithInactive = {
...activeHand,
_inactive: [0, 0, 0],
};
const result = Hand.pause(handWithInactive);
expect(result._inactive).toEqual([0, 0, 0]);
expect(result._intents).toEqual([2, 0, 0]);
});
it('should NOT modify _deadBlinds field', () => {
// Scenario: Pause without affecting dead blinds
// Input: Any _deadBlinds state
// Expected: _deadBlinds unchanged (server calculates)
const handWithDebt = {
...activeHand,
_deadBlinds: [0, 0, 0],
};
const result = Hand.pause(handWithDebt);
expect(result._deadBlinds).toEqual([0, 0, 0]);
expect(result._intents).toEqual([2, 0, 0]);
});
it('should NOT modify other players _intents', () => {
// Scenario: One player pauses, others continue
// Input: _intents: [0, 1, 0], Alice pauses
// Expected: _intents: [2, 1, 0], only Alice changed
const handWithIntents = {
...activeHand,
_intents: [0, 1, 0],
};
const result = Hand.pause(handWithIntents);
expect(result._intents).toEqual([2, 1, 0]);
});
});
describe('when transitioning states', () => {
it('should transition from active (0) to pause (2)', () => {
// Scenario: Active player takes break
// Input: _intents: [0, 0, 0], Alice pauses
// Expected: _intents: [2, 0, 0]
const result = Hand.pause(activeHand);
expect(result._intents).toEqual([2, 0, 0]);
expect(result.author).toBe('Alice');
});
it('should transition from wait-BB (1) to pause (2)', () => {
// Scenario: Player stops waiting for BB, takes immediate break
// Input: _intents: [1, 0, 0], Alice pauses
// Expected: _intents: [2, 0, 0]
const waitingHand = {
...activeHand,
_intents: [1, 0, 0],
};
const result = Hand.pause(waitingHand);
expect(result._intents).toEqual([2, 0, 0]);
expect(result.author).toBe('Alice');
});
it('should handle already paused state (2 to 2)', () => {
// Scenario: Already paused player
// Input: _intents: [2, 0, 0], Alice pauses again
// Expected: _intents: [2, 0, 0], no change
const pausedHand = {
...activeHand,
_intents: [2, 0, 0],
};
const result = Hand.pause(pausedHand);
expect(result._intents).toEqual([2, 0, 0]);
expect(result.author).toBe('Alice');
});
it('should allow transition from leaving (3) to pause', () => {
// Scenario: Client can request pause even if marked for leaving (server will validate)
// Input: _intents: [3, 0, 0], Alice tries to pause
// Expected: _intents: [2, 0, 0] - client forms request, server decides
const leavingHand = {
...activeHand,
_intents: [3, 0, 0],
};
const result = Hand.pause(leavingHand);
// Client method allows the transition
expect(result).not.toBe(leavingHand);
expect(result._intents).toEqual([2, 0, 0]);
});
});
});
describe('Hand.waitForBB - Player waiting for big blind', () => {
let activeHand: NoLimitHand;
beforeEach(() => {
activeHand = {
variant: 'NT',
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 2, 0], // Charlie (author) has no positional blind
antes: [0, 0, 0],
minBet: 2,
author: 'Charlie',
actions: [],
_intents: [0, 0, 0],
};
});
describe('when player waits for BB', () => {
it('should set author player _intents to 1', () => {
// Scenario: Player wants to wait for BB position
// Input: activeHand with author: 'Charlie', _intents: [0, 0, 0]
// Expected: _intents: [0, 0, 1], Charlie will wait
const result = Hand.waitForBB(activeHand);
expect(result._intents).toEqual([0, 0, 1]);
expect(result.author).toBe('Charlie');
});
it('should NOT change _inactive during active game', () => {
// SCENARIO: Active player calls waitForBB during game
// INPUT: _inactive: [0, 0, 0], all players active, Charlie has no blind
// EXPECTED: _inactive: [0, 0, 0] - Charlie stays active!
//
// _inactive can only be changed in next() between hands.
// Client methods only modify _intents to signal intention.
const handWithInactive = {
...activeHand,
_inactive: [0, 0, 0],
};
const result = Hand.waitForBB(handWithInactive);
expect(result._inactive).toEqual([0, 0, 0]); // NOT changed!
expect(result._intents).toEqual([0, 0, 1]); // Intent is set
});
it('should NOT accumulate _deadBlinds', () => {
// Scenario: Wait-for-BB avoids dead blind accumulation
// Input: Any _deadBlinds state
// Expected: _deadBlinds unchanged (server won't charge)
const handWithDebt = {
...activeHand,
_deadBlinds: [0, 0, 5],
};
const result = Hand.waitForBB(handWithDebt);
expect(result._deadBlinds).toEqual([0, 0, 5]);
expect(result._intents).toEqual([0, 0, 1]);
});
});
describe('when transitioning states', () => {
it('should transition from active (0) to wait-BB (1)', () => {
// Scenario: Active player chooses to wait
// Input: _intents: [0, 0, 0], Charlie waits
// Expected: _intents: [0, 0, 1]
const result = Hand.waitForBB(activeHand);
expect(result._intents).toEqual([0, 0, 1]);
expect(result.author).toBe('Charlie');
});
it('should transition from pause (2) to wait-BB (1)', () => {
// Scenario: Paused player switches to wait mode
// Input: _intents: [0, 0, 2], Charlie waits for BB
// Expected: _intents: [0, 0, 1]
const pausedHand = {
...activeHand,
_intents: [0, 0, 2],
};
const result = Hand.waitForBB(pausedHand);
expect(result._intents).toEqual([0, 0, 1]);
expect(result.author).toBe('Charlie');
});
it('should handle already waiting state (1 to 1)', () => {
// Scenario: Already waiting for BB
// Input: _intents: [0, 0, 1], Charlie waits again
// Expected: _intents: [0, 0, 1], no change
const waitingHand = {
...activeHand,
_intents: [0, 0, 1],
};
const result = Hand.waitForBB(waitingHand);
expect(result._intents).toEqual([0, 0, 1]);
expect(result.author).toBe('Charlie');
});
});
});
describe('Hand.resumeHand - Player returning to play', () => {
let pausedHand: NoLimitHand;
beforeEach(() => {
pausedHand = {
variant: 'NT',
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 0, 2], // Bob (inactive) has no positional blind
antes: [0, 0, 0],
minBet: 2,
author: 'Bob',
actions: [],
_intents: [0, 2, 0], // Bob is paused
_inactive: [0, 1, 0],
_deadBlinds: [0, 3, 0], // Bob has dead blinds
};
});
describe('when player resumes', () => {
it('should set author player _intents to 0', () => {
// Scenario: Paused player wants to return
// Input: pausedHand with Bob paused (_intents: 2)
// Expected: _intents: [0, 0, 0], Bob ready to play
const result = Hand.resume(pausedHand);
expect(result._intents).toEqual([0, 0, 0]);
expect(result.author).toBe('Bob');
});
it('should keep _inactive as waiting when game in progress (2+ active players)', () => {
// SCENARIO: Player resumes while game has 2+ active players
// INPUT: _inactive: [0, 1, 0] - Alice and Charlie active, Bob waiting
// EXPECTED: _inactive: [0, 1, 0] - Bob stays waiting, cannot jump into active game
//
// Bob must wait for advance()/next() to become active in next hand.
// This prevents players from "jumping into" an active game mid-hand.
const result = Hand.resume(pausedHand);
expect(result._inactive).toEqual([0, 1, 0]); // Bob stays waiting
expect(result._intents).toEqual([0, 0, 0]); // Intent is set to 0 (ready)
});
it('should NOT reset _deadBlinds - only next() handles debt', () => {
// SCENARIO: Resume should preserve _deadBlinds
// INPUT: _deadBlinds: [0, 3, 0], Bob owes 3
// EXPECTED: _deadBlinds: [0, 3, 0] - debt preserved
//
// Dead blinds are server-controlled and charged in next().
// Client methods should not modify _deadBlinds.
const result = Hand.resume(pausedHand);
expect(result._deadBlinds).toEqual([0, 3, 0]); // NOT reset!
expect(result._intents).toEqual([0, 0, 0]);
});
it('should NOT change _inactive even when game not started (< 2 active players)', () => {
// SCENARIO: Only one active player - game cannot start yet
// INPUT: _inactive: [0, 1, 1] - Only Alice active, Bob and Charlie waiting
// EXPECTED: _inactive: [0, 1, 1] - Bob stays waiting!
//
// _inactive changes are handled ONLY in next() between hands.
// Client methods only signal intent via _intents.
// advance() will handle player activation when needed.
const singleActivePlayerHand = {
variant: 'NT' as const,
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [0, 0, 0], // No blinds assigned yet
antes: [0, 0, 0],
minBet: 2,
author: 'Bob',
actions: [],
_intents: [0, 1, 1], // Bob and Charlie waiting
_inactive: [0, 1, 1], // Only Alice active
_deadBlinds: [0, 0, 0],
};
const result = Hand.resume(singleActivePlayerHand);
expect(result._inactive).toEqual([0, 1, 1]); // NOT changed!
expect(result._intents).toEqual([0, 0, 1]); // Only Bob's intent changed to 0
});
});
describe('when transitioning states', () => {
it('should transition from pause (2) to active (0)', () => {
// Scenario: Paused player returns
// Input: _intents: [0, 2, 0], Bob resumes
// Expected: _intents: [0, 0, 0]
const result = Hand.resume(pausedHand);
expect(result._intents).toEqual([0, 0, 0]);
expect(result.author).toBe('Bob');
});
it('should transition from wait-BB (1) to active (0)', () => {
// Scenario: Player stops waiting, wants immediate return
// Input: _intents: [0, 1, 0], Bob resumes
// Expected: _intents: [0, 0, 0]
const waitingHand = {
...pausedHand,
_intents: [0, 1, 0],
};
const result = Hand.resume(waitingHand);
expect(result._intents).toEqual([0, 0, 0]);
expect(result.author).toBe('Bob');
});
it('should handle already active state (0 to 0)', () => {
// Scenario: Already active player
// Input: _intents: [0, 0, 0], Bob resumes
// Expected: _intents: [0, 0, 0], no change
const activeStateHand = {
...pausedHand,
_intents: [0, 0, 0],
};
const result = Hand.resume(activeStateHand);
expect(result._intents).toEqual([0, 0, 0]);
expect(result.author).toBe('Bob');
});
it('should allow transition from leaving (3) to active', () => {
// Scenario: Client can request resume even if marked for leaving (server will validate)
// Input: _intents: [0, 3, 0], Bob tries to resume
// Expected: _intents: [0, 0, 0] - client forms request, server decides
const leavingHand = {
...pausedHand,
_intents: [0, 3, 0],
};
const result = Hand.resume(leavingHand);
// Client method allows the transition
expect(result).not.toBe(leavingHand);
expect(result._intents).toEqual([0, 0, 0]);
});
it('should NOT change _inactive for new player (_inactive: 2) - stays 2', () => {
// SCENARIO: New player who hasn't played yet calls resume
// INPUT: _inactive: [0, 0, 2], Charlie is new player
// EXPECTED: _inactive: [0, 0, 2] - Charlie stays NEW, only _intents changes
//
// _inactive changes are handled ONLY in next() between hands.
// Client methods only modify _intents to signal intention.
const newPlayerHand = {
variant: 'NT' as const,
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
minBet: 2,
actions: ['d dh p1 AsKs', 'd dh p2 7c7d'], // Game in progress, Charlie not dealt
_inactive: [0, 0, 2], // Charlie is NEW player
_intents: [0, 0, 0],
_deadBlinds: [0, 0, 0],
author: 'Charlie',
};
const result = Hand.resume(newPlayerHand);
// New player stays NEW (2), only intent changes
expect(result._inactive).toEqual([0, 0, 2]); // NOT changed!
expect(result._intents).toEqual([0, 0, 0]);
});
it('should NOT change _inactive for new player even with < 2 active players', () => {
// SCENARIO: New player calls resume when game hasn't started yet
// INPUT: _inactive: [0, 2] - Only Alice active, Bob is new player
// EXPECTED: _inactive: [0, 2] - Bob stays NEW, only _intents changes
//
// _inactive changes are handled ONLY in next() between hands.
// Client methods only modify _intents to signal intention.
const oneActiveOneNewHand = {
variant: 'NT' as const,
players: ['Alice', 'Bob'],
startingStacks: [100, 100],
blindsOrStraddles: [0, 0],
antes: [0, 0],
minBet: 2,
author: 'Bob',
actions: [],
_intents: [0, 0],
_inactive: [0, 2], // Alice active, Bob new
_deadBlinds: [0, 0],
};
const result = Hand.resume(oneActiveOneNewHand);
// New player stays NEW (2), only intent changes
expect(result._inactive).toEqual([0, 2]); // NOT changed!
expect(result._intents).toEqual([0, 0]);
});
it('should NOT change _inactive for waiting player even with zero active players', () => {
// SCENARIO: All players are waiting, one wants to resume
// INPUT: _inactive: [1, 1, 1] - All waiting
// EXPECTED: _inactive: [1, 1, 1] - Alice stays WAITING, only _intents changes
//
// _inactive changes are handled ONLY in next() between hands.
// advance() will handle player activation when enough intend to play.
const allWaitingHand = {
variant: 'NT' as const,
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [0, 0, 0],
antes: [0, 0, 0],
minBet: 2,
author: 'Alice',
actions: [],
_intents: [1, 1, 1],
_inactive: [1, 1, 1], // All waiting
_deadBlinds: [0, 0, 0],
};
const result = Hand.resume(allWaitingHand);
// Alice stays WAITING (1), only intent changes
expect(result._inactive).toEqual([1, 1, 1]); // NOT changed!
expect(result._intents).toEqual([0, 1, 1]);
});
it('should NOT change _inactive for new player even when all others are waiting', () => {
// SCENARIO: All players are waiting/new, new player calls resume
// INPUT: _inactive: [1, 1, 2] - Alice and Bob waiting, Charlie new
// EXPECTED: _inactive: [1, 1, 2] - Charlie stays NEW, only _intents changes
//
// _inactive changes are handled ONLY in next() between hands.
// Client methods only modify _intents to signal intention.
const allInactiveNewResumes = {
variant: 'NT' as const,
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [0, 0, 0],
antes: [0, 0, 0],
minBet: 2,
author: 'Charlie',
actions: [],
_intents: [1, 1, 0],
_inactive: [1, 1, 2], // All inactive, Charlie is new
_deadBlinds: [0, 0, 0],
};
const result = Hand.resume(allInactiveNewResumes);
// New player stays NEW (2), only intent changes
expect(result._inactive).toEqual([1, 1, 2]); // NOT changed!
expect(result._intents).toEqual([1, 1, 0]);
});
it('should handle exactly 2 active players boundary correctly', () => {
// SCENARIO: Exactly 2 active players (game in progress), waiting player resumes
// INPUT: _inactive: [0, 1, 0] - Alice and Charlie active, Bob waiting
// EXPECTED: _inactive: [0, 1, 0] - Bob stays WAITING
//
// This is the boundary condition: 2 players = game in progress
const exactlyTwoActiveHand = {
variant: 'NT' as const,
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 0, 2],
antes: [0, 0, 0],
minBet: 2,
author: 'Bob',
actions: ['d dh p1 AsKs', 'd dh p3 7c7d'], // Only 2 players dealt cards
_intents: [0, 1, 0],
_inactive: [0, 1, 0], // Exactly 2 active
_deadBlinds: [0, 0, 0],
};
const result = Hand.resume(exactlyTwoActiveHand);
// Bob stays waiting - game is in progress (2 >= 2)
expect(result._inactive).toEqual([0, 1, 0]);
expect(result._intents).toEqual([0, 0, 0]);
});
it('should NOT change _inactive for waiting player with exactly 1 active player', () => {
// SCENARIO: Only 1 active player, waiting player resumes
// INPUT: _inactive: [0, 1, 1] - Only Alice active
// EXPECTED: _inactive: [0, 1, 1] - Bob stays WAITING, only _intents changes
//
// _inactive changes are handled ONLY in next() between hands.
// Client methods only modify _intents to signal intention.
const oneActiveHand = {
variant: 'NT' as const,
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [0, 0, 0],
antes: [0, 0, 0],
minBet: 2,
author: 'Bob',
actions: [],
_intents: [0, 1, 1],
_inactive: [0, 1, 1], // Only 1 active
_deadBlinds: [0, 0, 0],
};
const result = Hand.resume(oneActiveHand);
// Bob stays WAITING (1), only intent changes
expect(result._inactive).toEqual([0, 1, 1]); // NOT changed!
expect(result._intents).toEqual([0, 0, 1]);
});
it('should NOT change _inactive for new player with 3+ active players', () => {
// SCENARIO: New player calls resume when 3 active players (game definitely in progress)
// INPUT: _inactive: [0, 0, 0, 2] - Alice, Bob, Charlie active, Dan new
// EXPECTED: _inactive: [0, 0, 0, 2] - Dan stays NEW, only _intents changes
//
// _inactive changes are handled ONLY in next() between hands.
// Client methods only modify _intents to signal intention.
const threeActiveOneNewHand = {
variant: 'NT' as const,
players: ['Alice', 'Bob', 'Charlie', 'Dan'],
startingStacks: [100, 100, 100, 100],
blindsOrStraddles: [0, 1, 2, 0],
antes: [0, 0, 0, 0],
minBet: 2,
author: 'Dan',
actions: ['d dh p1 AsKs', 'd dh p2 7c7d', 'd dh p3 QcQd'],
_intents: [0, 0, 0, 0],
_inactive: [0, 0, 0, 2], // 3 active, Dan new
_deadBlinds: [0, 0, 0, 0],
};
const result = Hand.resume(threeActiveOneNewHand);
// Dan stays NEW (2), only intent changes
expect(result._inactive).toEqual([0, 0, 0, 2]); // NOT changed!
expect(result._intents).toEqual([0, 0, 0, 0]);
});
});
describe('edge cases with missing arrays', () => {
it('should NOT create _inactive array when missing - only modifies _intents', () => {
// SCENARIO: Hand without _inactive array (legacy or initial state)
// INPUT: No _inactive field
// EXPECTED: No _inactive created, only _intents modified
//
// resume() only modifies _intents, not _inactive.
const handWithoutInactive = {
variant: 'NT' as const,
players: ['Alice', 'Bob'],
startingStacks: [100, 100],
blindsOrStraddles: [1, 2],
antes: [0, 0],
minBet: 2,
author: 'Bob',
actions: [],
_intents: [0, 0],
// No _inactive field
};
const result = Hand.resume(handWithoutInactive);
// Should NOT create _inactive array, only modify _intents
expect(result._inactive).toBeUndefined();
expect(result._intents).toEqual([0, 0]);
});
it('should initialize _intents array when missing but NOT change _inactive', () => {
// SCENARIO: Hand without _intents array, inactive player has no blinds
// INPUT: No _intents field, _inactive: [0, 1], inactive Bob has NO blinds
// EXPECTED: Creates _intents array, Bob's intent set to 0, _inactive unchanged
//
// resume() only creates/modifies _intents, not _inactive.
// NOTE: Inactive player CANNOT have blindsOrStraddles > 0 (validation rule)
const handWithoutIntents = {
variant: 'NT' as const,
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [10, 0, 20], // Bob (inactive) has NO blinds
antes: [0, 0, 0],
minBet: 20,
author: 'Bob',
actions: [],
_inactive: [0, 1, 0], // Bob waiting, has no blinds
// No _intents field
};
const result = Hand.resume(handWithoutIntents);
// Should create _intents array, Bob's intent set to 0
// But _inactive stays [0, 1, 0] - NOT changed!
expect(result._intents).toEqual([0, 0, 0]);
expect(result._inactive).toEqual([0, 1, 0]); // NOT changed!
});
});
});
describe('Integration scenarios', () => {
describe('when joining and pausing in sequence', () => {
it('should handle player joining then immediately pausing', () => {
// Scenario: New player joins but needs break
// Input: Empty table -> joinHand('David') -> pauseHand()
// Expected: David added with _intents: 1 (wait for BB), then _intents: 2 after pause
const emptyTable = {
variant: 'NT' as const,
players: [],
startingStacks: [],
blindsOrStraddles: [],
antes: [],
minBet: 2,
seatCount: 6,
actions: [],
};
// First, David joins
const player = {
playerName: 'David',
buyIn: 100,
};
const afterJoin = Hand.join(emptyTable, player);
expect(afterJoin.players).toEqual(['David']);
expect(afterJoin.startingStacks).toEqual([100]);
expect(afterJoin._intents).toEqual([0]); // New player ready to play
// Then David pauses
const handWithAuthor = { ...afterJoin, author: 'David' };
const afterPause = Hand.pause(handWithAuthor);
expect(afterPause.players).toEqual(['David']);
expect(afterPause._intents).toEqual([2]);
expect(afterPause.author).toBe('David');
});
it('should handle player joining then waiting for BB', () => {
// Scenario: New player joins ready to play, then chooses to wait for BB
// Input: 2-player table -> joinHand('David')
// Expected: David added with _intents: 0 (ready to play)
const twoPlayerTable = {
variant: 'NT' as const,
players: ['Alice', 'Bob'],
startingStacks: [100, 100],
blindsOrStraddles: [1, 2],
antes: [0, 0],
minBet: 2,
seatCount: 6,
actions: [],
};
// First, David joins
const player = {
playerName: 'David',
buyIn: 100,
};
const afterJoin = Hand.join(twoPlayerTable, player);
expect(afterJoin.players).toEqual(['Alice', 'Bob', 'David']);
expect(afterJoin._intents).toEqual([0, 0, 0]); // David ready to play by default
// David decides to wait for BB
const handWithAuthor = { ...afterJoin, author: 'David' };
const afterWait = Hand.waitForBB(handWithAuthor);
expect(afterWait.players).toEqual(['Alice', 'Bob', 'David']);
expect(afterWait._intents).toEqual([0, 0, 1]); // Now waiting
expect(afterWait.author).toBe('David');
});
});
describe('when p