import { describe, expect, test } from 'bun:test'; import { SeededRng, buildPots, randomPlayout, seedN, type PlayerId } from '@bg/engine'; import { deck52, deckD20, type CardId } from './cards'; import { evaluate } from './showdown'; import { awardHiLo } from './showdown'; import { chipsBalanced, poker, setupPoker, type FixedHand, type HandPlayer, type PokerAction, type PokerOptions, type PokerState, type PokerView, type Side } from './index'; const NAMES = 'ABCDEFGHIJ'.split(''); let clock = 1_000; const RNG = () => SeededRng.fromSeed([7, 7, 7, 7]); function mk(opts: Partial, n: number, fixed?: FixedHand, seed = 1): PokerState { const options = poker.optionsSchema.parse(opts); return setupPoker({ players: NAMES.slice(0, n), options, rng: SeededRng.fromSeed(seedN(seed)), now: clock }, fixed); } function act(s: PokerState, who: PlayerId, a: PokerAction): PokerState { const v = poker.validate(s, who, a); if (!v.ok) throw new Error(`${who} ${JSON.stringify(a)} rejected: ${v.reason}`); clock += 1000; return poker.apply(s, who, a, { rng: RNG(), now: clock }).state; } function reason(s: PokerState, who: PlayerId, a: PokerAction): string { const v = poker.validate(s, who, a); if (v.ok) throw new Error(`expected rejection of ${JSON.stringify(a)}`); return v.reason; } const view = (s: PokerState, who: PlayerId | null): PokerView => poker.view(s, who); const legal = (s: PokerState, who: PlayerId) => view(s, who).my.legal.map((l) => l.type); const toAct = (s: PokerState) => view(s, null).toAct; const stack = (s: PokerState, id: PlayerId) => s.seats.find((x) => x.id === id)!.stack; /** Deck with `first` on top (dealt in this order), then the remaining cards of the deck. */ const deckOf = (first: string, full: CardId[] = deck52()): CardId[] => { const top = first.trim().split(/\s+/).map((c) => c.replace('10', 'T')); expect(new Set(top).size).toBe(top.length); return [...top, ...full.filter((c) => !top.includes(c))]; }; function readyAll(s: PokerState): PokerState { for (const id of view(s, null).waiting) s = act(s, id, { type: 'ready' }); return s; } /** Everyone checks (korean) until the betting round ends. */ function checkAround(s: PokerState, type: 'k.check' | 'nl.check' = 'k.check'): PokerState { const no = s.hand!.no; const street = s.hand!.street; while (s.hand!.no === no && s.hand!.phase === 'betting' && s.hand!.street === street && toAct(s)) s = act(s, toAct(s)!, { type }); return s; } function allCards(s: PokerState): CardId[] { const h = s.hand!; const out = [...h.deck, ...h.muck, ...h.board]; for (const p of Object.values(h.players)) out.push(...p.hole, ...p.up, ...(p.choice ?? []), ...(p.discarded ? [p.discarded] : []), ...(p.forehead ? [p.forehead] : [])); return out; } // ------------------------------------------------------------------ options / setup describe('options and players', () => { test('player range per mode (playersFor)', () => { const r = (o: Partial) => poker.playersFor!(poker.optionsSchema.parse(o)); expect(r({ mode: 'holdem' })).toEqual({ min: 2, max: 10 }); expect(r({ mode: 'omaha' })).toEqual({ min: 2, max: 10 }); expect(r({ mode: 'sevenPoker' }).max).toBe(6); expect(r({ mode: 'sevenHiLo' }).max).toBe(6); expect(r({ mode: 'badugi' }).max).toBe(6); expect(r({ mode: 'fiveDraw' }).max).toBe(6); expect(r({ mode: 'indian' }).max).toBe(6); expect(r({ mode: 'indian', indianDeck: 'd52' }).max).toBe(10); expect(r({ mode: 'indian', indianDeck: 'd52', indianCards: 2 }).max).toBe(6); expect(() => mk({ mode: 'sevenPoker' }, 7)).toThrow(); }); test('base unit must be at most 1/20 of the starting chips; every option has a title', () => { expect(poker.optionsSchema.safeParse({ startingChips: 500, baseUnit: 50 }).success).toBe(false); expect(poker.optionsSchema.safeParse({ startingChips: 1000, baseUnit: 50 }).success).toBe(true); const shape = (poker.optionsSchema as unknown as { shape?: Record }).shape; if (shape) for (const [k, f] of Object.entries(shape)) expect(f.meta()?.title, k).toBeTruthy(); }); test('defaults: 7포커, korean betting via auto', () => { const s = mk({}, 3); expect(s.options.mode).toBe('sevenPoker'); expect(s.betStyle).toBe('korean'); expect(mk({ mode: 'holdem' }, 3).betStyle).toBe('noLimit'); expect(mk({ mode: 'omaha' }, 3).betStyle).toBe('potLimit'); expect(mk({ mode: 'indian' }, 3).betStyle).toBe('noLimit'); expect(mk({ mode: 'sevenPoker', betting: 'noLimit' }, 3).betStyle).toBe('noLimit'); }); }); // ------------------------------------------------------------------ korean betting describe('korean betting (§5.2)', () => { test('§5.2 example: ante 40 → ping 10 → half 40 → ddadang 80 → die → call 70 → call 40 = 280, round over', () => { let s = mk({ mode: 'badugi' }, 4, { dealerSeat: 3 }); expect(view(s, null).pot).toBe(40); expect(toAct(s)).toBe('A'); s = act(s, 'A', { type: 'k.ping' }); expect(view(s, 'B').my.legal.find((l) => l.type === 'k.half')!.amount).toBe(40); s = act(s, 'B', { type: 'k.half' }); expect(view(s, null).pot).toBe(90); expect(view(s, 'C').my.legal.find((l) => l.type === 'k.ddadang')!.amount).toBe(80); s = act(s, 'C', { type: 'k.ddadang' }); expect(view(s, null).pot).toBe(170); s = act(s, 'D', { type: 'k.die' }); expect(legal(s, 'A')).toEqual(['k.call', 'k.die']); expect(reason(s, 'A', { type: 'k.half' })).toBe('이번 라운드에서는 더 올릴 수 없어요(콜 또는 다이만 가능).'); expect(view(s, 'A').my.toCall).toBe(70); s = act(s, 'A', { type: 'k.call' }); expect(view(s, null).pot).toBe(240); s = act(s, 'B', { type: 'k.call' }); expect(view(s, null).pot).toBe(280); expect(s.hand!.phase).toBe('draw'); expect(stack(s, 'A') + stack(s, 'B') + stack(s, 'C')).toBe(3000); // seats keep pre-hand stacks until settled expect(view(s, null).seats.map((x) => x.stack)).toEqual([910, 910, 910, 990]); // 1000 - ante 10 - 80 expect(chipsBalanced(s)).toBe(true); }); test('a player who called or checked cannot raise later in the round', () => { let s = mk({ mode: 'badugi' }, 3, { dealerSeat: 2 }); s = act(s, 'A', { type: 'k.ping' }); s = act(s, 'B', { type: 'k.call' }); s = act(s, 'C', { type: 'k.half' }); s = act(s, 'A', { type: 'k.call' }); expect(reason(s, 'B', { type: 'k.half' })).toBe('이번 라운드에서는 더 올릴 수 없어요(콜 또는 다이만 가능).'); s = act(s, 'B', { type: 'k.call' }); expect(s.hand!.phase).toBe('draw'); let t = mk({ mode: 'badugi' }, 3, { dealerSeat: 2 }); t = act(t, 'A', { type: 'k.check' }); t = act(t, 'B', { type: 'k.ping' }); t = act(t, 'C', { type: 'k.call' }); expect(reason(t, 'A', { type: 'k.half' })).toBe('이번 라운드에서는 더 올릴 수 없어요(콜 또는 다이만 가능).'); expect(reason(t, 'A', { type: 'k.check' })).toBe('콜 금액이 있어서 체크할 수 없어요.'); }); test('full is refused under half rule and allowed with full', () => { const s = mk({ mode: 'badugi' }, 3, { dealerSeat: 2 }); expect(reason(s, 'A', { type: 'k.full' })).toBe('이 방은 하프까지만 올릴 수 있어요.'); expect(legal(s, 'A')).toEqual(['k.check', 'k.ping', 'k.quarter', 'k.half', 'k.die']); const f = mk({ mode: 'badugi', koreanMaxRaise: 'full' }, 3, { dealerSeat: 2 }); expect(view(f, 'A').my.legal.find((l) => l.type === 'k.full')!.amount).toBe(30); expect(act(f, 'A', { type: 'k.full' }).hand!.bet.currentBet).toBe(30); }); test('turn and style checks', () => { const s = mk({ mode: 'badugi' }, 3, { dealerSeat: 2 }); expect(reason(s, 'B', { type: 'k.check' })).toBe('지금은 내 차례가 아니에요.'); expect(reason(s, 'A', { type: 'nl.check' })).toBe('이 방은 한국식 베팅이에요.'); expect(reason(s, 'A', { type: 'k.call' })).toBe('낼 콜 금액이 없어요. 체크해 주세요.'); expect(reason(s, 'Z', { type: 'k.check' })).toBe('이 게임의 플레이어가 아니에요.'); }); test('timeout: auto check when nobody has bet, auto die facing a bet', () => { let s = mk({ mode: 'badugi' }, 3, { dealerSeat: 2 }); expect(poker.onTimeout(s, 'A')).toEqual({ type: 'timeout' }); s = act(s, 'A', { type: 'timeout' }); expect(s.hand!.players.A!.lastAction).toBe('체크'); s = act(s, 'B', { type: 'k.ping' }); s = act(s, 'C', { type: 'timeout' }); expect(s.hand!.players.C!.lastAction).toBe('다이'); expect(view(s, null).seats[2]!.folded).toBe(true); expect(reason(s, 'B', { type: 'timeout' })).toBe('지금은 시간 초과를 적용할 수 없어요.'); }); test('two timeouts in a row → away (5s turns); acting again clears it', () => { let s = mk({ mode: 'badugi' }, 3, { dealerSeat: 2 }); expect(s.deadlineAt! - clock).toBe(20_000); s = act(s, 'A', { type: 'timeout' }); // check s = act(s, 'B', { type: 'k.check' }); s = act(s, 'C', { type: 'k.check' }); expect(s.hand!.phase).toBe('draw'); expect(s.deadlineAt! - clock).toBe(30_000); // simultaneous step: turnSeconds + 10 s = act(s, 'A', { type: 'timeout' }); // pass expect(view(s, null).seats[0]!.away).toBe(true); s = act(s, 'B', { type: 'draw', cardIds: [] }); s = act(s, 'C', { type: 'draw', cardIds: [] }); expect(toAct(s)).toBe('A'); expect(s.deadlineAt! - clock).toBe(5_000); expect(view(s, null).deadlineTotalMs).toBe(5_000); s = act(s, 'A', { type: 'k.check' }); expect(view(s, null).seats[0]!.away).toBe(false); }); }); // ------------------------------------------------------------------ 7포커 /** 3 players, dealer C → order A, B, C. B's open cards make a pair (보스). */ const SEVEN_DECK = '2C 8S KD 5D 8C 6S 9H 3D 7C JS 4H QH 3S 8H 4C'; function sevenStart(opts: Partial = {}): PokerState { return mk({ mode: 'sevenPoker', ...opts }, 3, { dealerSeat: 2, deck: deckOf(SEVEN_DECK) }); } function chooseAll(s: PokerState): PokerState { s = act(s, 'A', { type: 'choose', discardId: '2C', openId: '5D' }); s = act(s, 'B', { type: 'choose', discardId: '3D', openId: '8S' }); return act(s, 'C', { type: 'choose', discardId: '6S', openId: 'KD' }); } describe('7포커 (§5.4.3)', () => { test('choice is simultaneous: an open card stays hidden until everyone chose', () => { let s = sevenStart(); expect(s.hand!.phase).toBe('choice'); expect(view(s, 'A').my.choice).toEqual(['2C', '5D', '9H', 'JS']); expect(view(s, 'B').seats[0]!.choiceCount).toBe(4); expect(JSON.stringify(view(s, 'B'))).not.toContain('"2C"'); s = act(s, 'A', { type: 'choose', discardId: '2C', openId: '5D' }); expect(reason(s, 'A', { type: 'choose', discardId: '9H', openId: 'JS' })).toBe('이미 골랐어요.'); const vb = view(s, 'B'); expect(vb.seats[0]!.choiceDone).toBe(true); expect(vb.seats[0]!.up).toEqual([]); expect(JSON.stringify(vb)).not.toContain('"5D"'); expect(JSON.stringify(view(s, null))).not.toContain('"5D"'); s = act(s, 'B', { type: 'choose', discardId: '3D', openId: '8S' }); s = act(s, 'C', { type: 'choose', discardId: '6S', openId: 'KD' }); const v = view(s, 'B'); expect(v.seats.map((x) => x.up)).toEqual([ ['5D', '3S'], ['8S', '8H'], ['KD', '4C'], ]); expect(v.seats[0]!.hole).toEqual([null, null]); expect(view(s, 'A').my.discarded).toBe('2C'); expect(JSON.stringify(v)).not.toContain('"2C"'); expect(view(s, 'A').my.handName).toBe('J탑'); expect(view(s, 'B').my.handName).toBe('8 트리플'); }); test('보스: the open pair acts first; after the boss checks, the next may ping and the boss can only call/die', () => { let s = chooseAll(sevenStart()); expect(view(s, null).seats[1]!.boss).toBe(true); expect(toAct(s)).toBe('B'); expect(view(s, null).streetName).toBe('4구'); s = act(s, 'B', { type: 'k.check' }); expect(legal(s, 'C')).toContain('k.ping'); s = act(s, 'C', { type: 'k.ping' }); s = act(s, 'A', { type: 'k.call' }); expect(legal(s, 'B')).toEqual(['k.call', 'k.die']); }); test('everyone checks → next card without betting; 7th card is hidden', () => { let s = chooseAll(sevenStart()); s = checkAround(s); expect(s.hand!.street).toBe(1); expect(view(s, null).pot).toBe(30); expect(view(s, null).seats.every((x) => x.up.length === 3)).toBe(true); s = checkAround(checkAround(s)); expect(view(s, null).streetName).toBe('7구(히든)'); expect(view(s, 'C').seats[0]!.hole).toEqual([null, null, null]); expect(view(s, 'A').seats[0]!.hole.every((c) => c !== null)).toBe(true); s = checkAround(s); expect(s.hand!.phase).toBe('handEnd'); expect(s.hand!.showdown!.length).toBe(3); // showdown reveals everyone's cards (but never the discarded choice card) const v = view(s, null); expect(v.seats[1]!.hole.every((c) => c !== null)).toBe(true); expect(JSON.stringify(v)).not.toContain('"3D"'); expect(v.winners).toEqual(['B']); }); test('choice timeout: discard the lowest card, open the next lowest', () => { let s = sevenStart(); s = act(s, 'B', { type: 'timeout' }); expect(s.hand!.players.B!.choiceSel).toEqual({ discard: '3D', open: '4H' }); }); test('a folded hand is not shown at showdown', () => { let s = chooseAll(sevenStart()); s = act(s, 'B', { type: 'k.ping' }); s = act(s, 'C', { type: 'k.die' }); s = act(s, 'A', { type: 'k.call' }); s = checkAround(checkAround(checkAround(s))); expect(s.hand!.showdown!.map((e) => e.id)).toEqual(['A', 'B']); expect(view(s, 'A').seats[2]!.hole).toEqual([null, null]); expect(view(s, 'C').seats[2]!.hole.every((c) => c !== null)).toBe(true); // own folded cards }); }); // ------------------------------------------------------------------ hi-lo function fake(cards: string): HandPlayer { return { id: 'x', hole: cards.split(' '), up: [], forehead: null, choice: null, choiceSel: null, discarded: null, drawPending: null, drawCounts: [], declare: null, ready: false, lastAction: null }; } describe('7포커 하이로우 (§5.4.4, §6.6)', () => { const o = poker.optionsSchema.parse({ mode: 'sevenHiLo' }); const ev = { A: evaluate(o, fake('KS KD KH 5S 5D 9C 2H'), []), // 풀하우스, no low B: evaluate(o, fake('AS 2D 3C 4H 7S 9D QC'), []), // 7탑 로우 C: evaluate(o, fake('AH 3H 5H 6H 8H 2C KC'), []), // 플러시 + 6탑 로우 D: evaluate(o, fake('QS JD 8C 7D 4S 3S 2S'), []), // Q탑, 8탑 로우 }; const pot = (amount: number, eligible: string[]) => ({ amount, eligible }); const sum = (as: { to: string; amount: number }[]) => Object.fromEntries(as.map((a) => [a.to, as.filter((b) => b.to === a.to).reduce((x, b) => x + b.amount, 0)])); test('evaluations', () => { expect(ev.A.high.name).toBe('K 풀하우스'); expect(ev.A.low).toBeNull(); expect(ev.B.low!.name).toBe('7탑 로우(A-2-3-4-7)'); expect(ev.C.high.name).toBe('A 플러시'); expect(ev.C.low!.name).toBe('6탑 로우(A-2-3-5-6)'); }); test('spec example: swing loses high → out; high and low halve, odd chip to high (101 → 51/50)', () => { const d: Record = { A: 'high', B: 'low', C: 'swing' }; expect(sum(awardHiLo(pot(101, ['A', 'B', 'C']), 0, ['A', 'B', 'C'], ev, d))).toEqual({ A: 51, B: 50 }); }); test('swing failure with no low declarer → high winner takes all', () => { expect(sum(awardHiLo(pot(100, ['A', 'C']), 0, ['A', 'C'], ev, { A: 'high', C: 'swing' }))).toEqual({ A: 100 }); }); test('swing that wins both sides scoops', () => { const r = awardHiLo(pot(90, ['B', 'C', 'D']), 0, ['B', 'C', 'D'], ev, { B: 'low', C: 'swing', D: 'high' }); expect(r).toEqual([{ potIndex: 0, to: 'C', amount: 90, side: 'scoop' }]); }); test('all swings fail → everyone competes on high', () => { expect(sum(awardHiLo(pot(60, ['B', 'C', 'D']), 0, ['B', 'C', 'D'], ev, { B: 'swing', C: 'swing', D: 'swing' }))).toEqual({ C: 60 }); }); test('low declarer without a valid low gets nothing; eight qualifier', () => { expect(sum(awardHiLo(pot(40, ['A', 'D']), 0, ['A', 'D'], ev, { A: 'low', D: 'high' }))).toEqual({ D: 40 }); const o8 = poker.optionsSchema.parse({ mode: 'sevenHiLo', hiLoQualifier: 'eight' }); expect(evaluate(o8, fake('AS 2D 3C 4H 9S KD QC'), []).low).toBeNull(); }); test('declare is secret until everyone declared, then the hand settles', () => { let s = mk({ mode: 'sevenHiLo' }, 2, { dealerSeat: 1 }, 5); for (const id of ['A', 'B']) { const c = s.hand!.players[id]!.choice!; s = act(s, id, { type: 'choose', discardId: c[0]!, openId: c[1]! }); } for (let i = 0; i < 4; i++) s = checkAround(s); expect(s.hand!.phase).toBe('declare'); expect(s.deadlineAt! - clock).toBe(30_000); s = act(s, 'A', { type: 'declare', side: 'low' }); expect(view(s, 'B').seats[0]!.declare).toBeNull(); expect(view(s, 'B').seats[0]!.declared).toBe(true); expect(view(s, null).seats[0]!.declare).toBeNull(); expect(view(s, 'A').seats[0]!.declare).toBe('low'); expect(view(s, 'A').my.lowName).not.toBeNull(); s = act(s, 'B', { type: 'declare', side: 'high' }); expect(s.hand!.phase).toBe('handEnd'); expect(view(s, null).seats[0]!.declare).toBe('low'); expect(s.hand!.awards!.reduce((a, x) => a + x.amount, 0)).toBe(20); expect(s.hand!.showdown!.every((e) => e.declare)).toBe(true); }); }); // ------------------------------------------------------------------ hold'em / omaha / NL / PL describe('no-limit / pot-limit (§5.3)', () => { test('BB 10, A raises to 30 → B to 45 refused (min 50), 50 allowed', () => { let s = mk({ mode: 'holdem' }, 4, { dealerSeat: 1 }); expect(s.hand!.sb).toBe('C'); expect(s.hand!.bb).toBe('D'); expect(toAct(s)).toBe('A'); s = act(s, 'A', { type: 'nl.raise', to: 30 }); expect(view(s, 'B').my.legal.find((l) => l.type === 'nl.raise')!.minTo).toBe(50); expect(reason(s, 'B', { type: 'nl.raise', to: 45 })).toBe('최소 레이즈는 50 칩이에요.'); expect(poker.validate(s, 'B', { type: 'nl.raise', to: 50 }).ok).toBe(true); }); test('incomplete all-in raise: earlier actors may only call or fold', () => { let s = mk({ mode: 'indian' }, 3, { dealerSeat: 2, deck: deckOf('5S 6S 7S', deckD20()) }); s.hand!.bet.players[2]!.stack = 350; s = act(s, 'A', { type: 'nl.raise', to: 100 }); expect(s.hand!.players.A!.lastAction).toBe('벳 100 칩'); s = act(s, 'B', { type: 'nl.raise', to: 300 }); s = act(s, 'C', { type: 'nl.allIn' }); expect(s.hand!.bet.currentBet).toBe(350); expect(legal(s, 'A')).toEqual(['nl.fold', 'nl.call']); expect(reason(s, 'A', { type: 'nl.raise', to: 700 })).toBe('이번 라운드에서는 더 올릴 수 없어요(콜 또는 폴드만 가능).'); s = act(s, 'A', { type: 'nl.call' }); expect(legal(s, 'B')).toEqual(['nl.fold', 'nl.call']); s = act(s, 'B', { type: 'nl.call' }); expect(s.hand!.phase).toBe('handEnd'); }); test('pot limit: pot 100, facing a bet of 20 → max raise to 160', () => { let s = mk({ mode: 'indian', indianDeck: 'd52', betting: 'potLimit' }, 10, { dealerSeat: 9 }); expect(view(s, null).pot).toBe(100); s = act(s, 'A', { type: 'nl.raise', to: 20 }); const r = view(s, 'B').my.legal.find((l) => l.type === 'nl.raise')!; expect(r.maxTo).toBe(160); expect(reason(s, 'B', { type: 'nl.raise', to: 161 })).toBe('팟리밋 최대는 160 칩이에요.'); expect(poker.validate(s, 'B', { type: 'nl.raise', to: 160 }).ok).toBe(true); expect(legal(s, 'B')).not.toContain('nl.allIn'); }); test('heads-up hold\'em: dealer is SB and acts first preflop, BB first from the flop', () => { let s = mk({ mode: 'holdem' }, 2, { dealerSeat: 0 }); expect(s.hand!.sb).toBe('A'); expect(s.hand!.bb).toBe('B'); expect(view(s, null).seats[0]!.roundBet).toBe(5); expect(toAct(s)).toBe('A'); s = act(s, 'A', { type: 'nl.call' }); expect(legal(s, 'B')).toEqual(['nl.fold', 'nl.check', 'nl.raise', 'nl.allIn']); // BB option s = act(s, 'B', { type: 'nl.check' }); expect(view(s, null).board).toHaveLength(3); expect(view(s, null).streetName).toBe('플랍'); expect(toAct(s)).toBe('B'); }); test('side pots: A 100 / B 300 all-in, C 500 → main 300 (ABC), side 400 (BC), 200 back to C', () => { // order A(SB 1) B(BB 2) C; deal A B C A B C, burn, flop, burn, turn, burn, river let s = mk({ mode: 'holdem', baseUnit: 2 }, 3, { dealerSeat: 2, deck: deckOf('AS KS 2C AH KH 7D 3C 3D 8C 9H 4C JD 5C 4S') }); const b = s.hand!.bet.players; b[0]!.stack = 99; b[1]!.stack = 298; b[2]!.stack = 500; expect(toAct(s)).toBe('C'); s = act(s, 'C', { type: 'nl.allIn' }); s = act(s, 'A', { type: 'nl.call' }); // mid-round: B (still to act) stays eligible for every pot it can still call into expect(view(s, null).pots).toEqual([ { amount: 202, eligible: ['A', 'B', 'C'] }, { amount: 400, eligible: ['B', 'C'] }, ]); s = act(s, 'B', { type: 'nl.call' }); expect(buildPots(s.hand!.bet.players)).toEqual([ { amount: 300, eligible: ['A', 'B', 'C'] }, { amount: 400, eligible: ['B', 'C'] }, ]); // all-in run-out: no more betting, five board cards, showdown expect(s.hand!.phase).toBe('handEnd'); expect(s.hand!.board).toEqual(['3D', '8C', '9H', 'JD', '4S']); expect(s.hand!.awards).toEqual([ { potIndex: 0, to: 'A', amount: 300 }, { potIndex: 1, to: 'B', amount: 400 }, ]); expect([stack(s, 'A'), stack(s, 'B'), stack(s, 'C')]).toEqual([300, 400, 200]); }); test('split pot by board play, odd chip to the winner left of the dealer; streets advance', () => { let s = mk({ mode: 'holdem', baseUnit: 2 }, 3, { dealerSeat: 2, deck: deckOf('2C 4H 6C 3D 5S 7D 8C AS KD QH 8D JC 8H TS') }); s = act(s, 'C', { type: 'nl.raise', to: 5 }); s = act(s, 'A', { type: 'nl.call' }); s = act(s, 'B', { type: 'nl.call' }); expect(view(s, null).board).toHaveLength(3); expect(toAct(s)).toBe('A'); s = act(s, 'A', { type: 'nl.check' }); s = act(s, 'B', { type: 'nl.raise', to: 2 }); s = act(s, 'C', { type: 'nl.fold' }); s = act(s, 'A', { type: 'nl.call' }); expect(view(s, null).board).toHaveLength(4); expect(view(s, 'A').my.handName).toBe('A 하이'); s = checkAround(s, 'nl.check'); expect(view(s, null).board).toHaveLength(5); expect(view(s, 'A').my.handName).toBe('A 스트레이트'); s = checkAround(s, 'nl.check'); expect(s.hand!.potTotal).toBe(19); expect(s.hand!.awards).toEqual([ { potIndex: 0, to: 'A', amount: 10 }, { potIndex: 0, to: 'B', amount: 9 }, ]); expect(stack(s, 'A') - 1000).toBe(10 - 7); }); test('fold to one: no showdown, cards stay hidden, pot to the last player', () => { let s = mk({ mode: 'holdem' }, 3, { dealerSeat: 2 }); s = act(s, 'C', { type: 'nl.fold' }); s = act(s, 'A', { type: 'nl.fold' }); expect(s.hand!.phase).toBe('handEnd'); expect(s.hand!.foldWin).toBe(true); expect(s.hand!.showdown).toBeNull(); expect(view(s, 'A').seats[1]!.hole).toEqual([null, null]); expect([stack(s, 'A'), stack(s, 'B'), stack(s, 'C')]).toEqual([995, 1005, 1000]); expect(s.deadlineAt! - clock).toBe(4_000); }); test('omaha deals four hole cards and uses pot limit', () => { const s = mk({ mode: 'omaha' }, 3); expect(view(s, 'A').seats[0]!.hole).toHaveLength(4); expect(view(s, 'A').my.handName).toBeNull(); // needs the flop expect(s.betStyle).toBe('potLimit'); }); }); // ------------------------------------------------------------------ draw games describe('바둑이 / 5카드 드로우 (§5.4.5, §5.4.6)', () => { test('6 players boxing every draw: discards reshuffled, exact counts, 52 cards conserved', () => { let s = mk({ mode: 'badugi' }, 6, { dealerSeat: 5 }); const check = () => { const cards = allCards(s); expect(cards).toHaveLength(52); expect(new Set(cards).size).toBe(52); for (const p of Object.values(s.hand!.players)) expect(p.hole).toHaveLength(4); }; check(); for (let round = 0; round < 3; round++) { s = checkAround(s); expect(s.hand!.phase).toBe('draw'); for (const id of NAMES.slice(0, 6)) { const before = [...s.hand!.players[id]!.hole]; s = act(s, id, { type: 'draw', cardIds: before }); check(); } expect(s.hand!.players.A!.drawCounts).toHaveLength(round + 1); } expect(s.log.some((e) => e.msg.includes('버린 카드를 섞어'))).toBe(true); expect(view(s, 'B').seats[0]!.drawCounts).toEqual([4, 4, 4]); s = checkAround(s); expect(s.hand!.phase).toBe('handEnd'); }); test('draw submissions are simultaneous and discards are never shown', () => { let s = mk({ mode: 'badugi' }, 2, { dealerSeat: 1 }); s = checkAround(s); const a = s.hand!.players.A!.hole.slice(0, 2); s = act(s, 'A', { type: 'draw', cardIds: a }); expect(view(s, 'B').seats[0]!.drawDone).toBe(true); expect(view(s, 'B').seats[0]!.drawCounts).toEqual([]); expect(reason(s, 'A', { type: 'draw', cardIds: [] })).toBe('이미 바꿀 카드를 냈어요.'); s = act(s, 'B', { type: 'draw', cardIds: [] }); expect(view(s, 'B').seats[0]!.drawCounts).toEqual([2]); const va = JSON.stringify(view(s, 'A')); for (const c of a) expect(va).not.toContain(`"${c}"`); // the label is logged; the per-seat label is cleared when the next betting round starts (count stays in drawCounts) expect(s.log.some((e) => e.by === 'A' && e.msg === '아침 2장 바꿈')).toBe(true); }); test('drawLimit three: 4 cards only when keeping an ace', () => { // order A, B; deal A B A B ... let s = mk({ mode: 'fiveDraw', drawLimit: 'three' }, 2, { dealerSeat: 1, deck: deckOf('AS KS 2C 2D 3D 3H 4H 4C 9S 9D') }); s = checkAround(s); expect(s.hand!.phase).toBe('draw'); expect(reason(s, 'B', { type: 'draw', cardIds: ['2D', '3H', '4C', '9D'] })).toBe('3장까지 바꿀 수 있어요(A를 남기면 4장).'); expect(poker.validate(s, 'B', { type: 'draw', cardIds: ['2D', '3H', '4C'] }).ok).toBe(true); expect(poker.validate(s, 'A', { type: 'draw', cardIds: ['2C', '3D', '4H', '9S'] }).ok).toBe(true); expect(poker.validate(s, 'A', { type: 'draw', cardIds: ['AS', '2C', '3D', '4H'] }).ok).toBe(false); expect(poker.validate(s, 'A', { type: 'draw', cardIds: ['AS', '2C', '3D', '4H', '9S'] }).ok).toBe(false); expect(reason(s, 'A', { type: 'draw', cardIds: ['KS'] })).toBe('내 손에 있는 카드만 바꿀 수 있어요.'); const any = mk({ mode: 'fiveDraw' }, 2, { dealerSeat: 1 }); const t = checkAround(any); expect(poker.validate(t, 'A', { type: 'draw', cardIds: t.hand!.players.A!.hole }).ok).toBe(true); }); test('badugi showdown: made beats base', () => { // A: 2♠ 3♦ 7♥ 8♣ (8 메이드), B: A♠ 2♦ 3♥ 3♣ (3 베이스) let s = mk({ mode: 'badugi' }, 2, { dealerSeat: 1, deck: deckOf('2S AS 3D 2D 7H 3H 8C 3C') }); for (let r = 0; r < 3; r++) { s = checkAround(s); s = act(s, 'A', { type: 'draw', cardIds: [] }); s = act(s, 'B', { type: 'draw', cardIds: [] }); } s = checkAround(s); expect(s.hand!.showdown!.map((e) => e.handName)).toEqual(['8 메이드', '3 베이스']); expect(s.hand!.winners).toEqual(['A']); }); }); // ------------------------------------------------------------------ indian describe('인디언 포커 (§5.4.7)', () => { test('forehead: hidden from its owner and spectators, visible to other players, all shown after the hand', () => { let s = mk({ mode: 'indian' }, 3, { dealerSeat: 2, deck: deckOf('TS 9H 2S', deckD20()) }); expect(view(s, 'A').seats[0]!.forehead).toBeNull(); expect(view(s, 'B').seats[0]!.forehead).toBe('TS'); expect(view(s, null).seats[0]!.forehead).toBeNull(); expect(JSON.stringify(view(s, 'A'))).not.toContain('"TS"'); expect(JSON.stringify(view(s, null))).not.toMatch(/"[2-9TA][SH]"/); expect(view(s, 'A').my.handName).toBeNull(); s = act(s, 'A', { type: 'nl.fold' }); s = act(s, 'B', { type: 'nl.check' }); s = act(s, 'C', { type: 'nl.check' }); expect(s.hand!.phase).toBe('handEnd'); expect(view(s, null).seats.map((x) => x.forehead)).toEqual(['TS', '9H', '2S']); // revealed incl. the folder expect(view(s, null).indianUsed).toEqual(['TS', '9H', '2S']); }); test('folding with the 10 → penalty 10 × base unit goes to the winner', () => { let s = mk({ mode: 'indian' }, 3, { dealerSeat: 2, deck: deckOf('TS 9H 2S', deckD20()) }); s = act(s, 'A', { type: 'nl.fold' }); expect(s.hand!.penalties).toEqual([{ from: 'A', amount: 100 }]); expect(chipsBalanced(s)).toBe(true); s = act(s, 'B', { type: 'nl.check' }); s = act(s, 'C', { type: 'nl.check' }); expect(s.hand!.awards).toContainEqual({ potIndex: 0, to: 'B', amount: 100, side: 'penalty' }); expect([stack(s, 'A'), stack(s, 'B'), stack(s, 'C')]).toEqual([890, 1120, 990]); const off = mk({ mode: 'indian', indianFoldPenalty: false }, 3, { dealerSeat: 2, deck: deckOf('TS 9H 2S', deckD20()) }); expect(act(off, 'A', { type: 'nl.fold' }).hand!.penalties).toEqual([]); }); test('tie with carry → the next hand winner takes the carried chips', () => { let s = mk({ mode: 'indian' }, 2, { dealerSeat: 1, deck: deckOf('7S 7H 9S 3H', deckD20()) }); s = act(s, 'A', { type: 'nl.check' }); s = act(s, 'B', { type: 'nl.check' }); expect(s.carryOver).toBe(20); expect(s.hand!.carriedOut).toBe(20); expect([stack(s, 'A'), stack(s, 'B')]).toEqual([990, 990]); expect(chipsBalanced(s)).toBe(true); s = readyAll(s); expect(s.hand!.no).toBe(2); expect(s.hand!.order).toEqual(['B', 'A']); expect(view(s, 'A').seats[1]!.forehead).toBe('9S'); expect(view(s, null).carryOver).toBe(20); s = act(s, 'B', { type: 'nl.check' }); s = act(s, 'A', { type: 'nl.check' }); expect(s.carryOver).toBe(0); expect([stack(s, 'A'), stack(s, 'B')]).toEqual([980, 1020]); }); test('tie on the last hand is split; split option splits right away', () => { let s = mk({ mode: 'indian', handLimit: 10 }, 2, { dealerSeat: 1, deck: deckOf('7S 7H', deckD20()) }); s.handsPlayed = 9; s = act(s, 'A', { type: 'nl.check' }); s = act(s, 'B', { type: 'nl.check' }); expect(s.carryOver).toBe(0); expect([stack(s, 'A'), stack(s, 'B')]).toEqual([1000, 1000]); let t = mk({ mode: 'indian', indianTie: 'split' }, 2, { dealerSeat: 1, deck: deckOf('7S 7H', deckD20()) }); t = act(act(t, 'A', { type: 'nl.check' }), 'B', { type: 'nl.check' }); expect(t.carryOver).toBe(0); expect(stack(t, 'A')).toBe(1000); }); test('whenEmpty: 2 players use the 20-card deck for 10 hands, then reshuffle and clear the used list', () => { let s = mk({ mode: 'indian', baseUnit: 2, indianFoldPenalty: false }, 2, undefined, 3); for (let k = 1; k <= 10; k++) { expect(s.hand!.no).toBe(k); expect(s.indianUsed).toHaveLength(2 * (k - 1)); s = act(s, toAct(s)!, { type: 'nl.fold' }); s = readyAll(s); } expect(s.hand!.no).toBe(11); expect(s.indianUsed).toEqual([]); expect(s.hand!.deck).toHaveLength(18); expect(s.log.some((e) => e.msg.includes('다시 섞었어요'))).toBe(true); const every = mk({ mode: 'indian', indianReshuffle: 'everyHand', indianFoldPenalty: false }, 2); expect(every.hand!.deck).toHaveLength(18); }); test('2-card mode: hand card is private, second betting round, pair beats high card', () => { // forehead A, B; then hand card A, B let s = mk({ mode: 'indian', indianCards: 2 }, 2, { dealerSeat: 1, deck: deckOf('3S 9S 3H 2H', deckD20()) }); s = act(s, 'A', { type: 'nl.check' }); s = act(s, 'B', { type: 'nl.check' }); expect(view(s, 'A').seats[0]!.hole).toEqual(['3H']); expect(view(s, 'B').seats[0]!.hole).toEqual([null]); expect(view(s, 'B').seats[0]!.forehead).toBe('3S'); expect(view(s, null).streetName).toBe('손 카드 뒤 베팅'); s = act(s, 'A', { type: 'nl.check' }); s = act(s, 'B', { type: 'nl.check' }); expect(s.hand!.showdown!.map((e) => e.handName)).toEqual(['3 페어', '9·2']); expect(s.hand!.winners).toEqual(['A']); }); }); // ------------------------------------------------------------------ hands, rebuy, end describe('between hands: rebuy, elimination, levels, dealer, end (§5.6, §5.7, §6.7)', () => { test('rebuy limited 1: rebuy once, bust again → eliminated; hands score = chips − 1000 × rebuys', () => { let s = mk({ mode: 'indian', rebuy: 'limited', rebuyLimit: 1 }, 2, { dealerSeat: 1, deck: deckOf('2S TS TH 2H', deckD20()) }); s = act(s, 'A', { type: 'nl.allIn' }); s = act(s, 'B', { type: 'nl.call' }); expect([stack(s, 'A'), stack(s, 'B')]).toEqual([0, 2000]); s = readyAll(s); expect(s.phase).toBe('rebuy'); expect(view(s, 'A').my.canRebuy).toBe(true); expect(view(s, null).waiting).toEqual(['A']); expect(s.deadlineAt! - clock).toBe(15_000); expect(reason(s, 'B', { type: 'rebuy', accept: true })).toBe('지금은 칩을 다시 받을 수 없어요.'); s = act(s, 'A', { type: 'rebuy', accept: true }); expect(s.phase).toBe('playing'); expect(stack(s, 'A')).toBe(1000); expect(s.chipsIssued).toBe(3000); expect(chipsBalanced(s)).toBe(true); expect(s.hand!.order).toEqual(['B', 'A']); s = act(s, 'B', { type: 'nl.check' }); s = act(s, 'A', { type: 'nl.allIn' }); s = act(s, 'B', { type: 'nl.call' }); s = readyAll(s); expect(s.phase).toBe('finished'); const r = poker.result(s)!; expect(r.ranking).toEqual([['B'], ['A']]); expect(r.scores).toEqual({ A: -1000, B: 3000 }); expect(r.summary).toBe('2판 끝 · 1위 +3,000칩'); expect(s.seats[0]!.status).toBe('eliminated'); }); test('rebuy off: a busted player is out at once; declining a rebuy eliminates too', () => { let s = mk({ mode: 'indian', rebuy: 'off' }, 3, { dealerSeat: 2, deck: deckOf('2S TS 3S', deckD20()) }); s = act(s, 'A', { type: 'nl.allIn' }); s = act(s, 'B', { type: 'nl.fold' }); s = act(s, 'C', { type: 'nl.call' }); s = readyAll(s); expect(s.seats[0]!.status).toBe('eliminated'); expect(s.phase).toBe('playing'); expect(s.hand!.order).not.toContain('A'); let t = mk({ mode: 'indian' }, 3, { dealerSeat: 2, deck: deckOf('2S TS 3S', deckD20()) }); t = act(t, 'A', { type: 'nl.allIn' }); t = act(t, 'B', { type: 'nl.fold' }); t = act(t, 'C', { type: 'nl.call' }); t = readyAll(t); expect(t.phase).toBe('rebuy'); t = act(t, 'A', { type: 'timeout' }); // timeout = decline expect(t.seats[0]!.status).toBe('eliminated'); expect(t.hand!.no).toBe(2); }); test('last standing: two players out in the same hand → more chips at hand start ranks higher', () => { let s = mk({ mode: 'indian', endCondition: 'lastStanding', rebuy: 'off' }, 3, { dealerSeat: 2, deck: deckOf('2S 3S TS', deckD20()) }); s.hand!.bet.players[0]!.stack = 290; s.hand!.bet.players[1]!.stack = 490; s.seats[0]!.stackAtHandStart = 300; s.seats[1]!.stackAtHandStart = 500; s = act(s, 'A', { type: 'nl.allIn' }); s = act(s, 'B', { type: 'nl.allIn' }); s = act(s, 'C', { type: 'nl.call' }); expect(s.hand!.awards!.map((a) => [a.potIndex, a.to, a.amount])).toEqual([ [0, 'C', 900], [1, 'C', 400], ]); s = readyAll(s); expect(s.phase).toBe('finished'); const r = poker.result(s)!; expect(r.ranking).toEqual([['C'], ['B'], ['A']]); expect(r.scores).toEqual({ C: 3, B: 2, A: 1 }); expect(s.eliminationSeq).toEqual([['A', 'B']]); }); test('hand limit ends the game; ranking by chips with ties shared', () => { let s = mk({ mode: 'indian', handLimit: 10, indianFoldPenalty: false, indianTie: 'split' }, 3, undefined, 4); for (let k = 0; k < 10; k++) { while (s.hand!.phase !== 'handEnd') s = act(s, toAct(s)!, { type: 'nl.check' }); s = readyAll(s); } expect(s.phase).toBe('finished'); expect(s.handsPlayed).toBe(10); const r = poker.result(s)!; expect(r.ranking.flat().sort()).toEqual(['A', 'B', 'C']); // §6.7: score = final chips - startingChips × rebuys (no rebuys here → all chips) expect(Object.values(r.scores!).reduce((a, b) => a + b, 0)).toBe(3000); expect(poker.activePlayers(s)).toEqual([]); expect(reason(s, 'A', { type: 'ready' })).toBe('게임이 이미 끝났어요.'); }); test('blind/ante level doubles every N hands, capped at startingChips / 5', () => { let s = mk({ mode: 'indian', blindUpEvery: 5, startingChips: 1000, baseUnit: 50, indianFoldPenalty: false }, 2, undefined, 2); const levels: number[] = []; for (let k = 0; k < 15 && s.phase === 'playing'; k++) { levels.push(s.baseUnit); s = act(s, toAct(s)!, { type: 'nl.fold' }); s = readyAll(s); } expect(levels.slice(0, 5)).toEqual([50, 50, 50, 50, 50]); expect(levels.slice(5, 10)).toEqual([100, 100, 100, 100, 100]); expect(levels.slice(10)).toEqual([200, 200, 200, 200, 200]); expect(s.level).toBe(3); }); test('dealer rotates clockwise, or goes to the last winner', () => { let s = mk({ mode: 'holdem' }, 3, { dealerSeat: 0 }); expect(s.hand!.order).toEqual(['B', 'C', 'A']); s = act(s, 'A', { type: 'nl.fold' }); s = act(s, 'B', { type: 'nl.fold' }); s = readyAll(s); expect(s.hand!.dealerSeat).toBe(1); let w = mk({ mode: 'holdem', dealerRule: 'winner' }, 3, { dealerSeat: 0 }); w = act(w, 'A', { type: 'nl.fold' }); w = act(w, 'B', { type: 'nl.fold' }); expect(w.lastWinner).toBe('C'); w = readyAll(w); expect(w.hand!.dealerSeat).toBe(2); }); test('result screen: ready from everyone or a timeout starts the next hand', () => { let s = mk({ mode: 'holdem' }, 3, { dealerSeat: 0 }); s = act(s, 'A', { type: 'nl.fold' }); s = act(s, 'B', { type: 'nl.fold' }); expect(view(s, 'A').my.canReady).toBe(true); s = act(s, 'A', { type: 'ready' }); expect(reason(s, 'A', { type: 'ready' })).toBe('이미 준비했어요.'); expect(s.hand!.no).toBe(1); s = act(s, 'B', { type: 'timeout' }); expect(s.hand!.no).toBe(2); }); }); describe('leaving (§5.8)', () => { test('leaving folds now and in later hands after paying the ante; last connected player ends the game', () => { let s = mk({ mode: 'badugi' }, 3, { dealerSeat: 2 }); expect(poker.onLeave!(s, 'B')).toEqual({ type: 'leave' }); s = act(s, 'B', { type: 'leave' }); expect(view(s, null).seats[1]!.folded).toBe(true); expect(toAct(s)).toBe('A'); s = act(s, 'A', { type: 'k.check' }); s = act(s, 'C', { type: 'k.check' }); for (let r = 0; r < 3; r++) { s = act(s, 'A', { type: 'draw', cardIds: [] }); s = act(s, 'C', { type: 'draw', cardIds: [] }); s = checkAround(s); } s = readyAll(s); expect(s.hand!.no).toBe(2); const b = s.hand!.bet.players[s.hand!.order.indexOf('B')]!; expect(b.status).toBe('folded'); expect(b.total).toBe(10); expect(poker.activePlayers(s)).not.toContain('B'); expect(chipsBalanced(s)).toBe(true); s = act(s, 'C', { type: 'leave' }); expect(s.phase).toBe('finished'); expect(poker.result(s)!.reason).toBe('abandoned'); expect(poker.onLeave!(s, 'A')).toBeNull(); }); test('leaving on your own turn folds immediately', () => { let s = mk({ mode: 'holdem' }, 3, { dealerSeat: 2 }); expect(toAct(s)).toBe('C'); s = act(s, 'C', { type: 'leave' }); expect(toAct(s)).toBe('A'); expect(s.hand!.players.C!.lastAction).toBe('폴드'); }); }); // ------------------------------------------------------------------ properties const MODES_ALL = ['holdem', 'omaha', 'sevenPoker', 'sevenHiLo', 'badugi', 'fiveDraw', 'indian'] as const; const CARD_RE = /"([2-9TJQKA][SHDC])"/g; const tokens = (v: unknown): Set => new Set([...JSON.stringify(v).matchAll(CARD_RE)].map((m) => m[1]!)); /** Fast leak / invariant checks (token sets instead of substring scans). */ function checkState(s: PokerState, players: PlayerId[]): void { if (!chipsBalanced(s)) throw new Error('chip conservation broken'); const h = s.hand; if (h && !h.settled) { const cards = allCards(s); const deckSize = s.options.mode === 'indian' ? (s.options.indianDeck === 'd20' ? 20 : 52) : 52; if (new Set(cards).size !== cards.length) throw new Error('duplicate card'); const total = s.options.mode === 'indian' ? cards.length + s.indianUsed.length : cards.length; if (total !== deckSize) throw new Error(`card count ${total}`); } const spect = tokens(poker.view(s, null)); const secrets = Object.fromEntries(players.map((p) => [p, poker.secretsOf!(s, p)])); for (const p of players) for (const c of secrets[p]!) if (spect.has(c)) throw new Error(`spectator sees ${c} of ${p}`); for (const viewer of players) { const seen = tokens(poker.view(s, viewer)); for (const p of players) if (p !== viewer) for (const c of secrets[p]!) if (seen.has(c)) throw new Error(`${viewer} sees ${c} of ${p}`); if (h && !h.settled) { const f = h.players[viewer]?.forehead; if (f && seen.has(f)) throw new Error(`${viewer} sees own forehead`); } } if (h && !h.settled) for (const p of Object.values(h.players)) if (p.forehead && spect.has(p.forehead)) throw new Error('spectator sees a forehead'); } function playout(options: PokerOptions, n: number, seed: number, maxSteps = 6000) { const players = NAMES.slice(0, n); const rng = SeededRng.fromSeed(seedN(seed)); const pick = SeededRng.fromSeed(seedN(seed + 7777)); let now = 1_000_000; let s = poker.setup({ players, options, rng, now }); const times: number[] = []; const trace: [PlayerId, PokerAction][] = []; let hands = 0; for (let step = 0; step < maxSteps; step++) { checkState(s, players); if (poker.result(s)) return { s, finished: true, times, trace, hands }; let actor: PlayerId; let a: PokerAction; const roll = pick.next(); if (roll < 0.003) { actor = players[pick.int(n)]!; const l = poker.onLeave!(s, actor); if (!l) continue; a = l; } else { const active = poker.activePlayers(s); if (active.length === 0) throw new Error('no active players'); actor = active[pick.int(active.length)]!; const legal = poker.legalActions!(s, actor); a = roll < 0.06 || legal.length === 0 ? poker.onTimeout(s, actor) : legal[pick.int(legal.length)]!; } const v = poker.validate(s, actor, a); if (!v.ok) throw new Error(`rejected ${JSON.stringify(a)}: ${v.reason}`); now += 1000; const before = s.hand?.no; const t0 = performance.now(); const r = poker.apply(s, actor, a, { rng, now }); times.push(performance.now() - t0); const all = new Set(players.flatMap((p) => poker.secretsOf!(r.state, p))); if (r.state.hand && !r.state.hand.settled) for (const p of Object.values(r.state.hand.players)) if (p.forehead) all.add(p.forehead); for (const c of tokens(r.events)) if (all.has(c)) throw new Error(`event leaks ${c}`); s = r.state; if (s.hand?.no !== before) hands++; trace.push([actor, a]); } return { s, finished: false, times, trace, hands }; } describe('random playouts', () => { test('engine randomPlayout (checkNoLeak via secretsOf) in every mode', () => { for (const mode of MODES_ALL) { const options = poker.optionsSchema.parse({ mode, handLimit: 10 }); for (const n of [2, 4]) { const a = randomPlayout(poker, { players: NAMES.slice(0, n), options, seed: seedN(n * 31 + mode.length), maxSteps: 3000, maxApplyMs: 50, invariant: (st) => { if (!chipsBalanced(st as PokerState)) throw new Error('chips'); }, }); expect(a.finished).toBe(true); } } }); test('~1000 hands across modes/options: no leaks (views, spectator, events, foreheads), chips and cards conserved, apply < 5ms', () => { const configs: [Partial, number][] = [ [{ mode: 'holdem' }, 6], [{ mode: 'holdem', betting: 'potLimit', dealerRule: 'winner' }, 10], [{ mode: 'omaha' }, 4], [{ mode: 'sevenPoker', koreanMaxRaise: 'full' }, 6], [{ mode: 'sevenPoker', betting: 'noLimit' }, 3], [{ mode: 'sevenHiLo', hiLoLow: 'aceToFive', hiLoQualifier: 'eight' }, 5], [{ mode: 'sevenHiLo' }, 2], [{ mode: 'badugi' }, 6], [{ mode: 'fiveDraw', drawLimit: 'three' }, 4], [{ mode: 'indian' }, 6], [{ mode: 'indian', indianCards: 2, indianTie: 'split' }, 3], [{ mode: 'indian', indianDeck: 'd52' }, 10], [{ mode: 'badugi', endCondition: 'lastStanding', rebuy: 'off', blindUpEvery: 5 }, 4], [{ mode: 'holdem', endCondition: 'lastStanding', rebuy: 'limited', rebuyLimit: 2 }, 3], ]; let hands = 0; const times: number[] = []; for (let i = 0; hands < 1000; i++) { const [o, n] = configs[i % configs.length]!; const r = playout(poker.optionsSchema.parse({ handLimit: 10, ...o }), n, i + 1); expect(r.finished).toBe(true); hands += r.hands; times.push(...r.times); } times.sort((a, b) => a - b); const p99 = times[Math.floor(times.length * 0.99)]!; expect(p99).toBeLessThan(5); expect(times.filter((t) => t > 5).length / times.length).toBeLessThan(0.002); }); test('determinism: same seed and actions → same state', () => { for (const mode of MODES_ALL) { const options = poker.optionsSchema.parse({ mode, handLimit: 10 }); const a = playout(options, 3, 42); const b = playout(options, 3, 42); expect(JSON.stringify(a.s)).toBe(JSON.stringify(b.s)); // replaying the trace on a fresh setup reproduces the final state const rng = SeededRng.fromSeed(seedN(42)); let now = 1_000_000; let s = poker.setup({ players: NAMES.slice(0, 3), options, rng, now }); for (const [actor, act] of a.trace) s = poker.apply(s, actor, act, { rng, now: (now += 1000) }).state; expect(JSON.stringify(s)).toBe(JSON.stringify(a.s)); } }); test('onTimeout always yields a valid action for every waiting player', () => { for (const mode of MODES_ALL) { const r = playout(poker.optionsSchema.parse({ mode, handLimit: 10 }), 3, 9); // replay with the same rng stream as the playout so the decks match const rng = SeededRng.fromSeed(seedN(9)); let now = 1_000_000; let s = poker.setup({ players: NAMES.slice(0, 3), options: r.s.options, rng, now }); for (const [actor, a] of r.trace) { for (const p of poker.timeoutPlayers!(s)) expect(poker.validate(s, p, poker.onTimeout(s, p)).ok).toBe(true); s = poker.apply(s, actor, a, { rng, now: (now += 1000) }).state; } } }); });