Files
joke-app/packages/games/src/poker/poker.test.ts
EJClaw 5dd8e6ea07 M3: 섯다·포커 규칙과 화면, 카드 게임 E2E
- 섯다(packages/games/seotda): 2장·3장 섯다, 1장씩/한 번에 나눠 주기, 족보(광땡·땡·알리…망통),
  땡잡이·암행어사·구사/멍텅구리구사 재경기, 동점 재경기·나눔, 리바이·탈락, 사이드 팟. 규칙 테스트 54개
- 포커(packages/games/poker): 홀덤·오마하·7포커(초이스·보스)·7포커 하이로우(동시 선언)·바둑이(아침·점심·저녁)·
  5카드 드로우·인디언 포커(이마 카드, 동점 이월, 다이 벌칙). 한국식/노리밋/팟리밋, 블라인드·앤티,
  리바이·레벨 상승·최후 1인. 규칙 테스트 52개 + 족보 판정 24개, 무작위 1,000판(칩 보존·비공개 카드 유출 없음)
- 웹: 섯다·포커 화면(테이블 좌석, 공용 카드, 사이드 팟, 한국식 버튼/레이즈 슬라이더, 초이스·교환·선언,
  체크 가능할 때 포기 확인, 리바이 안내), 섯다 결과 화면에서 가져간 칩 표시
- 엔진 betting.buildPots: 라운드 진행 중에는 아직 콜할 수 있는 플레이어도 각 팟 자격자로 표시
- 서버 테스트: 크래시 흉내 후 소켓 종료로 다시 걸린 타이머가 닫힌 DB를 건드리던 누수 수정
- E2E: 3인(PC + 휴대폰 2) 섯다·7포커 한 판, 6개 게임 모두 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:48:56 +09:00

972 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PokerOptions>, 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<PokerOptions>) => 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<string, { meta(): { title?: string } | undefined }> }).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<PokerOptions> = {}): 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<string, Side> = { 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<string> => 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<PokerOptions>, 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;
}
}
});
});