@idealic/poker-engine
Version:
Poker game engine and hand evaluator
350 lines (278 loc) • 12.6 kB
text/typescript
import { describe, expect, it } from 'vitest';
import { Hand } from '../../Hand';
import { getActionCards } from '../../game/position';
const BASE_HAND = {
variant: 'NT' as const,
minBet: 20,
hand: 1,
antes: [0, 0, 0],
blindsOrStraddles: [10, 20, 0],
startingStacks: [1000, 1000, 1000],
players: ['Hero', 'Villain', 'BB'],
actions: [],
};
let hand: Hand;
beforeEach(() => {
// Mock system time for consistent timestamp testing
vi.setSystemTime(new Date(1715616000000));
hand = Hand(BASE_HAND);
});
afterEach(() => {
// Restore real time after each test
vi.useRealTimers();
});
describe('mergeGames', () => {
it('appends unique actions from the new game after common prefix', () => {
// Using real poker actions with proper author
const oldHand = Hand({ ...hand, actions: ['p1 cc', 'p2 cc'] });
const newHand = Hand({
...hand,
author: 'BB', // BB is p3, adding their action
actions: ['p1 cc', 'p2 cc', 'p3 cbr 100', 'p1 f'],
});
// Note: 'p3 cbr 100', 'p1 f' won't be added because BB (p3) cannot submit any other player's action
const merged = Hand.merge(oldHand, newHand);
// Merge failed, because BB (p3) cannot submit any other player's action
expect(merged.actions).toEqual(['p1 cc', 'p2 cc']);
});
it('appends actions when using allowUnsafeMerge for generic testing', () => {
const oldHand = Hand({ ...hand, actions: ['a1', 'a2'] });
const newHand = Hand({ ...hand, actions: ['a1', 'a2', 'a3', 'a4'] });
// Use allowUnsafeMerge for generic test actions
const merged = Hand.merge(oldHand, newHand, true);
// Merge succeeded, because allowUnsafeMerge is true
// and all actions are allowed
expect(merged.actions).toEqual(['a1', 'a2', 'a3', 'a4']);
});
it('always removes author field from merged hands', () => {
// Test with different authors - using real poker actions with 3 players
const oldHand = Hand({ ...hand, author: 'Hero', actions: ['p1 cc'] });
const newHand = Hand({ ...hand, author: 'Villain', actions: ['p1 cc', 'p2 cbr 100'] });
const merged = Hand.merge(oldHand, newHand);
expect(merged.author).toBeUndefined();
expect(merged.actions).toEqual(['p1 cc', 'p2 cbr 100 #1715616000000']); // Villain's action gets timestamp
// Test with same authors
const oldHand2 = Hand({ ...hand, author: 'BB', actions: ['p1 cc', 'p2 cc'] });
const newHand2 = Hand({ ...hand, author: 'BB', actions: ['p1 cc', 'p2 cc', 'p3 cbr 100'] });
const merged2 = Hand.merge(oldHand2, newHand2);
expect(merged2.author).toBeUndefined();
expect(merged2.actions).toEqual(['p1 cc', 'p2 cc', 'p3 cbr 100 #1715616000000']); // BB's action gets timestamp
// Test with only one having author
const oldHand3 = Hand({ ...hand, actions: ['p3 cc'] });
const newHand3 = Hand({ ...hand, author: 'Hero', actions: ['p3 cc', 'p1 cbr 100'] });
const merged3 = Hand.merge(oldHand3, newHand3);
expect(merged3.author).toBeUndefined();
expect(merged3.actions).toEqual(['p3 cc', 'p1 cbr 100 #1715616000000']); // Hero's action gets timestamp
});
it('keeps later duplicate actions that represent distinct events', () => {
const baseActions = [
'd dh p1 Qc9s',
'd dh p2 9c3d',
'd dh p3 3h9d',
'p3 cc',
'p1 cc',
'p2 cc',
'd db Kh4dKs',
];
const oldHand = Hand({ ...hand, actions: baseActions });
const newHand = Hand({
...hand,
author: 'Hero', // Hero is p1, adding their check
actions: [...baseActions, 'p1 cc'],
});
const merged = Hand.merge(oldHand, newHand);
expect(merged.actions).toEqual([...baseActions, 'p1 cc #1715616000000']); // Hero's action gets timestamp
});
describe('card visibility merging', () => {
it('preserves real cards when merging with hidden cards', () => {
// Server has real cards, client has hidden cards for other players
const serverActions = ['d dh p1 AcKs', 'd dh p2 QhJd', 'd dh p3 Tc9h'];
// Client perspective from p1 - knows own cards, others are hidden
const clientActions = ['d dh p1 AcKs', 'd dh p2 ????', 'd dh p3 ????'];
const serverHand = Hand({ ...hand, actions: serverActions });
const clientHand = Hand({ ...hand, actions: clientActions });
const merged = Hand.merge(serverHand, clientHand);
// Should preserve server's real cards
expect(merged.actions).toEqual(serverActions);
});
it('fills in missing card information when new hand has real cards', () => {
// Old state has hidden cards
const oldActions = ['d dh p1 ????', 'd dh p2 QhJd', 'd dh p3 Tc9h'];
// New state reveals p1's cards
const newActions = ['d dh p1 AcKs', 'd dh p2 QhJd', 'd dh p3 Tc9h'];
const oldHand = Hand({ ...hand, actions: oldActions });
const newHand = Hand({ ...hand, actions: newActions });
const merged = Hand.merge(oldHand, newHand);
// Should use new hand's real cards to fill in missing info
expect(merged.actions).toEqual(newActions);
});
it('keeps authoritative cards when both have real but conflicting cards', () => {
// Old (authoritative) has one set of real cards
const oldActions = ['d dh p1 AcKs', 'd dh p2 QhJd', 'd dh p3 Tc9h'];
// New has different real cards (shouldn't happen in practice but handles edge case)
const newActions = [
'd dh p1 2c3s', // Different cards!
'd dh p2 QhJd',
'd dh p3 Tc9h',
];
const oldHand = Hand({ ...hand, actions: oldActions });
const newHand = Hand({ ...hand, actions: newActions });
const merged = Hand.merge(oldHand, newHand);
// Should keep old (authoritative) cards
expect(getActionCards(merged.actions[0])).toEqual(['Ac', 'Ks']);
expect(getActionCards(merged.actions[1])).toEqual(['Qh', 'Jd']);
expect(getActionCards(merged.actions[2])).toEqual(['Tc', '9h']);
});
it('maintains consistency when both have hidden cards', () => {
// Both states have hidden cards
const oldActions = ['d dh p1 AcKs', 'd dh p2 ????', 'd dh p3 ????'];
const newActions = ['d dh p1 AcKs', 'd dh p2 ????', 'd dh p3 ????'];
const oldHand = Hand({ ...hand, actions: oldActions });
const newHand = Hand({ ...hand, actions: newActions });
const merged = Hand.merge(oldHand, newHand);
// Should keep old hand's version
expect(merged.actions).toEqual(oldActions);
});
it('correctly merges hands with mixed card visibility and additional actions', () => {
// Complex scenario with card visibility and new actions
const serverActions = ['d dh p1 AcKs', 'd dh p2 QhJd', 'd dh p3 Tc9h', 'p3 cc', 'p1 r 100'];
// Client sees own cards, others hidden, and adds new action
const clientActions = [
'd dh p1 AcKs',
'd dh p2 ????',
'd dh p3 ????',
'p3 cc',
'p1 r 100',
'p2 f', // New action from client (Villain folds)
];
const serverHand = Hand({ ...hand, actions: serverActions });
const clientHand = Hand({
...hand,
author: 'Villain', // Villain is p2, adding their fold
actions: clientActions,
});
const merged = Hand.merge(serverHand, clientHand);
// Should preserve server's real cards and add new action
expect(merged.actions).toEqual([
'd dh p1 AcKs',
'd dh p2 QhJd',
'd dh p3 Tc9h',
'p3 cc',
'p1 r 100',
'p2 f #1715616000000', // Villain's action gets timestamp
]);
});
it('handles equivalent hole card actions with same player and card count', () => {
// Test the equivalence logic for hole cards
const oldActions = ['d dh p1 AcKs', 'd dh p2 ????', 'p1 cc'];
const newActions = [
'd dh p1 AcKs',
'd dh p2 ????', // Same hidden cards
'p1 cc',
'p2 cc', // New action
];
const oldHand = Hand({ ...hand, actions: oldActions });
const newHand = Hand({
...hand,
author: 'Villain', // Villain is p2, adding their check
actions: newActions,
});
const merged = Hand.merge(oldHand, newHand);
expect(merged.actions).toEqual([
'd dh p1 AcKs',
'd dh p2 ????',
'p1 cc',
'p2 cc #1715616000000',
]); // Villain's action gets timestamp
});
});
describe('security and author validation', () => {
it('rejects actions from non-author players', () => {
const oldHand = Hand({ ...hand, actions: ['p1 cc', 'p2 cc'] });
const newHand = Hand({
...hand,
hand: 1,
author: 'Hero', // Hero is p1
actions: ['p1 cc', 'p2 cc', 'p2 cbr 100'], // Trying to add p2's action
});
const merged = Hand.merge(oldHand, newHand);
// Should reject p2's action since Hero (p1) cannot submit it
expect(merged.actions).toEqual(['p1 cc', 'p2 cc']);
});
it('allows messages from any player', () => {
const oldHand = Hand({ ...hand, actions: ['p1 cc'] });
const newHand = Hand({
...hand,
author: 'Hero', // Hero is p1
actions: [
'p1 cc',
'p2 m "Nice hand!"', // Message from p2
'p3 m "Good luck!"', // Message from p3
'p1 cbr 100', // Hero's action
],
});
const merged = Hand.merge(oldHand, newHand);
// Messages should be allowed even from non-author players
expect(merged.actions).toEqual([
'p1 cc',
'p2 m "Nice hand!"',
'p3 m "Good luck!"',
'p1 cbr 100 #1715616000000', // Hero's action gets timestamp
]);
});
it('requires allowUnsafeMerge for dealer actions', () => {
const oldHand = Hand({ ...hand, actions: ['p1 cc', 'p2 cc', 'p3 cc'] });
// Try to add board cards without allowUnsafeMerge
const newHand1 = Hand({
...hand,
author: undefined, // Server has no author
actions: ['p1 cc', 'p2 cc', 'p3 cc', 'd db AhKhQh'],
});
const merged1 = Hand.merge(oldHand, newHand1);
expect(merged1.actions).toEqual(['p1 cc', 'p2 cc', 'p3 cc']); // Rejected
// Now with allowUnsafeMerge
const merged2 = Hand.merge(oldHand, newHand1, true);
expect(merged2.actions).toEqual(['p1 cc', 'p2 cc', 'p3 cc', 'd db AhKhQh']); // Accepted
});
it('ignores allowUnsafeMerge when author is set (security enforcement)', () => {
const oldHand = Hand({ ...hand, actions: ['p1 cc', 'p2 cc', 'p3 cc'] });
// Try to add dealer actions with author set AND allowUnsafeMerge=true
const newHandWithAuthor = Hand({
...hand,
author: 'Hero', // Author is set
actions: ['p1 cc', 'p2 cc', 'p3 cc', 'd db AhKhQh'],
});
// Even with allowUnsafeMerge=true, dealer actions should be rejected when author is set
const merged = Hand.merge(oldHand, newHandWithAuthor, true);
expect(merged.actions).toEqual(['p1 cc', 'p2 cc', 'p3 cc']); // Rejected despite allowUnsafeMerge=true
// Also test with hole cards
const oldHand2 = Hand({ ...hand, actions: [] });
const newHandWithHoleCards = Hand({
...hand,
author: 'Villain', // Author is set
actions: ['d dh p1 AcKs', 'd dh p2 QhJd', 'd dh p3 Tc9h'],
});
const merged2 = Hand.merge(oldHand2, newHandWithHoleCards, true);
expect(merged2.actions).toEqual([]); // Rejected despite allowUnsafeMerge=true
});
it('handles multiple players requiring separate merges', () => {
let gameState = Hand({ ...hand, actions: [] });
// Hero's action
const heroAction = Hand({ ...hand, author: 'Hero', actions: ['p1 cc'] });
gameState = Hand.merge(gameState, heroAction);
expect(gameState.actions).toEqual(['p1 cc #1715616000000']); // Hero's action gets timestamp
// Villain's action
const villainAction = Hand({ ...hand, author: 'Villain', actions: ['p1 cc', 'p2 cbr 100'] });
gameState = Hand.merge(gameState, villainAction);
expect(gameState.actions).toEqual(['p1 cc #1715616000000', 'p2 cbr 100 #1715616000000']); // Villain's action also gets timestamp
// BB's action
const bbAction = Hand({ ...hand, author: 'BB', actions: ['p1 cc', 'p2 cbr 100', 'p3 f'] });
gameState = Hand.merge(gameState, bbAction);
expect(gameState.actions).toEqual([
'p1 cc #1715616000000',
'p2 cbr 100 #1715616000000',
'p3 f #1715616000000',
]); // BB's action also gets timestamp
});
});
});