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>
This commit is contained in:
971
packages/games/src/poker/poker.test.ts
Normal file
971
packages/games/src/poker/poker.test.ts
Normal file
@@ -0,0 +1,971 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user