@idealic/poker-engine
Version:
Poker game engine and hand evaluator
1,292 lines (1,128 loc) • 116 kB
text/typescript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Hand } from '../../../Hand';
describe('Hand.merge() - Sit In/Out functionality', () => {
let baseHand: Hand;
beforeEach(() => {
// Mock system time for consistent timestamp testing
vi.setSystemTime(new Date(1715616000000));
// Setup base hands for testing
baseHand = {
variant: 'NT',
players: ['Player1', 'Player2'],
startingStacks: [100, 100],
blindsOrStraddles: [1, 2],
antes: [0, 0],
minBet: 2,
seatCount: 6, // 6-max table
actions: [],
_inactive: [0, 0],
_intents: [0, 0],
_deadBlinds: [0, 0],
};
});
afterEach(() => {
// Restore real time after each test
vi.useRealTimers();
});
describe('Player joining the game (Sit In)', () => {
describe('when a new player wants to join', () => {
it('should allow a new player to join when game has not started', () => {
// Scenario: Player3 wants to join an empty table
// Input: newHand with Player3 added to all arrays, author: 'Player3', _intents: [0, 0, 1] (wait for BB)
// Expected: Player3 added to all arrays, _inactive: [0, 0, 1] - new players always start inactive
const newHand = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, newHand);
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result.startingStacks).toEqual([100, 100, 150]);
expect(result.blindsOrStraddles).toEqual([1, 2, 0]);
expect(result.antes).toEqual([0, 0, 0]);
expect(result._inactive).toEqual([0, 0, 2]);
expect(result._intents).toEqual([0, 0, 1]);
expect(result.author).toBeUndefined();
});
it('should mark new player as inactive when joining mid-game', () => {
// Scenario: Player3 joins while game is in progress
// Input: baseHand has actions, newHand adds Player3 with author: 'Player3', _intents: [0, 0, 1] (wait for BB)
// Expected: Player3 added but _inactive: [0, 0, 1], _intents: [0, 0, 1], will play next hand
const gameInProgress = {
...baseHand,
actions: ['d dh p1 AsKs', 'd dh p2 7c7d', 'p1 cc'],
};
const newHand = {
...gameInProgress,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 1],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(gameInProgress, newHand);
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result._inactive).toEqual([0, 0, 2]);
expect(result._intents).toEqual([0, 0, 1]);
});
it('should set _inactive: 1 for new player when actions exist', () => {
// Scenario: Game in progress (actions not empty), new player must wait
// Input: oldHand.actions = ['p1 cc', 'p2 cbr 10'], Player3 joins with _intents: 1 (wait for BB)
// Expected: _inactive: [0, 0, 1], _intents: [0, 0, 1] - Player3 marked inactive until next hand
const gameWithActions = {
...baseHand,
actions: ['p1 cc', 'p2 cbr 10'],
};
const newHand = {
...gameWithActions,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 1],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(gameWithActions, newHand);
expect(result._inactive).toEqual([0, 0, 2]);
expect(result._intents).toEqual([0, 0, 1]);
expect(result.players.length).toBe(3);
});
it('should set _inactive: 1 when joining with wait-for-BB intent', () => {
// Scenario: New player joins with wait-for-BB intent, must wait regardless of game state
// Input: oldHand.actions = [], Player3 joins with _intents: 1 (wait for BB)
// Expected: _inactive: [0, 0, 1], _intents: [0, 0, 1] - Player3 inactive until BB position
const emptyGame = {
...baseHand,
actions: [],
};
const newHand = {
...emptyGame,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(emptyGame, newHand);
expect(result._inactive).toEqual([0, 0, 2]);
expect(result._intents).toEqual([0, 0, 1]);
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
});
it('should validate all player-related arrays have correct length', () => {
// Scenario: Player3 joins but arrays are mismatched
// Input: newHand with inconsistent array lengths
// Expected: Returns original hand unchanged
const newHand = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100], // Wrong length!
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 0],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, newHand);
// Should return original hand unchanged
expect(result).toBe(baseHand);
expect(result.players).toEqual(['Player1', 'Player2']);
});
it('should reject if player name does not match author', () => {
// Scenario: Player3 tries to add Player4 to the game
// Input: author: 'Player3', but adds 'Player4' to players array
// Expected: Returns original hand unchanged
const newHand = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player4'], // Wrong player!
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 0],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, newHand);
// Should reject and return original
expect(result).toBe(baseHand);
expect(result.players).toEqual(['Player1', 'Player2']);
});
it('should accept new player joining with _intents: 0 and calculate dead blinds', () => {
// Scenario: New player wants to play immediately
// Input: author: 'Player3', _intents: [0, 0, 0] - wants to play next hand
// Expected: Player added with _inactive: 1, dead blinds calculated based on position
const joinWithPlayIntent = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
seats: [1, 2, 3], // Player3 at seat 3
_inactive: [0, 0, 0],
_intents: [0, 0, 0], // Wants to play immediately
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, joinWithPlayIntent);
// Player should be added but inactive until next hand
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result._inactive).toEqual([0, 0, 2]); // New player inactive
expect(result._intents).toEqual([0, 0, 0]); // Intent preserved
// After rotation, blinds become [0,1,2] - Player3 will be on BB position
// So Player3 should pay 0 dead blinds (standard poker rule: no dead blinds on BB)
expect(result._deadBlinds).toEqual([0, 0, 0]);
});
it('should calculate full BB dead blinds when new player will be on non-blind position', () => {
// Scenario: New player joins at position that will NOT be on any blind after rotation
// Input: Player4 joins at seat 4
// Expected: _deadBlinds: 1.0*BB = 2 (standard for non-blind positions)
const baseWith3Players = {
...baseHand,
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 2, 0],
seats: [1, 2, 3],
antes: [0, 0, 0],
_inactive: [0, 0, 1],
_intents: [0, 0, 0],
_deadBlinds: [0, 0, 0],
};
const newPlayerJoining = {
...baseWith3Players,
author: 'Player4',
players: ['Player1', 'Player2', 'Player3', 'Player4'],
startingStacks: [100, 100, 100, 150],
blindsOrStraddles: [1, 2, 0, 0],
seats: [1, 2, 3, 4], // Player4 at seat 4
antes: [0, 0, 0, 0],
_inactive: [0, 0, 1, 0],
_intents: [0, 0, 0, 0], // Player4 wants to play immediately
_deadBlinds: [0, 0, 0, 0],
};
const result = Hand.merge(baseWith3Players, newPlayerJoining);
// After rotation, blinds become [0, 1, 2, 0] (last element moves to front)
// Player4 (at index 3) will have blind=0 (not on blind position)
// So Player4 should pay 1.0×BB = 2 dead blinds
expect(result._deadBlinds).toEqual([0, 0, 0, 0]);
expect(result.startingStacks).toEqual([100, 100, 100, 150]);
});
it('should calculate half BB dead blinds when new player will be on SB position', () => {
// Scenario: New player joins at position that will be SB after rotation
// Input: Setup where new player will get SB position
// Expected: _deadBlinds: 0.5*BB = 1 (for BB=2)
const baseWith2Players: Hand = {
variant: 'NT',
players: ['Player1', 'Player2'],
startingStacks: [100, 100],
blindsOrStraddles: [1, 2],
seats: [2, 3], // Specific seats to control rotation
antes: [0, 0],
minBet: 2,
seatCount: 6,
actions: [],
_inactive: [0, 0],
_intents: [0, 0],
_deadBlinds: [0, 0],
};
const joinAtSeat1 = {
...baseWith2Players,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
seats: [2, 3, 1], // Player3 takes seat 1
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 0], // Wants to play immediately
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseWith2Players, joinAtSeat1);
// Result maintains original order: ['Player1', 'Player2', 'Player3']
// For dead blind calculation, arrays are sorted by seats: [1,2,3] => Player3, Player1, Player2
// Sorted blinds: [0, 1, 2]
// After rotation: [2, 0, 1] (last element moves to front)
// Player3 (at sorted index 0) will have blind=2 (BB position)
// So Player3 should pay 0 dead blinds (no dead blinds on BB)
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result._intents).toEqual([0, 0, 0]);
expect(result._inactive).toEqual([0, 0, 2]);
expect(result._deadBlinds).toEqual([0, 0, 0]);
});
it('should accept new player joining with valid _intents: 1 (wait for BB)', () => {
// Scenario: Player3 correctly joins with wait-for-BB intent
// Input: author: 'Player3', _intents: [0, 0, 1] - proper wait-for-BB
// Expected: Player3 successfully added with _intents: 1
const validJoin = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 1], // Valid: wait for BB
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, validJoin);
// Should accept valid join with wait-for-BB intent
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result._intents).toEqual([0, 0, 1]);
expect(result.startingStacks).toEqual([100, 100, 150]);
});
it('should accept new player joining with _intents: 2 (simple pause)', () => {
// Scenario: Player3 joins but immediately pauses
// Input: author: 'Player3', _intents: [0, 0, 2] - joining with pause intent
// Expected: Player3 added with _intents: 2
const joinWithPause = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 2], // Valid: joining with pause
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, joinWithPause);
// Should accept join with pause intent
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result._intents).toEqual([0, 0, 2]);
});
it('should add only the author when multiple players are in newHand', () => {
// Scenario: newHand contains multiple new players, but only author can join
// Input: newHand has Player3 (author) and Player4, author: 'Player3'
// Expected: Only Player3 is added, Player4 is ignored
const newHand = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3', 'Player4'], // Has extra player
startingStacks: [100, 100, 150, 200],
blindsOrStraddles: [1, 2, 0, 0],
antes: [0, 0, 0, 0],
_inactive: [0, 0, 0, 0],
_intents: [0, 0, 0, 0],
_deadBlinds: [0, 0, 0, 0],
};
const result = Hand.merge(baseHand, newHand);
// Should add only Player3 (the author)
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result.players.length).toBe(3);
expect(result.startingStacks).toEqual([100, 100, 150]); // Only Player3's stack
expect(result.blindsOrStraddles).toEqual([1, 2, 0]);
expect(result.antes).toEqual([0, 0, 0]);
expect(result._inactive).toEqual([0, 0, 2]);
expect(result._intents).toEqual([0, 0, 0]);
expect(result._deadBlinds).toEqual([0, 0, 0]); // Dead blind calculated for Player3
expect(result.startingStacks).toEqual([100, 100, 150]);
});
it('should accept client-requested buy-in but server may adjust', () => {
// Scenario: Player3 requests 150 chip buy-in
// Input: startingStacks includes 150 for Player3, _intents: 1 (wait for BB)
// Expected: Merged hand accepts the value (server will validate later)
const newHand = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150], // Player3 requests 150
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, newHand);
// Client's requested amount is accepted in merge
expect(result.startingStacks).toEqual([100, 100, 150]);
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result._intents).toEqual([0, 0, 1]);
// Server may adjust this in its response
});
it('should handle client requesting buy-in above table maximum', () => {
// Scenario: Player3 requests 500 chips on 200 max table
// Input: startingStacks: [100, 100, 500], _intents: 1 (wait for BB)
// Expected: Value accepted in merge (server adjusts to 200 in response)
const newHand = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 500], // Requesting above max
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, newHand);
// Merge accepts the client's value
expect(result.startingStacks).toEqual([100, 100, 500]);
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result._intents).toEqual([0, 0, 1]);
// Server will cap this to table max in response
});
it('should handle client requesting buy-in below table minimum', () => {
// Scenario: Player3 requests 10 chips on 20 min table
// Input: startingStacks: [100, 100, 10], _intents: 1 (wait for BB)
// Expected: Value accepted in merge (server may reject or adjust)
const newHand = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 10], // Below minimum
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, newHand);
// Merge accepts whatever client sends
expect(result.startingStacks).toEqual([100, 100, 10]);
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result._intents).toEqual([0, 0, 1]);
// Server will enforce minimum in response
});
it('should handle client with insufficient bankroll', () => {
// Scenario: Player3 requests 100 but only has 75.25 available
// Input: startingStacks: [100, 100, 100] from client, _intents: 1 (wait for BB)
// Expected: Merge accepts, server will adjust to 75.25
const newHand = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 100], // Requests 100
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, newHand);
// Client request accepted as-is
expect(result.startingStacks).toEqual([100, 100, 100]);
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result._intents).toEqual([0, 0, 1]);
// Server will adjust to actual bankroll (75.25) in response
});
});
describe('seat selection validation', () => {
it('should accept valid seat selection within seatCount range', () => {
// Scenario: Player3 selects seat 4 on 6-max table
// Input: seats: [1, 2, 4], seatCount: 6, _intents: 1 (wait for BB)
// Expected: Seat selection preserved, _intents: [0, 0, 1]
const handWithSeats = {
...baseHand,
seats: [1, 2],
};
const newHand = {
...handWithSeats,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
seats: [1, 2, 4],
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(handWithSeats, newHand);
expect(result.seats).toEqual([1, 2, 4]);
expect(result.players.length).toBe(3);
expect(result._intents).toEqual([0, 0, 1]);
});
it('should reject seat selection outside seatCount range', () => {
// Scenario: Player3 tries seat 7 on 6-max table
// Input: seats: [1, 2, 7], seatCount: 6, _intents: 1 (wait for BB)
// Expected: Returns original hand unchanged
const handWithSeats = {
...baseHand,
seats: [1, 2],
seatCount: 6,
};
const newHand = {
...handWithSeats,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
seats: [1, 2, 7], // Seat 7 out of range for 6-max
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(handWithSeats, newHand);
// Should reject and return original
expect(result).toBe(handWithSeats);
expect(result.players).toEqual(['Player1', 'Player2']);
expect(result.seats).toEqual([1, 2]);
});
it('should reject duplicate seat selection', () => {
// Scenario: Player3 tries to take Player1's seat
// Input: seats: [1, 2, 1] (duplicate seat 1), _intents: 1 (wait for BB)
// Expected: Returns original hand unchanged
const handWithSeats = {
...baseHand,
seats: [1, 2],
seatCount: 6,
};
const newHand = {
...handWithSeats,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
seats: [1, 2, 1], // Duplicate seat 1
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(handWithSeats, newHand);
// Should reject due to duplicate seat
expect(result).toBe(handWithSeats);
expect(result.players).toEqual(['Player1', 'Player2']);
expect(result.seats).toEqual([1, 2]);
});
it('should handle missing seats array', () => {
// Scenario: No seat preferences specified
// Input: seats field not provided, _intents: 1 (wait for BB)
// Expected: Merge proceeds, server assigns available seat
const newHand = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
// No seats array provided
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, newHand);
// Should proceed without seats
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result.startingStacks).toEqual([100, 100, 100]);
expect(result.seats).toBeUndefined();
expect(result._intents).toEqual([0, 0, 1]);
});
it('should validate seats array length matches players array', () => {
// Scenario: Seats array wrong length
// Input: 3 players but seats: [1, 2]
// Expected: Returns original hand unchanged
const handWithSeats = {
...baseHand,
seats: [1, 2],
};
const newHand = {
...handWithSeats,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
seats: [1, 2], // Missing seat for Player3
_inactive: [0, 0, 0],
_intents: [0, 0, 0],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(handWithSeats, newHand);
// Should reject due to mismatched array lengths
expect(result).toBe(handWithSeats);
expect(result.players).toEqual(['Player1', 'Player2']);
});
it('should handle table size changes', () => {
// Scenario: Table was 9-max, now 6-max
// Input: oldHand seatCount: 9, newHand seatCount: 6
// Expected: Returns original hand unchanged, because of seatCount mismatch
const nineMaxHand = {
...baseHand,
seatCount: 9,
seats: [1, 3],
};
const sixMaxHand = {
...nineMaxHand,
author: 'Player1',
seatCount: 6, // Changed to 6-max
_intents: [0, 0],
};
const result = Hand.merge(nineMaxHand, sixMaxHand);
// Can't merge because of seatCount mismatch, should return original hand
expect(result.seatCount).toBe(9);
expect(result.seats).toEqual([1, 3]);
expect(result._intents).toEqual([0, 0]);
});
});
describe('when arrays need expansion', () => {
it('should expand all player-related arrays correctly', () => {
// Scenario: Ensure all arrays expand uniformly
// Input: 2-player game becomes 3-player game, _intents: 1 (wait for BB)
// Expected: All arrays have length 3 with correct values
const newHand = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
seats: [1, 2, 5],
_venueIds: ['id1', 'id2', 'id3'],
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, newHand);
// All arrays should have length 3
expect(result.players.length).toBe(3);
expect(result.startingStacks.length).toBe(3);
expect(result.blindsOrStraddles.length).toBe(3);
expect(result.antes.length).toBe(3);
expect(result._intents!.length).toBe(3);
expect(result._intents).toEqual([0, 0, 1]);
});
it('should initialize optional arrays if not present', () => {
// Scenario: Base hand missing _venueIds
// Input: newHand provides _venueIds for all players, _intents: 1 (wait for BB)
// Expected: _venueIds created and populated, _intents: [0, 0, 1]
const newHand = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_venueIds: ['user1', 'user2', 'user3'],
_inactive: [0, 0, 0],
_intents: [0, 0, 1],
_deadBlinds: [0, 0, 0],
};
const result = Hand.merge(baseHand, newHand);
expect(result._venueIds).toEqual(['Player1', 'Player2', 'user3']);
expect(result.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(result._intents).toEqual([0, 0, 1]);
});
});
describe('sequential player operations', () => {
it('should handle sequential addition of multiple players', () => {
// Scenario: Three players join the game one by one
// Input: Player3, then Player4, then Player5 join sequentially
// Expected: Each player added correctly with proper _inactive and _deadBlinds
// Start with 2 players
let currentHand = baseHand;
// Player3 joins
const player3Joins: Hand = {
...currentHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
seats: [1, 2, 3],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 0], // Wants to play immediately
_deadBlinds: [0, 0, 0],
};
currentHand = Hand.merge(currentHand, player3Joins);
expect(currentHand.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(currentHand._inactive).toEqual([0, 0, 2]); // Player3 inactive until next hand
expect(currentHand._deadBlinds).toEqual([0, 0, 0]); // Player3 at seat 3 will be on BB position, no dead blinds
// Player4 joins
const player4Joins = {
...currentHand,
author: 'Player4',
players: ['Player1', 'Player2', 'Player3', 'Player4'],
startingStacks: [100, 100, 150, 200],
blindsOrStraddles: [1, 2, 0, 0],
seats: [1, 2, 3, 5],
antes: [0, 0, 0, 0],
_inactive: [0, 0, 1, 0],
_intents: [0, 0, 0, 1], // Player4 waits for BB
_deadBlinds: [0, 0, 2, 0],
};
currentHand = Hand.merge(currentHand, player4Joins);
expect(currentHand.players).toEqual(['Player1', 'Player2', 'Player3', 'Player4']);
expect(currentHand._inactive).toEqual([0, 0, 2, 2]); // Both new players inactive
expect(currentHand._intents).toEqual([0, 0, 0, 1]);
expect(currentHand._deadBlinds).toEqual([0, 0, 0, 0]); // Player4 at seat 5 accumulates dead blinds
expect(currentHand.startingStacks).toEqual([100, 100, 150, 200]);
// Player5 joins
const player5Joins = {
...currentHand,
author: 'Player5',
players: ['Player1', 'Player2', 'Player3', 'Player4', 'Player5'],
startingStacks: [100, 100, 150, 200, 120],
blindsOrStraddles: [1, 2, 0, 0, 0],
seats: [1, 2, 3, 5, 6],
antes: [0, 0, 0, 0, 0],
_inactive: [0, 0, 1, 1, 0],
_intents: [0, 0, 0, 1, 0], // Player5 wants to play immediately
_deadBlinds: [0, 0, 2, 0, 0],
};
currentHand = Hand.merge(currentHand, player5Joins);
expect(currentHand.players.length).toBe(5);
expect(currentHand.players).toEqual([
'Player1',
'Player2',
'Player3',
'Player4',
'Player5',
]);
expect(currentHand._inactive).toEqual([0, 0, 2, 2, 2]); // All new players inactive
expect(currentHand._intents).toEqual([0, 0, 0, 1, 0]);
expect(currentHand._deadBlinds).toEqual([0, 0, 0, 0, 0]); // Recalculated: Player3 on BB (0), Player4 accumulates (2), Player5 pays (2)
expect(currentHand.startingStacks).toEqual([100, 100, 150, 200, 120]);
});
it('should handle player joining then leaving immediately', () => {
// Scenario: Player joins and then leaves before playing any hands
// Input: Player3 joins with wait-for-BB, then changes intent to leave
// Expected: Player marked for removal, never becomes active
// Player3 joins
const player3Joins = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 1], // Wait for BB
_deadBlinds: [0, 0, 0],
};
let currentHand = Hand.merge(baseHand, player3Joins);
expect(currentHand.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(currentHand._inactive).toEqual([0, 0, 2]);
expect(currentHand._intents).toEqual([0, 0, 1]);
expect(currentHand._deadBlinds).toEqual([0, 0, 0]); // Player3 accumulates dead blinds (no seats, so default BB)
expect(currentHand.startingStacks).toEqual([100, 100, 150]);
// Player3 decides to leave immediately
const player3Leaves = {
...currentHand,
author: 'Player3',
_intents: [0, 0, 3], // Leave intent
};
currentHand = Hand.merge(currentHand, player3Leaves);
expect(currentHand.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(currentHand._inactive).toEqual([0, 0, 2]); // Still inactive
expect(currentHand._intents).toEqual([0, 0, 3]); // Marked for removal
expect(currentHand._deadBlinds).toEqual([0, 0, 0]); // Dead blinds remain (intent change doesn't trigger recalc)
expect(currentHand.startingStacks).toEqual([100, 100, 150]);
});
it('should calculate dead blinds correctly for multiple players joining at different positions', () => {
// Scenario: Players join at various seat positions, dead blinds depend on future position
// Input: 3 players join with different seat selections
// Expected: Dead blinds calculated based on their position after rotation
const baseWith3Players: Hand = {
variant: 'NT',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [0, 1, 2], // Player3 on BB
seats: [1, 3, 5],
antes: [0, 0, 0],
minBet: 2,
seatCount: 9,
actions: [],
_inactive: [0, 0, 0],
_intents: [0, 0, 0],
_deadBlinds: [0, 0, 0],
};
// Player4 joins at seat 2 (between Player1 and Player2)
const player4Joins: Hand = {
...baseWith3Players,
author: 'Player4',
players: ['Player1', 'Player2', 'Player3', 'Player4'],
startingStacks: [100, 100, 100, 150],
blindsOrStraddles: [0, 1, 2, 0],
seats: [1, 3, 5, 2], // Seat 2
antes: [0, 0, 0, 0],
_inactive: [0, 0, 0, 0],
_intents: [0, 0, 0, 0], // Play immediately
_deadBlinds: [0, 0, 0, 0],
};
let currentHand = Hand.merge(baseWith3Players, player4Joins);
// After rotation with seats [1,2,3,5] sorted, blinds will rotate
// Need to calculate based on seat order
expect(currentHand.players).toEqual(['Player1', 'Player2', 'Player3', 'Player4']);
expect(currentHand.seats).toEqual([1, 3, 5, 2]);
expect(currentHand._inactive).toEqual([0, 0, 0, 2]);
// Dead blind calculation depends on position after sort
expect(currentHand._deadBlinds?.[3]).toBe(0);
expect(currentHand.startingStacks).toEqual([100, 100, 100, 150]);
// Player5 joins at seat 7
const player5Joins: Hand = {
...currentHand,
author: 'Player5',
players: ['Player1', 'Player2', 'Player3', 'Player4', 'Player5'],
startingStacks: [100, 100, 100, 150, 200],
blindsOrStraddles: [0, 1, 2, 0, 0],
seats: [1, 3, 5, 2, 7],
antes: [0, 0, 0, 0, 0],
_inactive: [0, 0, 0, 1, 0],
_intents: [0, 0, 0, 0, 0], // Play immediately
_deadBlinds: [0, 0, 0, currentHand._deadBlinds?.[3] || 0, 0],
};
currentHand = Hand.merge(currentHand, player5Joins);
expect(currentHand.players.length).toBe(5);
expect(currentHand.seats).toEqual([1, 3, 5, 2, 7]);
});
it('should handle add-remove-add pattern for the same player', () => {
// Scenario: Player joins, leaves, then rejoins
// Input: Player3 joins -> leaves -> joins again
// Expected: Treated as new player each time
// Initial join
const firstJoin = {
...baseHand,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 0],
_deadBlinds: [0, 0, 0],
};
let currentHand = Hand.merge(baseHand, firstJoin);
expect(currentHand.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(currentHand._inactive).toEqual([0, 0, 2]);
expect(currentHand._deadBlinds).toEqual([0, 0, 0]);
expect(currentHand.startingStacks).toEqual([100, 100, 150]);
// Player3 leaves
const playerLeaves = {
...currentHand,
author: 'Player3',
_intents: [0, 0, 3],
};
currentHand = Hand.merge(currentHand, playerLeaves);
expect(currentHand._intents).toEqual([0, 0, 3]);
// Simulate Hand.next() removing the player
const afterRemoval = {
...baseHand,
actions: ['d db AhKhQh', 'p1 cc', 'p2 cc'], // Some actions
};
// Player3 joins again (second time)
const secondJoin = {
...afterRemoval,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 175], // Different stack this time
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 1], // Wait for BB this time
_deadBlinds: [0, 0, 0],
};
currentHand = Hand.merge(afterRemoval, secondJoin);
expect(currentHand.players).toEqual(['Player1', 'Player2', 'Player3']);
expect(currentHand._inactive).toEqual([0, 0, 2]); // Inactive again
expect(currentHand._intents).toEqual([0, 0, 1]); // Different intent
expect(currentHand.startingStacks).toEqual([100, 100, 175]); // New stack
});
it('should maintain game state consistency with rapid player changes', () => {
// Scenario: Multiple players changing states in quick succession
// Input: Mix of joins, pauses, resumes, and leaves
// Expected: Each operation processed correctly without state corruption
// Start with 3 active players
let currentHand: Hand = {
variant: 'NT',
players: ['Alice', 'Bob', 'Charlie'],
startingStacks: [100, 150, 200],
blindsOrStraddles: [1, 2, 0],
antes: [0, 0, 0],
minBet: 2,
seatCount: 9,
actions: [],
_inactive: [0, 0, 0],
_intents: [0, 0, 0],
_deadBlinds: [0, 0, 0],
};
// David joins
const davidJoins = {
...currentHand,
author: 'David',
players: ['Alice', 'Bob', 'Charlie', 'David'],
startingStacks: [100, 150, 200, 100],
blindsOrStraddles: [1, 2, 0, 0],
antes: [0, 0, 0, 0],
_inactive: [0, 0, 0, 0],
_intents: [0, 0, 0, 0],
_deadBlinds: [0, 0, 0, 0],
};
currentHand = Hand.merge(currentHand, davidJoins);
expect(currentHand.players.length).toBe(4);
expect(currentHand._inactive).toEqual([0, 0, 0, 2]);
// Alice pauses
const alicePauses = {
...currentHand,
author: 'Alice',
_intents: [2, 0, 0, 0],
};
currentHand = Hand.merge(currentHand, alicePauses);
expect(currentHand._intents).toEqual([2, 0, 0, 0]);
expect(currentHand._inactive).toEqual([0, 0, 0, 2]); // Alice stays inactive
// Eve joins
const eveJoins = {
...currentHand,
author: 'Eve',
players: ['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
startingStacks: [100, 150, 200, 100, 80],
blindsOrStraddles: [0, 1, 2, 0, 0], // Alice inactive, so blinds shift to Bob/Charlie
antes: [0, 0, 0, 0, 0],
_inactive: [1, 0, 0, 1, 0],
_intents: [2, 0, 0, 0, 1], // Eve waits for BB
_deadBlinds: [0, 0, 0, currentHand._deadBlinds?.[3] || 0, 0],
};
currentHand = Hand.merge(currentHand, eveJoins);
expect(currentHand.players.length).toBe(5);
expect(currentHand._inactive).toEqual([0, 0, 0, 2, 2]);
// Bob leaves
const bobLeaves = {
...currentHand,
author: 'Bob',
_intents: [2, 3, 0, 0, 1],
};
currentHand = Hand.merge(currentHand, bobLeaves);
expect(currentHand._intents).toEqual([2, 3, 0, 0, 1]);
expect(currentHand._inactive).toEqual([0, 0, 0, 2, 2]); // Bob stays active
// Alice resumes
const aliceResumes = {
...currentHand,
author: 'Alice',
_intents: [0, 3, 0, 0, 1],
};
currentHand = Hand.merge(currentHand, aliceResumes);
expect(currentHand._intents).toEqual([0, 3, 0, 0, 1]);
expect(currentHand._inactive).toEqual([0, 0, 0, 2, 2]); // Still active until next hand
// Final state check
expect(currentHand.players).toEqual(['Alice', 'Bob', 'Charlie', 'David', 'Eve']);
// Alice, Bob and Charlie are active
expect(currentHand._inactive).toEqual([0, 0, 0, 2, 2]);
// Alice wants to resume, Bob leaving, Charlie active, David/Eve waiting
expect(currentHand._intents).toEqual([0, 3, 0, 0, 1]);
});
it('should correctly calculate cumulative dead blinds for sequential joins', () => {
// Scenario: Track dead blind accumulation as players join at different times
// Input: Players join while game progresses, missing different blind positions
// Expected: Dead blinds reflect missed mandatory positions
const gameInProgress: Hand = {
variant: 'NT',
players: ['Player1', 'Player2'],
startingStacks: [100, 100],
blindsOrStraddles: [1, 2],
seats: [1, 5], // Seats 1 and 5 occupied
antes: [0, 0],
minBet: 2,
seatCount: 9,
actions: ['p1 cc', 'p2 cc', 'd db AhKhQh'],
_inactive: [0, 0],
_intents: [0, 0],
_deadBlinds: [0, 0],
};
// Player3 joins at seat 3 (between the blinds)
const player3Joins = {
...gameInProgress,
author: 'Player3',
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 150],
blindsOrStraddles: [1, 2, 0],
seats: [1, 5, 3],
antes: [0, 0, 0],
_inactive: [0, 0, 0],
_intents: [0, 0, 0], // Play immediately
_deadBlinds: [0, 0, 0],
};
let currentHand = Hand.merge(gameInProgress, player3Joins);
expect(currentHand.seats).toEqual([1, 5, 3]);
expect(currentHand._inactive).toEqual([0, 0, 2]);
// Player3 at seat 3 will need to pay dead blinds
// After sort by seats: [1,3,5] -> [P1, P3, P2]
// Current blinds [1,2] on P1,P2 -> after rotation will be [0,0,1,2] pattern
expect(currentHand._deadBlinds?.[2]).toBeGreaterThanOrEqual(0); // Some dead blind amount
// Add more actions to simulate hand progression
const moreActions = {
...currentHand,
actions: [...currentHand.actions, 'p1 cc', 'p2 cbr 10', 'p1 f'],
};
// Player4 joins at seat 7
const player4Joins = {
...moreActions,
author: 'Player4',
players: ['Player1', 'Player2', 'Player3', 'Player4'],
startingStacks: [100, 100, 150, 200],
blindsOrStraddles: [1, 2, 0, 0],
seats: [1, 5, 3, 7],
antes: [0, 0, 0, 0],
_inactive: [0, 0, 1, 0],
_intents: [0, 0, 0, 0], // Play immediately
_deadBlinds: [0, 0, currentHand._deadBlinds?.[2] || 0, 0],
};
currentHand = Hand.merge(moreActions, player4Joins);
expect(currentHand.players.length).toBe(4);
expect(currentHand.seats).toEqual([1, 5, 3, 7]);
expect(currentHand._inactive).toEqual([0, 0, 2, 2]);
// Player4 at seat 7 (after Player2 at seat 5) will have different dead blind calculation
expect(currentHand._deadBlinds?.[3]).toBeGreaterThanOrEqual(0);
});
it('should handle full table scenario with sequential joins', () => {
// Scenario: Table fills up to maximum capacity
// Input: Players join until table is full (6-max)
// Expected: All joins processed correctly, no overflow
const twoPlayerTable: Hand = {
variant: 'NT',
players: ['Player1', 'Player2'],
startingStacks: [100, 100],
blindsOrStraddles: [1, 2],
antes: [0, 0],
minBet: 2,
seatCount: 6, // 6-max table
actions: [],
_inactive: [0, 0],
_intents: [0, 0],
_deadBlinds: [0, 0],
};
let currentHand: Hand = twoPlayerTable;
// Add players 3-6 sequentially
const newPlayers = ['Player3', 'Player4', 'Player5', 'Player6'];
const stacks = [150, 200, 120, 180];
newPlayers.forEach((playerName, index) => {
const playerCount = currentHand.players.length;
const newHand: Hand = {
...currentHand,
author: playerName,
players: [...currentHand.players, playerName],
startingStacks: [...currentHand.startingStacks, stacks[index]],
blindsOrStraddles: [...currentHand.blindsOrStraddles, 0],
antes: [...currentHand.antes, 0],
_inactive: [...(currentHand._inactive || []), 0],
_intents: [...(currentHand._intents || []), index % 2], // Mix of intents
_deadBlinds: [...(currentHand._deadBlinds || []), 0],
};
currentHand = Hand.merge(currentHand, newHand);
expect(currentHand.players.length).toBe(playerCount + 1);
expect(currentHand.players[playerCount]).toBe(playerName);
});
// Table should now be full
expect(currentHand.players.length).toBe(6);
expect(currentHand.players).toEqual([
'Player1',
'Player2',
'Player3',
'Player4',
'Player5',
'Player6',
]);
// All new players should be inactive
expect(currentHand._inactive).toEqual([0, 0, 2, 2, 2, 2]);
});
});
});
describe('Player intent changes (Pause/Resume/Leave)', () => {
describe('author validation for intent changes', () => {
it('should verify author exists in players array', () => {
// Scenario: Unknown player tries to change intents
// Input: author: 'Player99', not in players array
// Expected: Returns original hand unchanged
const newHand = {
...baseHand,
author: 'Player99',
_intents: [1, 0],
};
const result = Hand.merge(baseHand, newHand);
// Should reject unknown author
expect(result).toBe(baseHand);
expect(result._intents).toEqual([0, 0]);
});
it('should calculate author index from name and validate changes', () => {
// Scenario: Server must verify author modifies only their index
// Input: author: 'Player2' (index 1), tries to change _intents[0]
// Expected: Returns original hand unchanged - wrong index
const newHand = {
...baseHand,
author: 'Player2',
_intents: [1, 0], // Player2 trying to change index 0!
};
const result = Hand.merge(baseHand, newHand);
// Should only allow Player2 to change index 1
expect(result._intents).toEqual([0, 0]);
});
it('should reject any modifications at non-author index', () => {
// Scenario: Player1 (index 0) provides _intents: [0, 2]
// Input: author: 'Player1', but _intents[1] changed
// Expected: Only _intents[0] can be modified, reject change
const newHand = {
...baseHand,
author: 'Player1',
_intents: [0, 2], // Player1 trying to change Player2's intent!
};
const result = Hand.merge(baseHand, newHand);
// Should not allow changing other player's intent
expect(result._intents).toEqual([0, 0]);
});
it('should preserve all non-author indices unchanged', () => {
// Scenario: Ensure other players' intents remain untouched
// Input: author: 'Player2', _intents: [99, 2, 99]
// Expected: Result _intents: [0, 2, 0] - only index 1 modified
const threePlayerHand = {
...baseHand,
players: ['Player1', 'Player2', 'Player3'],
startingStacks: [100, 100, 100],
blindsOrStraddles: [0, 1, 2],
antes: [0, 0, 0],
_intents: [0, 0, 0],
};
const newHand = {
...threePlayerHand,
author: 'Player2',
_intents: [99, 2, 99], // Trying to change all
};
const result = Hand.merge(threePlayerHand, newHand);
// Only Player2's intent should change
expect(result._intents).toEqual([0, 2, 0]);
});
it('should calculate correct author index from player name', () => {
// Scenario: Player2 (index 1) changes their intent
// Input: author: 'Player2', _intents: [0, 1]
// Expected: Only index 1 modified
const newHand = {
...baseHand,
author: 'Player2',
_intents: [0, 1],
};
const result = Hand.merge(baseHand, newHand);
expect(result._intents).toEqual([0, 1]);
});
it('should allow player to change only their own intent by index', () => {
// Scenario: Player1 (index 0) changes their intent
// Input: author: 'Player1', _intents: [2, 0]
// Expected: Only Player1's intent at index 0 updated
const newHand = {
...baseHand,
author: 'Player1',
_intents: [2, 99], // Player1 tries to change both
};
const result = Hand.merge(baseHand, newHand);
// Only author's index should change
expect(result._intents).toEqual([2, 0]);
});
it('should reject intent changes at wrong index', () => {
// Scenario: Player1 tries to modify index 1
// Input: author: 'Player1', _intents: [0, 1]
// Expected: Returns original hand unchanged
const newHand = {
...baseHand,
author: 'Player1'