UNPKG

@idealic/poker-engine

Version:

Poker game engine and hand evaluator

1,292 lines (1,128 loc) 116 kB
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'