UNPKG

@idealic/poker-engine

Version:

Poker game engine and hand evaluator

1,555 lines (1,289 loc) 85.1 kB
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as Poker from '../../../index'; import { BASE_HAND } from './fixtures/baseHand'; /** * Data Transformation Tests for Hand API * * Purpose: Test Hand methods that transform data structures: * 1. merge - Intelligently merges two hand states, combining actions * 2. isEqual - Compares hands for equality using deep JSON comparison * 3. personalize - Returns hand from specific player's perspective * * Uses BASE_HAND as reference */ describe('Hand Data Transformation', () => { beforeEach(() => { // Mock system time for consistent timestamp testing vi.setSystemTime(new Date(1715616000000)); }); afterEach(() => { // Restore real time after each test vi.useRealTimers(); }); describe('Hand.merge', () => { // Core hole card processing tests it('should preserve known cards over hidden cards', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AsKs', 'd dh p2 ????', 'p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Bob', // Bob is p2 actions: ['d dh p1 ????', 'd dh p2 QhQd', 'p1 cc', 'p2 f'], }); const merged = Poker.Hand.merge(hand1, hand2); // Should preserve known cards from both hands expect(merged.actions[0]).toBe('d dh p1 AsKs'); expect(merged.actions[1]).toBe('d dh p2 QhQd'); expect(merged.actions[2]).toBe('p1 cc'); expect(merged.actions[3]).toBe('p2 f #1715616000000'); // Bob's action gets timestamp expect(merged.author).toBeUndefined(); expect(merged.actions).toEqual([ 'd dh p1 AsKs', 'd dh p2 QhQd', 'p1 cc', 'p2 f #1715616000000', ]); }); it('should handle sorted card comparison for hole cards', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 KsAs'], // Cards in one order }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Alice', // Alice is p1 actions: ['d dh p1 AsKs'], // Same cards, different order }); const merged = Poker.Hand.merge(hand1, hand2); // Should recognize as same cards after sorting expect(merged.actions).toEqual(['d dh p1 KsAs']); }); // Dealer action validation tests it('should reject dealer hole actions in remaining without allowUnsafeMerge', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 f'], }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 f', 'd dh p3 AsKs'], }); const merged = Poker.Hand.merge(hand1, hand2); // Should reject and return hand1 expect(merged).toEqual(hand1); }); it('should reject dealer board actions in remaining without allowUnsafeMerge', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 f'], }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 f', 'd db AhKhQd'], }); const merged = Poker.Hand.merge(hand1, hand2); // Should reject and return hand1 expect(merged).toEqual(hand1); }); it('should allow dealer actions with allowUnsafeMerge=true', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'd dh p2 AsKs', 'd db AhKhQd'], }); const merged = Poker.Hand.merge(hand1, hand2, true); expect(merged.actions).toEqual(['p1 cc', 'd dh p2 AsKs', 'd db AhKhQd']); }); it('should process multiple hole cards correctly', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 ????', 'd dh p2 JhJd', 'd dh p3 ????', 'p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AsKs', 'd dh p2 ????', 'd dh p3 QhQd', 'p1 cc', 'p2 f'], }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions[0]).toBe('d dh p1 AsKs'); // From hand2 expect(merged.actions[1]).toBe('d dh p2 JhJd'); // From hand1 expect(merged.actions[2]).toBe('d dh p3 QhQd'); // From hand2 expect(merged.actions[3]).toBe('p1 cc'); }); it('should handle hole cards with empty card specification', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1', 'p1 cc'], // No cards specified }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: ['d dh p1 AsKs', 'p1 cc', 'p2 f'], }); const merged = Poker.Hand.merge(hand1, hand2); // Should use cards from hand2 expect(merged.actions[0]).toBe('d dh p1 AsKs'); expect(merged.actions[1]).toBe('p1 cc'); expect(merged.author).toBeUndefined(); expect(merged.actions).toEqual(['d dh p1 AsKs', 'p1 cc', 'p2 f #1715616000000']); // Bob's action gets timestamp }); // Edge cases and special scenarios it('should handle different hand numbers by rejecting merge', () => { const hand1 = Poker.Hand({ ...BASE_HAND, hand: 5, actions: ['p1 f', 'p2 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, hand: 3, actions: ['p1 cbr 100', 'p2 f'], }); const merged = Poker.Hand.merge(hand1, hand2); // Should return hand1 unchanged due to incompatible hand numbers expect(merged).toEqual(hand1); expect(merged.hand).toBe(5); expect(merged.actions).toEqual(['p1 f', 'p2 cc']); }); it('should allow non-dealer actions in remaining', () => { const hand1 = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: ['p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: ['p1 cc', 'p2 cbr 100'], // Bob actions }); let mergedHand = Poker.Hand.merge(hand1, hand2); const hand3 = Poker.Hand({ ...BASE_HAND, author: 'Charlie', actions: ['p1 cc', 'p2 cbr 100', 'p2 m "GL HF"', 'p3 f'], // Charlie actions }); mergedHand = Poker.Hand.merge(hand2, hand3); // Should allow merge with regular actions expect(mergedHand.actions).toEqual([ 'p1 cc', 'p2 cbr 100', 'p2 m "GL HF"', 'p3 f #1715616000000', ]); // Charlie's action gets timestamp }); it('should handle empty actions arrays', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: [], }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: [], }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions).toEqual([]); }); it('should merge when hand1 has empty actions', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: [], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: ['p1 f'], }); let mergedHand = Poker.Hand.merge(hand1, hand2); const hand3 = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: ['p1 f', 'p2 cc'], }); mergedHand = Poker.Hand.merge(mergedHand, hand3); expect(mergedHand.actions).toEqual(['p1 f #1715616000000', 'p2 cc #1715616000000']); // Both actions have timestamps }); it('should merge when hand2 has empty actions', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cbr 50', 'p2 f'], }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: [], }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions).toEqual(['p1 cbr 50', 'p2 f']); }); it('should handle complex real-world merge with dealer actions', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AsKs', 'd dh p2 ????', 'p1 cc', 'p2 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: [ 'd dh p1 ????', 'd dh p2 QhQd', 'p1 cc', 'p2 cc', 'p3 f', 'd db AhKhQd', // Board cards in remaining ], }); // Without allowUnsafeMerge - should reject due to board cards const merged1 = Poker.Hand.merge(hand1, hand2); expect(merged1).toMatchObject({ ...hand1, author: undefined, actions: ['d dh p1 AsKs', 'd dh p2 QhQd', 'p1 cc', 'p2 cc'], }); // With allowUnsafeMerge const merged2 = Poker.Hand.merge(hand1, hand2, true); expect(merged2.actions).toEqual([ 'd dh p1 AsKs', 'd dh p2 QhQd', 'p1 cc', 'p2 cc', 'p3 f', 'd db AhKhQd', ]); }); it('should handle hole cards with sorted comparison', () => { // Cards in different order but same cards const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 KsAs'], // Cards in one order }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AsKs'], // Same cards, different order }); const merged = Poker.Hand.merge(hand1, hand2); // Should recognize as same cards after sorting expect(merged.actions).toEqual(['d dh p1 KsAs']); // Keeps hand1's order }); it('should handle multiple hole card actions for different players', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 ????', 'd dh p2 QhQd', 'd dh p3 ????', 'p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: ['d dh p1 AsKs', 'd dh p2 ????', 'd dh p3 JhJd', 'p1 cc', 'p2 f'], }); const merged = Poker.Hand.merge(hand1, hand2); // Should merge hole cards preserving visibility expect(merged.actions[0]).toBe('d dh p1 AsKs'); expect(merged.actions[1]).toBe('d dh p2 QhQd'); expect(merged.actions[2]).toBe('d dh p3 JhJd'); expect(merged.actions[3]).toBe('p1 cc'); expect(merged.actions[4]).toBe('p2 f #1715616000000'); // Bob's action gets timestamp }); it('should always remove author field from merged result', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 f'], author: 'Alice', }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['p1 f', 'p2 cc'], author: 'Bob', }); const merged = Poker.Hand.merge(hand1, hand2); // Author should always be undefined in merged result expect(merged.author).toBeUndefined(); expect(merged.actions).toEqual(['p1 f', 'p2 cc #1715616000000']); // Bob's action gets timestamp }); it('should reject merge when structural fields differ', () => { // Different _venueIds const hand1 = Poker.Hand({ ...BASE_HAND, _venueIds: ['id1', 'id2', 'id3'], }); const hand2 = Poker.Hand({ ...BASE_HAND, _venueIds: ['id1', 'id2', 'id4'], // Different ID }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); // Different antes const hand3 = Poker.Hand({ ...BASE_HAND, antes: [5, 5, 5], }); const hand4 = Poker.Hand({ ...BASE_HAND, antes: [10, 10, 10], }); const merged2 = Poker.Hand.merge(hand3, hand4); expect(merged2).toEqual(hand3); }); it('should handle empty hole card slots correctly', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: [ 'd dh p1', // No cards specified 'p1 cc', ], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: ['d dh p1 AsKs', 'p1 cc', 'p2 f'], }); const merged = Poker.Hand.merge(hand1, hand2); // Should use cards from hand2 expect(merged.actions[0]).toBe('d dh p1 AsKs'); expect(merged.actions[1]).toBe('p1 cc'); expect(merged.actions[2]).toBe('p2 f #1715616000000'); // Bob's action gets timestamp }); it('should handle stud variant merging correctly', () => { const hand1 = Poker.Hand({ variant: 'F7S', players: ['Alice', 'Bob'], startingStacks: [1000, 1000], blindsOrStraddles: [0, 0], smallBet: 10, bigBet: 20, bringIn: 5, actions: ['p1 cc'], antes: [0, 0], }); const hand2 = Poker.Hand({ variant: 'F7S', players: ['Alice', 'Bob'], startingStacks: [1000, 1000], blindsOrStraddles: [0, 0], smallBet: 10, bigBet: 20, bringIn: 10, // Different bring-in actions: ['p1 cc', 'p2 cbr 20'], antes: [0, 0], }); const merged = Poker.Hand.merge(hand1, hand2); // Should reject due to different bringIn expect(merged).toEqual(hand1); }); }); describe('Hand.merge security tests (Action Diff Security - Step 6)', () => { it('should reject actions from non-author players in diff', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Alice', // Alice is p1 actions: [ 'p1 cc', 'p2 cc', 'p2 cbr 100', // Bob's action, but Alice is author 'p3 f', // Charlie's action, but Alice is author ], }); const merged = Poker.Hand.merge(hand1, hand2); // Should reject because Alice (p1) cannot submit actions for p2 and p3 expect(merged).toEqual(hand1); expect(merged.actions).toEqual(['p1 cc', 'p2 cc']); }); it('should allow only author actions in diff', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Bob', // Bob is p2 actions: [ 'p1 cc', 'p2 cc', 'p3 cc', // Charlie's action 'p1 cbr 100', // Alice's action 'p2 cbr 200', // Bob's action - should be allowed ], }); const merged = Poker.Hand.merge(hand1, hand2); // Should reject because Bob (p2) cannot submit actions for p1 and p3 expect(merged).toEqual(hand1); expect(merged.actions).toEqual(['p1 cc', 'p2 cc']); }); it('should allow messages from any player in diff', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Alice', // Alice is p1 actions: [ 'p1 cc', 'p2 cc', 'p2 m "Good luck!"', // Message from Bob 'p3 m "Have fun!"', // Message from Charlie 'p1 cbr 100', // Alice's action ], }); const merged = Poker.Hand.merge(hand1, hand2); // Messages should be allowed even from non-author players expect(merged.actions).toEqual([ 'p1 cc', 'p2 cc', 'p2 m "Good luck!"', 'p3 m "Have fun!"', 'p1 cbr 100 #1715616000000', // Alice's action gets timestamp ]); expect(merged.author).toBeUndefined(); }); it('should prevent impersonation attempts', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 cc', 'p3 cc'], }); // Alice tries to impersonate Bob const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Alice', // Alice is p1 actions: [ 'p1 cc', 'p2 cc', 'p3 cc', 'p2 cbr 100', // Trying to act as Bob ], }); const merged = Poker.Hand.merge(hand1, hand2); // Should reject impersonation attempt expect(merged).toEqual(hand1); expect(merged.actions).not.toContain('p2 cbr 100'); }); it('should handle author index correctly when author is not first player', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Charlie', // Charlie is p3 (index 2) actions: [ 'p1 cc', 'p3 cc', // Charlie's action 'p1 cbr 100', // Alice's action - causes rejection of all remaining ], }); const merged = Poker.Hand.merge(hand1, hand2); // Should reject all remaining actions because there's a non-author action expect(merged.actions).toEqual(['p1 cc']); }); it('should reject all non-author actions except messages', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Bob', // Bob is p2 actions: [ 'p1 cc', 'p1 cbr 100', // Alice's action - causes rejection of all remaining 'p2 cbr 200', // Bob's action 'p3 f', // Charlie's action 'p1 m "Nice!"', // Message from Alice 'p3 m "GG"', // Message from Charlie ], }); const merged = Poker.Hand.merge(hand1, hand2); // Should reject all remaining actions because there's a non-author non-message action expect(merged.actions).toEqual(['p1 cc']); }); it('should handle undefined author (server state)', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: undefined, // No author (server state) actions: ['p1 cc', 'p2 cc', 'p3 f'], }); const merged = Poker.Hand.merge(hand1, hand2); // When author is undefined, getAuthorPlayerIndex returns -1 // This means no player matches, so non-message actions are rejected expect(merged).toEqual(hand1); }); it('should not allow author actions with allowUnsafeMerge even with other players actions', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: [ 'p1 cc', 'p2 cc', // Bob's action 'p3 f', // Charlie's action 'p1 cbr 100', // Alice's action ], }); const merged = Poker.Hand.merge(hand1, hand2, true); // With allowUnsafeMerge, all actions should be accepted expect(merged.actions).toEqual(hand1.actions); }); it('should ensure author field manipulation prevention', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc'], author: 'Bob', // Old hand has Bob as author }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Alice', // New hand claims Alice as author actions: [ 'p1 cc', 'p2 cc', // Bob's action - causes rejection of all remaining 'p1 cbr 100', // Alice's action ], }); const merged = Poker.Hand.merge(hand1, hand2); // Author field should always be removed from result expect(merged.author).toBeUndefined(); // Should reject all remaining because there's a non-author action expect(merged.actions).toEqual(['p1 cc']); }); it('should handle missing author in new hand', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, // No author field actions: ['p1 cc', 'p2 cc', 'p3 f'], }); const merged = Poker.Hand.merge(hand1, hand2); // Without author, no player actions should be added (getAuthorPlayerIndex returns -1) expect(merged).toEqual(hand1); }); it('should handle author not in players list', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'David', // Not in players list actions: ['p1 cc', 'p2 cc'], }); const merged = Poker.Hand.merge(hand1, hand2); // Author not in players means getAuthorPlayerIndex returns -1 expect(merged).toEqual(hand1); }); it('should handle combined attack vectors', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: [ 'p1 cc', 'p2 cc', 'p2 cbr 100', // Impersonation attempt 'd db AhKhQd', // Dealer action attempt 'p3 f', // Another impersonation 'p1 cbr 200', // Valid author action ], }); // Without allowUnsafeMerge - should block both attacks const merged1 = Poker.Hand.merge(hand1, hand2); expect(merged1).toEqual(hand1); // With allowUnsafeMerge - should not allow merge with author even with allowUnsafeMerge is true const merged2 = Poker.Hand.merge(hand1, hand2, true); expect(merged2).toEqual(hand1); }); it('should allow complex valid merge scenario', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 ????', 'd dh p2 ????', 'p1 cc', 'p2 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Charlie', actions: [ 'd dh p1 AsKs', // Revealing cards 'd dh p2 QhQd', // Revealing cards 'p1 cc', 'p2 cc', 'p3 cbr 100', // Charlie's valid action 'p1 m "Nice bet!"', // Message from Alice 'p2 m "Thinking..."', // Message from Bob ], }); const merged = Poker.Hand.merge(hand1, hand2); // Should merge cards, Charlie's action, and all messages expect(merged.actions).toEqual([ 'd dh p1 AsKs', 'd dh p2 QhQd', 'p1 cc', 'p2 cc', 'p3 cbr 100', 'p1 m "Nice bet!"', 'p2 m "Thinking..." #1715616000000', // Last action gets timestamp (messages don't prevent timestamp) ]); expect(merged.author).toBeUndefined(); }); }); describe('Hand.merge action sequence validation edge cases', () => { it('should reject when newHand has fewer actions than oldHand', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 cc', 'p3 f', 'p1 cbr 100'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: ['p1 cc', 'p2 cc'], // Shorter sequence }); const merged = Poker.Hand.merge(hand1, hand2); // Should return oldHand unchanged when newHand is shorter expect(merged).toEqual(hand1); expect(merged.actions.length).toBe(4); }); it('should handle prefix length edge case in getCommonActions', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 cc', 'p3 f'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: ['p1 cc', 'p2 cc'], // Shorter than hand1 }); const merged = Poker.Hand.merge(hand1, hand2); // When prefixLen <= oldActions.length, getCommonActions returns oldActions expect(merged).toEqual(hand1); expect(merged.actions).toEqual(['p1 cc', 'p2 cc', 'p3 f']); }); it('should reject when actions diverge in the middle', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc', 'p2 cc', 'p3 f'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: ['p1 cc', 'p2 f', 'p3 cc'], // Different from position 1 }); const merged = Poker.Hand.merge(hand1, hand2); // Should return oldHand when sequences diverge expect(merged).toEqual(hand1); }); it('should handle exact same action sequences', () => { const actions = ['p1 cc', 'p2 cc', 'p3 f', 'd db AhKhQd']; const hand1 = Poker.Hand({ ...BASE_HAND, actions: [...actions], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: [...actions], }); const merged = Poker.Hand.merge(hand1, hand2); // Should return same actions but without author expect(merged.actions).toEqual(actions); expect(merged.author).toBeUndefined(); }); }); describe('Hand.merge real-world game progression', () => { it('should build complete game from empty to finished using only merge()', () => { // Start with empty game let gameState = Poker.Hand({ ...BASE_HAND, actions: [], }); // Step 1: Dealer deals hole cards (server/dealer action with allowUnsafeMerge) const dealHoleCards = Poker.Hand({ ...BASE_HAND, author: undefined, // Server has no author actions: [ 'd dh p1 6c5h #1756734331690', 'd dh p2 Jc2s #1756734331691', 'd dh p3 Tc3c #1756734331691', ], }); gameState = Poker.Hand.merge(gameState, dealHoleCards, true); expect(gameState.actions.length).toBe(3); expect(Poker.Hand.isComplete(gameState)).toBe(false); // Step 2: Alice calls const aliceCall1 = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: [ 'd dh p1 6c5h #1756734331690', 'd dh p2 ???? #1756734331691', // Alice doesn't know Bob's cards 'd dh p3 ???? #1756734331691', // Alice doesn't know Charlie's cards 'p1 cc #1756734331691', ], }); gameState = Poker.Hand.merge(gameState, aliceCall1); expect(gameState.actions.length).toBe(4); expect(gameState.actions[3]).toBe('p1 cc #1715616000000'); // Timestamp gets replaced with mocked time // Step 3: Bob calls const bobCall1 = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: [ 'd dh p1 ???? #1756734331690', 'd dh p2 Jc2s #1756734331691', 'd dh p3 ???? #1756734331691', 'p1 cc #1756734331691', 'p2 cc #1756734331691', ], }); gameState = Poker.Hand.merge(gameState, bobCall1); expect(gameState.actions.length).toBe(5); // Step 4: Charlie calls const charlieCall1 = Poker.Hand({ ...BASE_HAND, author: 'Charlie', actions: [ 'd dh p1 ???? #1756734331690', 'd dh p2 ???? #1756734331691', 'd dh p3 Tc3c #1756734331691', 'p1 cc #1756734331691', 'p2 cc #1756734331691', 'p3 cc #1756734331691', ], }); gameState = Poker.Hand.merge(gameState, charlieCall1); expect(gameState.actions.length).toBe(6); // Step 5: Dealer deals flop (server action with allowUnsafeMerge) const dealFlop = Poker.Hand({ ...BASE_HAND, author: undefined, actions: [ 'd dh p1 6c5h #1756734331690', 'd dh p2 Jc2s #1756734331691', 'd dh p3 Tc3c #1756734331691', 'p1 cc #1756734331691', 'p2 cc #1756734331691', 'p3 cc #1756734331691', 'd db 8s2dJs #1756734331691', ], }); gameState = Poker.Hand.merge(gameState, dealFlop, true); expect(gameState.actions.length).toBe(7); // Step 6: Bob checks const bobCheck = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: [...gameState.actions.slice(0, 7), 'p2 cc #1756734331692'], }); gameState = Poker.Hand.merge(gameState, bobCheck); expect(gameState.actions.length).toBe(8); // Step 7: Charlie checks const charlieCheck = Poker.Hand({ ...BASE_HAND, author: 'Charlie', actions: [...gameState.actions.slice(0, 8), 'p3 cc #1756734331692'], }); gameState = Poker.Hand.merge(gameState, charlieCheck); expect(gameState.actions.length).toBe(9); // Step 8: Alice checks const aliceCheck = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: [...gameState.actions.slice(0, 9), 'p1 cc #1756734331692'], }); gameState = Poker.Hand.merge(gameState, aliceCheck); expect(gameState.actions.length).toBe(10); // Step 9: Dealer deals turn const dealTurn = Poker.Hand({ ...BASE_HAND, author: undefined, actions: [...gameState.actions.slice(0, 10), 'd db Kh #1756734331692'], }); gameState = Poker.Hand.merge(gameState, dealTurn, true); expect(gameState.actions.length).toBe(11); // Steps 10-12: All players check on turn const bobCheckTurn = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: [...gameState.actions.slice(0, 11), 'p2 cc #1756734331692'], }); gameState = Poker.Hand.merge(gameState, bobCheckTurn); const charlieCheckTurn = Poker.Hand({ ...BASE_HAND, author: 'Charlie', actions: [...gameState.actions.slice(0, 12), 'p3 cc #1756734331692'], }); gameState = Poker.Hand.merge(gameState, charlieCheckTurn); const aliceCheckTurn = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: [...gameState.actions.slice(0, 13), 'p1 cc #1756734331692'], }); gameState = Poker.Hand.merge(gameState, aliceCheckTurn); expect(gameState.actions.length).toBe(14); // Step 13: Dealer deals river const dealRiver = Poker.Hand({ ...BASE_HAND, author: undefined, actions: [...gameState.actions.slice(0, 14), 'd db Qd #1756734331692'], }); gameState = Poker.Hand.merge(gameState, dealRiver, true); expect(gameState.actions.length).toBe(15); // Steps 14-16: All players check on river const bobCheckRiver = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: [...gameState.actions.slice(0, 15), 'p2 cc #1756734331692'], }); gameState = Poker.Hand.merge(gameState, bobCheckRiver); const charlieCheckRiver = Poker.Hand({ ...BASE_HAND, author: 'Charlie', actions: [...gameState.actions.slice(0, 16), 'p3 cc #1756734331692'], }); gameState = Poker.Hand.merge(gameState, charlieCheckRiver); const aliceCheckRiver = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: [...gameState.actions.slice(0, 17), 'p1 cc #1756734331692'], }); gameState = Poker.Hand.merge(gameState, aliceCheckRiver); expect(gameState.actions.length).toBe(18); // Step 17: Showdown - players show cards const bobShow = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: [...gameState.actions.slice(0, 18), 'p2 sm Jc2s #1756734331692'], }); gameState = Poker.Hand.merge(gameState, bobShow); const charlieShow = Poker.Hand({ ...BASE_HAND, author: 'Charlie', actions: [...gameState.actions.slice(0, 19), 'p3 sm Tc3c #1756734331692'], }); gameState = Poker.Hand.merge(gameState, charlieShow); const aliceShow = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: [...gameState.actions.slice(0, 20), 'p1 sm 6c5h #1756734331692'], }); gameState = Poker.Hand.merge(gameState, aliceShow); // Final verification expect(gameState.actions.length).toBe(21); // Expected actions with mocked timestamps where applicable const expectedActions = [ 'd dh p1 6c5h #1756734331690', 'd dh p2 Jc2s #1756734331691', 'd dh p3 Tc3c #1756734331691', 'p1 cc #1715616000000', // Replaced with mocked timestamp 'p2 cc #1715616000000', // Replaced with mocked timestamp 'p3 cc #1715616000000', // Replaced with mocked timestamp 'd db 8s2dJs #1756734331691', 'p2 cc #1715616000000', // Replaced with mocked timestamp 'p3 cc #1715616000000', // Replaced with mocked timestamp 'p1 cc #1715616000000', // Replaced with mocked timestamp 'd db Kh #1756734331692', 'p2 cc #1715616000000', // Replaced with mocked timestamp 'p3 cc #1715616000000', // Replaced with mocked timestamp 'p1 cc #1715616000000', // Replaced with mocked timestamp 'd db Qd #1756734331692', 'p2 cc #1715616000000', // Replaced with mocked timestamp 'p3 cc #1715616000000', // Replaced with mocked timestamp 'p1 cc #1715616000000', // Replaced with mocked timestamp 'p2 sm Jc2s #1715616000000', // Replaced with mocked timestamp 'p3 sm Tc3c #1715616000000', // Replaced with mocked timestamp 'p1 sm 6c5h #1715616000000', // Replaced with mocked timestamp ]; expect(gameState.actions).toEqual(expectedActions); expect(gameState.author).toBeUndefined(); // Author always removed after merge // Verify hole cards were properly merged expect(gameState.actions[0]).toBe('d dh p1 6c5h #1756734331690'); expect(gameState.actions[1]).toBe('d dh p2 Jc2s #1756734331691'); expect(gameState.actions[2]).toBe('d dh p3 Tc3c #1756734331691'); // To make it complete, we'd need to call Hand.finish() which adds finishingStacks // But since we're testing only merge(), the game progresses correctly to showdown }); }); describe('Hand.merge helper function coverage', () => { // Test areHandsCompatible() through various incompatibility scenarios describe('compatibility checks', () => { it('should reject merge when variants differ', () => { // hand1: NT variant, minBet=20 → BB=20, SB=10 const hand1 = Poker.Hand({ ...BASE_HAND, variant: 'NT' } as Poker.Hand); // hand2: FT variant, smallBet=20 → BB=20, SB=10 const hand2 = Poker.Hand({ ...BASE_HAND, variant: 'FT', smallBet: 20, bigBet: 40, bringIn: undefined, minBet: undefined, }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when venues differ', () => { const hand1 = Poker.Hand({ ...BASE_HAND, venue: 'pokerstars' }); const hand2 = Poker.Hand({ ...BASE_HAND, venue: 'ggpoker' }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when currencies differ', () => { const hand1 = Poker.Hand({ ...BASE_HAND, currency: 'USD' }); const hand2 = Poker.Hand({ ...BASE_HAND, currency: 'EUR' }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when table IDs differ', () => { const hand1 = Poker.Hand({ ...BASE_HAND, table: 'table-1' }); const hand2 = Poker.Hand({ ...BASE_HAND, table: 'table-2' }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when hand IDs differ', () => { const hand1 = Poker.Hand({ ...BASE_HAND, hand: 1 }); const hand2 = Poker.Hand({ ...BASE_HAND, hand: 2 }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when seeds differ', () => { const hand1 = Poker.Hand({ ...BASE_HAND, seed: 12345 }); const hand2 = Poker.Hand({ ...BASE_HAND, seed: 67890 }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when player arrays differ', () => { // minBet=20 → BB=20, SB=10 (heads-up: [SB, BB]) const hand1 = Poker.Hand({ ...BASE_HAND, players: ['Alice', 'Bob'], startingStacks: [1000, 1000], blindsOrStraddles: [10, 20], antes: [0, 0], }); const hand2 = Poker.Hand({ ...BASE_HAND, players: ['Alice', 'Charlie'], startingStacks: [1000, 1000], blindsOrStraddles: [10, 20], antes: [0, 0], }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when starting stacks differ', () => { const hand1 = Poker.Hand({ ...BASE_HAND, startingStacks: [1000, 1000, 1000] }); const hand2 = Poker.Hand({ ...BASE_HAND, startingStacks: [2000, 2000, 2000] }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when blinds differ', () => { // hand1: minBet=20 → BB=20, SB=10 const hand1 = Poker.Hand({ ...BASE_HAND, blindsOrStraddles: [0, 10, 20] }); // hand2: minBet=50 → BB=50, SB=25 const hand2 = Poker.Hand({ ...BASE_HAND, blindsOrStraddles: [0, 25, 50], minBet: 50 }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when antes differ', () => { const hand1 = Poker.Hand({ ...BASE_HAND, antes: [5, 5, 5] }); const hand2 = Poker.Hand({ ...BASE_HAND, antes: [10, 10, 10] }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when _venueIds differ', () => { const hand1 = Poker.Hand({ ...BASE_HAND, _venueIds: ['id1', 'id2', 'id3'] }); const hand2 = Poker.Hand({ ...BASE_HAND, _venueIds: ['id1', 'id2', 'id4'] }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when minBet differs in NT games', () => { // hand1: minBet=20 → BB=20, SB=10 const hand1 = Poker.Hand({ ...BASE_HAND, variant: 'NT', minBet: 20 }); // hand2: minBet=40 → BB=40, SB=20 const hand2 = Poker.Hand({ ...BASE_HAND, variant: 'NT', minBet: 40, blindsOrStraddles: [0, 20, 40] }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should reject merge when smallBet/bigBet differ in FT games', () => { // hand1: smallBet=20 → BB=20, SB=10 const hand1 = Poker.Hand({ variant: 'FT', players: ['A', 'B'], startingStacks: [1000, 1000], blindsOrStraddles: [10, 20], smallBet: 20, bigBet: 40, actions: [], antes: [0, 0], }); // hand2: smallBet=40 → BB=40, SB=20 const hand2 = Poker.Hand({ variant: 'FT', players: ['A', 'B'], startingStacks: [1000, 1000], blindsOrStraddles: [20, 40], smallBet: 40, bigBet: 80, actions: [], antes: [0, 0], }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged).toEqual(hand1); }); it('should allow merge when only optional fields differ', () => { const hand1 = Poker.Hand({ ...BASE_HAND }); const hand2 = Poker.Hand({ ...BASE_HAND, optionalField: 'value' } as any); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions).toEqual(BASE_HAND.actions); }); it('should allow merge when critical fields are undefined in one hand', () => { const hand1 = Poker.Hand({ ...BASE_HAND }); const hand2 = Poker.Hand({ ...BASE_HAND, hand: undefined }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions).toEqual(BASE_HAND.actions); }); }); // Test findCommonPrefixLength() through prefix scenarios describe('common prefix detection', () => { it('should find full prefix when actions are identical', () => { const actions = ['p1 cc', 'p2 f', 'p3 cbr 100']; const hand1 = Poker.Hand({ ...BASE_HAND, actions }); const hand2 = Poker.Hand({ ...BASE_HAND, actions }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions).toEqual(actions); }); it('should handle hole cards in common prefix', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AcKs', 'd dh p2 ????', 'p1 cc'], }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Bob', actions: ['d dh p1 AcKs', 'd dh p2 QhJd', 'p1 cc', 'p2 f'], }); const merged = Poker.Hand.merge(hand1, hand2); // Should preserve known cards and detect common prefix correctly expect(merged.actions).toEqual([ 'd dh p1 AcKs', 'd dh p2 QhJd', 'p1 cc', 'p2 f #1715616000000', ]); // Bob's action gets timestamp }); it('should not treat hole cards for different players as equivalent', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AcKs'] }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AcKs', 'd dh p2 AcKs'] }); const merged = Poker.Hand.merge(hand1, hand2, true); expect(merged.actions).toEqual(['d dh p1 AcKs', 'd dh p2 AcKs']); }); it('should not treat hole cards with different real cards as equivalent', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AcKs'] }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 QhJd'] }); const merged = Poker.Hand.merge(hand1, hand2); // When both have different real cards, they're not equivalent, so no common prefix // Old action is kept, new action with conflicting cards is deduplicated expect(merged.actions).toEqual(['d dh p1 AcKs']); }); it('should handle empty action arrays', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: [] }); const hand2 = Poker.Hand({ ...BASE_HAND, author: 'Alice', actions: ['p1 cc'] }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions).toEqual(['p1 cc #1715616000000']); // Alice's action gets timestamp }); }); // Test selectBestAction() through card visibility scenarios describe('card visibility preference', () => { it('should prefer real cards over hidden cards from old hand', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AcKs'] }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 ????'] }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions).toEqual(['d dh p1 AcKs']); }); it('should use real cards from new hand when old has hidden', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 ????'] }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AcKs'] }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions).toEqual(['d dh p1 AcKs']); }); it('should keep old authoritative cards when both have real cards', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AcKs'] }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 QhJd'] }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions[0]).toBe('d dh p1 AcKs'); }); it('should keep old when both have hidden cards', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 ????'] }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 ????'] }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions).toEqual(['d dh p1 ????']); }); it('should not affect non-hole-card actions', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc'] }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: ['p1 cc'] }); const merged = Poker.Hand.merge(hand1, hand2); expect(merged.actions).toEqual(['p1 cc']); }); }); }); describe('Hand.isEqual', () => { it('should compare identical hands as equal', () => { const hand1 = Poker.Hand(BASE_HAND); const hand2 = Poker.Hand(BASE_HAND); expect(Poker.Hand.isEqual(hand1, hand2)).toBe(true); }); it('should detect differences in actions', () => { const hand1 = Poker.Hand({ ...BASE_HAND, actions: BASE_HAND.actions.slice(0, 5), }); const hand2 = Poker.Hand({ ...BASE_HAND, actions: BASE_HAND.actions.slice(0, 6), }); expect(Poker.Hand.isEqual(hand1, hand2)).toBe(false); }); it('should detect differences in player data', () => { const hand1 = Poker.Hand(BASE_HAND); const hand2 = Poker.Hand({ ...BASE_HAND, players: ['Alice', 'Bob', 'David'], // Changed Charlie to David }); expect(Poker.Hand.isEqual(hand1, hand2)).toBe(false); }); it('should detect differences in numeric fields', () => { // hand1: minBet=20 → BB=20, SB=10 const hand1 = Poker.Hand(BASE_HAND); // hand2: minBet=30 → BB=30, SB=15 const hand2 = Poker.Hand({ ...BASE_HAND, minBet: 30, blindsOrStraddles: [0, 15, 30], }); expect(Poker.Hand.isEqual(hand1, hand2)).toBe(false); }); it('should handle hands with private fields', () => { const hand1 = Poker.Hand({ ...BASE_HAND, _venueIds: ['id1', 'id2', 'id3'], }); const hand2 = Poker.Hand({ ...BASE_HAND, _venueIds: ['id1', 'id2', 'id3'], }); expect(Poker.Hand.isEqual(hand1, hand2)).toBe(true); const hand3 = Poker.Hand({ ...BASE_HAND, _venueIds: ['id1', 'id2', 'id4'], // Different ID }); expect(Poker.Hand.isEqual(hand1, hand3)).toBe(false); }); it('should use deep JSON serialization comparison', () => { const hand1 = Poker.Hand({ ...BASE_HAND, metadata: { nested: { value: 1 } }, } as any); const hand2 = Poker.Hand({ ...BASE_HAND, metadata: { nested: { value: 1 } }, } as any); const hand3 = Poker.Hand({ ...BASE_HAND, metadata: { nested: { value: 2 } }, } as any); expect(Poker.Hand.isEqual(hand1, hand2)).toBe(true); expect(Poker.Hand.isEqual(hand1, hand3)).toBe(false); }); }); describe('Hand.personalize', () => { it('should return full hand when no player specified', () => { const hand = Poker.Hand(BASE_HAND); const personalized = Poker.Hand.personalize(hand); expect(personalized).toEqual(hand); }); it('should hide other players hole cards', () => { const hand = Poker.Hand({ ...BASE_HAND, actions: [ 'd dh p1 AsKs #1700000000000', 'd dh p2 QhQd #1700000001000', 'd dh p3 JhJd #1700000002000', ], }); const aliceView = Poker.Hand.personalize(hand, 'Alice'); // Alice should see her cards expect(aliceView.actions[0]).toBe('d dh p1 AsKs #1700000000000'); // But not others' cards expect(aliceView.actions[1]).toBe('d dh p2 ???? #1700000001000'); expect(aliceView.actions[2]).toBe('d dh p3 ???? #1700000002000'); }); it('should show cards that were shown at showdown', () => { const hand = Poker.Hand({ ...BASE_HAND, actions: [ 'd dh p1 AsKs #1700000000000', 'd dh p2 QhQd #1700000001000', 'p1 sm AsKs #1700000010000', // Player 1 shows 'p2 sm QhQd #1700000011000', // Player 2 shows ], }); const bobView = Poker.Hand.personalize(hand, 'Bob'); // Bob sees his own cards expect(bobView.actions[1]).toBe('d dh p2 QhQd #1700000001000'); // Bob doesn't see Alice's hole cards initially expect(bobView.actions[0]).toBe('d dh p1 ???? #1700000000000'); // But sees shown cards expect(bobView.actions[2]).toBe('p1 sm AsKs #1700000010000'); expect(bobView.actions[3]).toBe('p2 sm QhQd #1700000011000'); }); it('should work with numeric player identifier', () => { const hand = Poker.Hand({ ...BASE_HAND, actions: ['d dh p1 AsKs #1700000000000', 'd dh p2 QhQd #1700000001000'], }); const player0View = Poker.Hand.personalize(hand, 0); // Player 0 (Alice) sees her cards expect(player0View.actions[0]).toBe('d dh p1 AsKs #1700000000000'); // But not player 1's cards expect(player0View.actions[1]).toBe('d dh p2 ???? #1700000001000'); }); it('should preserve all other actions unchanged', () => { const hand = Poker.Hand({ ...BASE_HAND, actions: [ 'd dh p1 AsKs #1700000000000', 'p1 cbr 60 #1700000005000', 'd db AhKh7d #1700000006000', 'p1 f #1700000007000', ], }); const bobView = Poker.Hand.personalize(hand, 'Bob'); // Hole cards hidden expect(bobView.actions[0]).toBe('d dh p1 ???? #1700000000000'); // All other actions unchanged expect(bobView.actions[1]).toBe('p1 cbr 60 #1700000005000'); expect(bobView.actions[2]).toBe('d db AhKh7d #1700000006000'); expect(bobView.actions[3]).toBe('p1 f #1700000007000'); }); it('should set author field to perspective player', () => { const hand = Poker.Hand(BASE_HAND); const aliceView = Poker.Hand.personalize(hand, 'Alice'); expect(aliceView.author).toBe('Alice'); const bobView = Poker.Hand.personalize(hand, 1); expect(bobView.author).toBe('Bob'); }); }); describe('Integration: merge() to next() flow for Sit In/Out', () => { describe('player