UNPKG

@idealic/poker-engine

Version:

Poker game engine and hand evaluator

1,496 lines (1,261 loc) 98.8 kB
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