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:
EJClaw
2026-10-05 03:48:56 +09:00
parent 31aedbfcc9
commit 5dd8e6ea07
28 changed files with 7545 additions and 12 deletions

View File

@@ -0,0 +1,86 @@
/** Card helpers for poker (docs/games/poker.md §3). Cards are engine ids like "AS", "TD". */
import { RANKS, rankOf, rankValue, standardDeck, suitOf, type CardId, type Suit } from '@bg/engine';
export type { CardId, Suit };
/** Korean suit order ♠ > ◆ > ♥ > ♣ (§6.2). Larger is better. */
export const SUIT_ORDER: Record<Suit, number> = { S: 4, D: 3, H: 2, C: 1 };
export const suitVal = (c: CardId): number => SUIT_ORDER[suitOf(c)];
/** Ace high: 2..14. */
export const hi = (c: CardId): number => rankValue(c);
/** Ace low: A=1, 2..13. */
export const lo = (c: CardId): number => (rankOf(c) === 'A' ? 1 : rankValue(c));
/** Rank label for a numeric value (1 or 14 = A). */
export function rankLabel(v: number): string {
if (v === 1 || v === 14) return 'A';
if (v === 13) return 'K';
if (v === 12) return 'Q';
if (v === 11) return 'J';
return String(v);
}
export const deck52 = (): CardId[] => standardDeck();
/** 인디언 포커 d20: A(=1)~10 of ♠ and ♥ (§3). */
export const deckD20 = (): CardId[] => ['S', 'H'].flatMap((s) => RANKS.slice(0, 9).map((r) => `${r}${s}`).concat(`A${s}`));
/** All k-subsets of `arr` (small arrays only). */
export function combos<T>(arr: readonly T[], k: number): T[][] {
const out: T[][] = [];
const cur: T[] = [];
const rec = (start: number) => {
if (cur.length === k) {
out.push(cur.slice());
return;
}
for (let i = start; i <= arr.length - (k - cur.length); i++) {
cur.push(arr[i]!);
rec(i + 1);
cur.pop();
}
};
rec(0);
return out;
}
/** Lexicographic compare of score keys (larger = better). */
export function cmpKey(a: readonly number[], b: readonly number[]): number {
const n = Math.max(a.length, b.length);
for (let i = 0; i < n; i++) {
const d = (a[i] ?? -Infinity) - (b[i] ?? -Infinity);
if (d !== 0) return d > 0 ? 1 : -1;
}
return 0;
}
/** Evaluated hand: `key` is compared with cmpKey (larger wins), `best` are the cards used. */
export interface HandValue {
key: number[];
cat: number;
name: string;
best: CardId[];
}
/** Picks the best value among candidate card sets. */
export function bestOf(sets: CardId[][], evalFn: (cards: CardId[]) => HandValue | null): HandValue | null {
let best: HandValue | null = null;
for (const set of sets) {
const v = evalFn(set);
if (v && (!best || cmpKey(v.key, best.key) > 0)) best = v;
}
return best;
}
/** Groups by numeric rank: [[rank, cards[]], ...] sorted by size desc then rank desc. */
export function groupByRank(cards: CardId[], val: (c: CardId) => number = hi): [number, CardId[]][] {
const m = new Map<number, CardId[]>();
for (const c of cards) {
const v = val(c);
const g = m.get(v);
if (g) g.push(c);
else m.set(v, [c]);
}
return [...m.entries()].sort((a, b) => b[1].length - a[1].length || b[0] - a[0]);
}

View File

@@ -0,0 +1,28 @@
/**
* 바둑이 족보 (docs/games/poker.md §6.4): A=1 lowest, suits unranked.
* Grade = size k of the largest subset with all-different ranks AND suits (메이드 4 > 베이스 3 > 투베이스 2 > 노베이스 1).
* Same k → compare that subset from the highest card down, lower wins. Equal numbers = tie (split).
*/
import { suitOf } from '@bg/engine';
import { combos, cmpKey, lo, rankLabel, type CardId, type HandValue } from '../cards';
const GRADE = ['', '노베이스', '투베이스', '베이스', '메이드'];
const NAMED: Record<string, string> = { '4,3,2,1': '골프', '5,3,2,1': '세컨드', '5,4,2,1': '써드' };
export function evalBadugi(cards: CardId[]): HandValue {
let best: { k: number; key: number[]; cards: CardId[] } | null = null;
for (let k = Math.min(4, cards.length); k >= 1 && !best; k--) {
for (const set of combos(cards, k)) {
if (new Set(set.map(lo)).size !== k || new Set(set.map(suitOf)).size !== k) continue;
const desc = set.map(lo).sort((a, b) => b - a);
const key = [k, ...desc.map((x) => -x)];
if (!best || cmpKey(key, best.key) > 0) best = { k, key, cards: set.slice().sort((a, b) => lo(a) - lo(b)) };
}
}
if (!best) return { cat: 0, key: [0], name: '패 없음', best: [] };
const top = -best.key[1]!;
const ranks = best.key.slice(1).map((x) => -x).join();
let name = best.k === 1 ? '노베이스' : `${rankLabel(top)} ${GRADE[best.k]}`;
if (best.k === 4 && NAMED[ranks]) name = `${NAMED[ranks]} (${rankLabel(top)} 메이드)`;
return { cat: best.k, key: best.key, name, best: best.cards };
}

View File

@@ -0,0 +1,230 @@
import { describe, expect, test } from 'bun:test';
import { cmpKey, combos, deckD20, type CardId } from '../cards';
import { bestIntl, bestOmaha, evalIntl, INTL } from './international';
import { bestKorean, evalKorean, KOR, openCardsKey } from './korean';
import { bestLow, evalLow } from './low';
import { evalBadugi } from './badugi';
import { evalIndian } from './indian';
/** "AS KD" → ["AS", "KD"]; accepts 10 as T. */
const h = (s: string): CardId[] => s.trim().split(/\s+/).map((c) => c.replace('10', 'T'));
const sign = (n: number) => Math.sign(n);
describe('international ranking (§6.1)', () => {
test('category order', () => {
const hands = [
['AS KS QS JS TS', INTL.straightFlush, '로열 스트레이트 플러시'],
['5D 4D 3D 2D AD', INTL.straightFlush, '5 스트레이트 플러시'],
['9C 9D 9H 9S 2C', INTL.quads, '9 포카드'],
['KC KD KH 2S 2C', INTL.fullHouse, 'K 풀하우스'],
['AH 9H 7H 4H 2H', INTL.flush, 'A 플러시'],
['TC JD QH KS AC', INTL.straight, 'A 스트레이트'],
['5C 4D 3H 2S AC', INTL.straight, '5 스트레이트'],
['7C 7D 7H KS 2C', INTL.trips, '7 트리플'],
['7C 7D 4H 4S 2C', INTL.twoPair, '7·4 투페어'],
['7C 7D 4H 3S 2C', INTL.pair, '7 원페어'],
['AC 7D 4H 3S 2C', INTL.high, 'A 하이'],
] as const;
for (const [cards, cat, name] of hands) {
const v = evalIntl(h(cards));
expect(v.cat).toBe(cat);
expect(v.name).toBe(name);
}
for (let i = 1; i < hands.length; i++) expect(cmpKey(evalIntl(h(hands[i - 1]![0])).key, evalIntl(h(hands[i]![0])).key)).toBe(1);
});
test('wheel is the lowest straight, no wrap-around', () => {
expect(cmpKey(evalIntl(h('6C 5D 4H 3S 2C')).key, evalIntl(h('5C 4D 3H 2S AC')).key)).toBe(1);
expect(evalIntl(h('KC AD 2H 3S 4C')).cat).toBe(INTL.high);
});
test('no suit order: identical ranks tie', () => {
expect(cmpKey(evalIntl(h('AS KS 9D 7C 2H')).key, evalIntl(h('AD KD 9S 7H 2C')).key)).toBe(0);
});
test('kickers decide: §6.1 holdem example K pair with A kicker beats Q kicker', () => {
const board = h('KS KD 7C 4H 2S');
const a = bestIntl([...h('AC 3D'), ...board]);
const b = bestIntl([...h('QH JH'), ...board]);
expect(a.name).toBe('K 원페어');
expect(cmpKey(a.key, b.key)).toBe(1);
});
test('board play: 9-9-8-8-A board ties for players below A', () => {
const board = h('9S 9D 8C 8H AS');
const a = bestIntl([...h('2C 3D'), ...board]);
const b = bestIntl([...h('7H 4H'), ...board]);
expect(cmpKey(a.key, b.key)).toBe(0);
expect(a.name).toBe('9·8 투페어');
});
test('two pair kicker and quads kicker', () => {
expect(cmpKey(evalIntl(h('7C 7D 4H 4S KC')).key, evalIntl(h('7H 7S 4C 4D QC')).key)).toBe(1);
expect(cmpKey(evalIntl(h('7C 7D 7H 7S KC')).key, evalIntl(h('7C 7D 7H 7S QC')).key)).toBe(1);
});
test('omaha uses exactly two hole cards and three board cards (§5.4.2)', () => {
// One spade in hand + four spades on board → no flush.
const one = bestOmaha(h('AS KD QH JC'), h('2S 5S 8S 9S 3D'))!;
expect(one.cat).not.toBe(INTL.flush);
// Two spades in hand → flush.
const two = bestOmaha(h('AS KS QD JC'), h('2S 5S 8S 9D 3D'))!;
expect(two.cat).toBe(INTL.flush);
expect(two.name).toBe('A 플러시');
// Board quads can't be used whole: only 3 board cards count.
const quadBoard = bestOmaha(h('2C 3D 5H 7S'), h('9C 9D 9H 9S KD'))!;
expect(quadBoard.cat).toBe(INTL.trips);
expect(combos(h('AS KD QH JC'), 2).length * combos(h('2S 5S 8S 9S 3D'), 3).length).toBe(60);
});
});
describe('korean ranking (§6.2)', () => {
test('mountain > back straight > K-top straight; back SF > K-top SF > quads', () => {
const order = [
'AS KS QS JS TS', // royal
'AD 2D 3D 4D 5D', // back SF
'KH QH JH TH 9H', // K-top SF
'6S 5S 4S 3S 2S', // 6-top SF
'9C 9D 9H 9S 2C', // quads
'KC KD KH 2S 2C', // full house
'AH 9H 7H 4H 2H', // flush
'AC KD QH JS TC', // mountain
'AS 2D 3H 4C 5C', // back straight
'KC QD JH TS 9C', // K-top straight
'7C 7D 7H KS 2C', // trips
'KC KD 9H 9S 2C', // two pair
'AC AD 4H 3S 2C', // pair
'AC 7D 4H 3S 2C', // top
];
const cats = [KOR.royal, KOR.backStraightFlush, KOR.straightFlush, KOR.straightFlush, KOR.quads, KOR.fullHouse, KOR.flush, KOR.mountain, KOR.backStraight, KOR.straight, KOR.trips, KOR.twoPair, KOR.pair, KOR.high];
order.forEach((c, i) => expect(evalKorean(h(c)).cat).toBe(cats[i]!));
for (let i = 1; i < order.length; i++) expect(cmpKey(evalKorean(h(order[i - 1]!)).key, evalKorean(h(order[i]!)).key)).toBe(1);
expect(evalKorean(h('AC KD QH JS TC')).name).toBe('마운틴');
expect(evalKorean(h('AS 2D 3H 4C 5C')).name).toBe('백 스트레이트');
});
test('example 2: mountain with A♣ beats back straight with A♠ (rank before suit)', () => {
expect(cmpKey(evalKorean(h('AC KD QH JS TC')).key, evalKorean(h('AS 2D 3H 4C 5C')).key)).toBe(1);
});
test('example 1: 7♠7♣ beats 7◆7♥ (best suit of the pair), kickers ignored', () => {
const a = bestKorean(h('7S 7C 2D 3H 5C 9D 10C'));
const b = bestKorean(h('7D 7H AD KH QC 9S JC'));
expect(a.name).toBe('7 원페어');
expect(cmpKey(a.key, b.key)).toBe(1);
});
test('example 3: two pair K-5 loses to K-9 (low pair before suit, kicker ignored)', () => {
const a = evalKorean(h('KS KD 5S 5D AC'));
const b = evalKorean(h('KH KC 9C 9H 2C'));
expect(cmpKey(a.key, b.key)).toBe(-1);
// Same pairs: the better suit inside the high pair decides, not the kicker.
expect(cmpKey(evalKorean(h('KS KC 5S 5D 2C')).key, evalKorean(h('KD KH 5H 5C AC')).key)).toBe(1);
});
test('same-rank straights, flushes and tops are decided by suit', () => {
expect(cmpKey(evalKorean(h('KS QD JH TS 9C')).key, evalKorean(h('KD QS JS TD 9D')).key)).toBe(1);
expect(cmpKey(evalKorean(h('AS 2D 3H 4C 5C')).key, evalKorean(h('AD 2S 3S 4D 5D')).key)).toBe(1);
expect(cmpKey(evalKorean(h('AH 9H 7H 4H 2H')).key, evalKorean(h('AC 9C 7C 4C 2C')).key)).toBe(1);
expect(cmpKey(evalKorean(h('AD 9C 7S 4D 2S')).key, evalKorean(h('AC 9S 7D 4S 2D')).key)).toBe(1);
expect(cmpKey(evalKorean(h('AS AD 2C 3C 4H')).key, evalKorean(h('AH AC KS QS JD')).key)).toBe(1);
});
test('best 5 of 7: three pairs pick the top two', () => {
const v = bestKorean(h('KS KD 9H 9C 5S 5D 2C'));
expect(v.name).toBe('K·9 투페어');
expect(v.best).toHaveLength(5);
});
test('boss (보스) from open cards: pair beats top, same rank → suit', () => {
expect(cmpKey(openCardsKey(h('3C 3D')), openCardsKey(h('AS KS')))).toBe(1);
expect(cmpKey(openCardsKey(h('8S 8C')), openCardsKey(h('8D 8H')))).toBe(1);
expect(cmpKey(openCardsKey(h('AS 9C 2D')), openCardsKey(h('AD KC 3D')))).toBe(-1);
expect(cmpKey(openCardsKey(h('AS 9C 2D')), openCardsKey(h('AD 9S 2H')))).toBe(1);
expect(cmpKey(openCardsKey(h('5S 5C 5D 2H')), openCardsKey(h('KS KC 9D 9H')))).toBe(1);
});
});
describe('hi-lo low (§6.3)', () => {
test('korean: A-2-3-4-5-9-K → A-2-3-4-9 (back straight not allowed); aceToFive → A-2-3-4-5', () => {
const seven = h('AS 2D 3H 4C 5S 9D KH');
expect(bestLow(seven, 'korean')!.best.map((c) => c[0]).sort().join()).toBe(['2', '3', '4', '9', 'A'].sort().join());
expect(bestLow(seven, 'korean')!.name).toBe('9탑 로우(A-2-3-4-9)');
expect(bestLow(seven, 'aceToFive')!.name).toBe('5탑 로우(A-2-3-4-5)');
});
test('korean best low is A-2-3-4-6; flushes are not lows', () => {
const best = evalLow(h('AS 2D 3H 4C 6S'), 'korean')!;
expect(best.name).toBe('6탑 로우(A-2-3-4-6)');
expect(evalLow(h('AS 2S 3S 4S 6S'), 'korean')).toBeNull();
expect(evalLow(h('AS 2S 3S 4S 6S'), 'aceToFive')).not.toBeNull();
expect(evalLow(h('AS KD QH JC TS'), 'korean')).toBeNull(); // mountain is a straight
});
test('lower wins from the top card down; equal numbers → suit of the top card', () => {
const a = evalLow(h('AS 2D 3H 4C 7S'), 'korean')!;
const b = evalLow(h('AD 2S 3C 5H 7D'), 'korean')!;
expect(cmpKey(a.key, b.key)).toBe(1);
const c = evalLow(h('AD 2S 3C 4H 7S'), 'korean')!;
const d = evalLow(h('AS 2D 3H 4C 7H'), 'korean')!;
expect(cmpKey(c.key, d.key)).toBe(1);
});
test('needs five different numbers; eight qualifier', () => {
expect(bestLow(h('AS AD 2H 2C 3S 3D 4H'), 'korean')).toBeNull();
expect(bestLow(h('AS 2D 3H 4C 9S KD KH'), 'korean', 'eight')).toBeNull();
expect(bestLow(h('AS 2D 3H 4C 8S KD KH'), 'korean', 'eight')!.name).toBe('8탑 로우(A-2-3-4-8)');
});
});
describe('badugi (§6.4)', () => {
test('grades and names', () => {
expect(evalBadugi(h('AS 4C 5D 6H')).name).toBe('6 메이드');
expect(evalBadugi(h('AS 4C 5D 5H')).name).toBe('5 베이스');
expect(evalBadugi(h('AS 4S 5D 5H')).cat).toBe(2);
expect(evalBadugi(h('AS 4S 5S 9S')).name).toBe('노베이스');
expect(evalBadugi(h('AS AD AH AC')).name).toBe('노베이스');
expect(evalBadugi(h('AS 2D 3H 4C')).name).toBe('골프 (4 메이드)');
expect(evalBadugi(h('AS 2D 3H 5C')).name).toBe('세컨드 (5 메이드)');
expect(evalBadugi(h('AS 2D 4H 5C')).name).toBe('써드 (5 메이드)');
});
test('§10: A♠2♦3♥3♣ (3 base) < 2♠3♦7♥8♣ (8 made); 2♥4♣7♦8♠ < 2♠3♦7♥8♣; same numbers tie', () => {
const base = evalBadugi(h('AS 2D 3H 3C'));
const made8 = evalBadugi(h('2S 3D 7H 8C'));
expect(base.name).toBe('3 베이스');
expect(cmpKey(made8.key, base.key)).toBe(1);
expect(cmpKey(evalBadugi(h('2H 4C 7D 8S')).key, made8.key)).toBe(-1);
expect(cmpKey(evalBadugi(h('2H 3C 7S 8D')).key, made8.key)).toBe(0);
});
test('picks the lowest subset among same-size candidates', () => {
// K♠ and 2♠ share a suit: keep 2♠ → 4 base.
expect(evalBadugi(h('KS 2S 3D 4H')).name).toBe('4 베이스');
});
});
describe('indian (§6.5)', () => {
test('d20: 10 high, A lowest; d52: A highest', () => {
expect(cmpKey(evalIndian(['TS'], 'd20').key, evalIndian(['9H'], 'd20').key)).toBe(1);
expect(cmpKey(evalIndian(['AS'], 'd20').key, evalIndian(['2H'], 'd20').key)).toBe(-1);
expect(cmpKey(evalIndian(['AS'], 'd52').key, evalIndian(['KH'], 'd52').key)).toBe(1);
expect(cmpKey(evalIndian(['7S'], 'd20').key, evalIndian(['7H'], 'd20').key)).toBe(0);
});
test('2 cards: pair beats no pair, then high card, then low card', () => {
expect(cmpKey(evalIndian(['2S', '2H'], 'd20').key, evalIndian(['TS', '9H'], 'd20').key)).toBe(1);
expect(cmpKey(evalIndian(['3S', '3H'], 'd20').key, evalIndian(['2S', '2H'], 'd20').key)).toBe(1);
expect(cmpKey(evalIndian(['TS', '5H'], 'd20').key, evalIndian(['TH', '4S'], 'd20').key)).toBe(1);
expect(evalIndian(['TS', '5H'], 'd20').name).toBe('10·5');
});
test('d20 deck has A-10 of ♠ and ♥', () => {
const d = deckD20();
expect(d).toHaveLength(20);
expect(new Set(d).size).toBe(20);
expect(d).toContain('AS');
expect(d).toContain('TH');
expect(sign(evalIndian(['AS'], 'd20').key[0]!)).toBe(1);
});
});

View File

@@ -0,0 +1,19 @@
/**
* 인디언 포커 판정 (docs/games/poker.md §6.5).
* - 1장: higher number wins. d20: 10 > ... > 2 > A(1). d52: A > K > ... > 2. Suits ignored.
* - 2장 (이마 + 손): pair > no pair; pairs by number, otherwise high card then low card.
*/
import { hi, lo, rankLabel, type CardId, type HandValue } from '../cards';
export type IndianDeck = 'd20' | 'd52';
export const indianRank = (c: CardId, deck: IndianDeck): number => (deck === 'd20' ? lo(c) : hi(c));
/** Highest number of the deck (10 for d20, A for d52) — the 다이 벌칙 card. */
export const indianTop = (deck: IndianDeck): number => (deck === 'd20' ? 10 : 14);
export function evalIndian(cards: CardId[], deck: IndianDeck): HandValue {
const v = cards.map((c) => indianRank(c, deck)).sort((a, b) => b - a);
if (v.length === 1) return { cat: 0, key: [v[0]!], name: rankLabel(v[0]!), best: cards };
if (v[0] === v[1]) return { cat: 1, key: [1, v[0]!], name: `${rankLabel(v[0]!)} 페어`, best: cards };
return { cat: 0, key: [0, v[0]!, v[1]!], name: `${rankLabel(v[0]!)}·${rankLabel(v[1]!)}`, best: cards };
}

View File

@@ -0,0 +1,68 @@
/** 국제 족보 (docs/games/poker.md §6.1): holdem, omaha, 5-card draw. No suit order. */
import { suitOf } from '@bg/engine';
import { bestOf, combos, groupByRank, hi, rankLabel, type CardId, type HandValue } from '../cards';
export const INTL = {
straightFlush: 9,
quads: 8,
fullHouse: 7,
flush: 6,
straight: 5,
trips: 4,
twoPair: 3,
pair: 2,
high: 1,
} as const;
/** Top card of a 5-rank straight (wheel A-2-3-4-5 → 5), or 0. */
export function straightTop(values: number[]): number {
const u = [...new Set(values)].sort((a, b) => b - a);
if (u.length !== 5) return 0;
if (u[0]! - u[4]! === 4) return u[0]!;
if (u.join() === '14,5,4,3,2') return 5;
return 0;
}
/** Evaluates 1..5 cards (straights/flushes need exactly 5). */
export function evalIntl(cards: CardId[]): HandValue {
const groups = groupByRank(cards);
const vals = cards.map(hi);
const n = cards.length;
const flush = n === 5 && cards.every((c) => suitOf(c) === suitOf(cards[0]!));
const top = n === 5 ? straightTop(vals) : 0;
const sorted = [...cards].sort((a, b) => hi(b) - hi(a));
const desc = sorted.map(hi);
const byGroups = groups.flatMap(([, cs]) => cs);
const g0 = groups[0]!;
const g1 = groups[1];
const kick = (skip: number) => groups.slice(skip).flatMap(([v, cs]) => cs.map(() => v));
if (flush && top) {
return { cat: INTL.straightFlush, key: [INTL.straightFlush, top], name: top === 14 ? '로열 스트레이트 플러시' : `${rankLabel(top)} 스트레이트 플러시`, best: sorted };
}
if (g0[1].length === 4) return { cat: INTL.quads, key: [INTL.quads, g0[0], ...kick(1)], name: `${rankLabel(g0[0])} 포카드`, best: byGroups };
if (g0[1].length === 3 && g1 && g1[1].length >= 2) {
return { cat: INTL.fullHouse, key: [INTL.fullHouse, g0[0], g1[0]], name: `${rankLabel(g0[0])} 풀하우스`, best: byGroups };
}
if (flush) return { cat: INTL.flush, key: [INTL.flush, ...desc], name: `${rankLabel(desc[0]!)} 플러시`, best: sorted };
if (top) return { cat: INTL.straight, key: [INTL.straight, top], name: `${rankLabel(top)} 스트레이트`, best: sorted };
if (g0[1].length === 3) return { cat: INTL.trips, key: [INTL.trips, g0[0], ...kick(1)], name: `${rankLabel(g0[0])} 트리플`, best: byGroups };
if (g0[1].length === 2 && g1 && g1[1].length === 2) {
return { cat: INTL.twoPair, key: [INTL.twoPair, g0[0], g1[0], ...kick(2)], name: `${rankLabel(g0[0])}·${rankLabel(g1[0])} 투페어`, best: byGroups };
}
if (g0[1].length === 2) return { cat: INTL.pair, key: [INTL.pair, g0[0], ...kick(1)], name: `${rankLabel(g0[0])} 원페어`, best: byGroups };
return { cat: INTL.high, key: [INTL.high, ...desc], name: `${rankLabel(desc[0]!)} 하이`, best: sorted };
}
/** Best 5 of any number of cards (fewer than 5 → evaluated as-is). */
export function bestIntl(cards: CardId[]): HandValue {
if (cards.length <= 5) return evalIntl(cards);
return bestOf(combos(cards, 5), evalIntl)!;
}
/** Omaha: exactly 2 hole cards + exactly 3 board cards (§5.4.2). */
export function bestOmaha(hole: CardId[], board: CardId[]): HandValue | null {
if (board.length < 3 || hole.length < 2) return null;
const sets: CardId[][] = [];
for (const h of combos(hole, 2)) for (const b of combos(board, 3)) sets.push([...h, ...b]);
return bestOf(sets, evalIntl);
}

View File

@@ -0,0 +1,90 @@
/**
* 한국식 족보 (docs/games/poker.md §6.2): 7포커 / 하이로우 하이.
* Suit order ♠ > ◆ > ♥ > ♣ breaks ties; pairs ignore kickers; 마운틴 > 백 스트레이트 > K탑 스트레이트;
* 로열 > 백 스트레이트 플러시 > K탑 스트레이트 플러시.
*
* Keys: the entries that decide the hand between two players come first; trailing kicker ranks
* are only there so the best-5 selection shows the nicest cards (they never decide between players
* because the suit entry before them always differs in a single deck).
*/
import { suitOf } from '@bg/engine';
import { bestOf, combos, groupByRank, hi, rankLabel, suitVal, type CardId, type HandValue } from '../cards';
export const KOR = {
royal: 13,
backStraightFlush: 12,
straightFlush: 11,
quads: 10,
fullHouse: 9,
flush: 8,
mountain: 7,
backStraight: 6,
straight: 5,
trips: 4,
twoPair: 3,
pair: 2,
high: 1,
} as const;
const maxSuit = (cs: CardId[]) => Math.max(...cs.map(suitVal));
/** Evaluates 1..5 cards. Straights/flushes only exist with exactly 5 cards. */
export function evalKorean(cards: CardId[]): HandValue {
const n = cards.length;
const sorted = [...cards].sort((a, b) => hi(b) - hi(a) || suitVal(b) - suitVal(a));
const desc = sorted.map(hi);
const groups = groupByRank(cards);
const byGroups = groups.flatMap(([, cs]) => cs.slice().sort((a, b) => suitVal(b) - suitVal(a)));
const kick = (skip: number) => groups.slice(skip).flatMap(([v, cs]) => cs.map(() => v));
const flush = n === 5 && cards.every((c) => suitOf(c) === suitOf(cards[0]!));
const uniq = groups.length === 5 && n === 5;
const mountain = uniq && desc.join() === '14,13,12,11,10';
const back = uniq && desc.join() === '14,5,4,3,2';
const normal = uniq && !mountain && !back && desc[0]! - desc[4]! === 4;
const aceSuit = () => suitVal(sorted.find((c) => hi(c) === 14)!);
const flushSuit = flush ? suitVal(cards[0]!) : 0;
if (flush && mountain) return { cat: KOR.royal, key: [KOR.royal, flushSuit], name: '로열 스트레이트 플러시', best: sorted };
if (flush && back) return { cat: KOR.backStraightFlush, key: [KOR.backStraightFlush, flushSuit], name: '백 스트레이트 플러시', best: sorted };
if (flush && normal) {
return { cat: KOR.straightFlush, key: [KOR.straightFlush, desc[0]!, flushSuit], name: `${rankLabel(desc[0]!)}탑 스트레이트 플러시`, best: sorted };
}
const g0 = groups[0]!;
const g1 = groups[1];
if (g0[1].length === 4) return { cat: KOR.quads, key: [KOR.quads, g0[0], ...kick(1)], name: `${rankLabel(g0[0])} 포카드`, best: byGroups };
if (g0[1].length === 3 && g1 && g1[1].length >= 2) {
return { cat: KOR.fullHouse, key: [KOR.fullHouse, g0[0], g1[0]], name: `${rankLabel(g0[0])} 풀하우스`, best: byGroups };
}
if (flush) return { cat: KOR.flush, key: [KOR.flush, ...desc, flushSuit], name: `${rankLabel(desc[0]!)} 플러시`, best: sorted };
if (mountain) return { cat: KOR.mountain, key: [KOR.mountain, aceSuit()], name: '마운틴', best: sorted };
if (back) return { cat: KOR.backStraight, key: [KOR.backStraight, aceSuit()], name: '백 스트레이트', best: sorted };
if (normal) {
return { cat: KOR.straight, key: [KOR.straight, desc[0]!, suitVal(sorted[0]!)], name: `${rankLabel(desc[0]!)}탑 스트레이트`, best: sorted };
}
if (g0[1].length === 3) return { cat: KOR.trips, key: [KOR.trips, g0[0], ...kick(1)], name: `${rankLabel(g0[0])} 트리플`, best: byGroups };
if (g0[1].length === 2 && g1 && g1[1].length === 2) {
return {
cat: KOR.twoPair,
key: [KOR.twoPair, g0[0], g1[0], maxSuit(g0[1]), ...kick(2)],
name: `${rankLabel(g0[0])}·${rankLabel(g1[0])} 투페어`,
best: byGroups,
};
}
if (g0[1].length === 2) {
return { cat: KOR.pair, key: [KOR.pair, g0[0], maxSuit(g0[1]), ...kick(1)], name: `${rankLabel(g0[0])} 원페어`, best: byGroups };
}
return { cat: KOR.high, key: [KOR.high, ...desc, suitVal(sorted[0]!)], name: `${rankLabel(desc[0]!)}탑`, best: sorted };
}
/** Best 5 of up to 7 cards (21 combinations); fewer than 5 → partial hand (used for 보스 and hints). */
export function bestKorean(cards: CardId[]): HandValue {
if (cards.length <= 5) return evalKorean(cards);
return bestOf(combos(cards, 5), evalKorean)!;
}
/**
* 보스 (first to act) key from open cards only (§5.4.3): pairs/two pair/trips/quads/top with suit.
* Up to 4 open cards, so straights and flushes never apply.
*/
export function openCardsKey(up: CardId[]): number[] {
return evalKorean(up.slice(0, 4)).key;
}

View File

@@ -0,0 +1,40 @@
/**
* 하이로우 로우 판정 (docs/games/poker.md §6.3).
* - korean (기본): A=1, 서로 다른 숫자 5장, 스트레이트(A-2-3-4-5 포함)·플러시가 되면 안 됨 → 최고 로우 A-2-3-4-6.
* - aceToFive: 스트레이트·플러시 무시 → 최고 로우 A-2-3-4-5.
* Lower cards win; equal numbers → suit of the highest card (♠ best). `qualifier='eight'` needs 8 or lower on top.
*/
import { suitOf } from '@bg/engine';
import { bestOf, combos, hi, lo, rankLabel, suitVal, type CardId, type HandValue } from '../cards';
export type LowRule = 'korean' | 'aceToFive';
export type LowQualifier = 'none' | 'eight';
function isStraight(cards: CardId[]): boolean {
for (const val of [lo, hi]) {
const v = cards.map(val).sort((a, b) => a - b);
if (new Set(v).size === 5 && v[4]! - v[0]! === 4) return true;
}
return false;
}
/** Evaluates exactly 5 cards as a low hand, or null if they cannot form one. Larger key = better low. */
export function evalLow(cards: CardId[], rule: LowRule, qualifier: LowQualifier = 'none'): HandValue | null {
if (cards.length !== 5) return null;
const sorted = [...cards].sort((a, b) => lo(b) - lo(a) || suitVal(b) - suitVal(a));
const v = sorted.map(lo);
if (new Set(v).size !== 5) return null;
if (rule === 'korean') {
if (cards.every((c) => suitOf(c) === suitOf(cards[0]!))) return null;
if (isStraight(cards)) return null;
}
if (qualifier === 'eight' && v[0]! > 8) return null;
const asc = [...v].reverse().map(rankLabel).join('-');
return { cat: 0, key: [...v.map((x) => -x), suitVal(sorted[0]!)], name: `${rankLabel(v[0]!)}탑 로우(${asc})`, best: sorted };
}
/** Best low from up to 7 cards (any 5). Null when no valid low exists. */
export function bestLow(cards: CardId[], rule: LowRule, qualifier: LowQualifier = 'none'): HandValue | null {
if (cards.length < 5) return null;
return bestOf(combos(cards, 5), (cs) => evalLow(cs, rule, qualifier));
}

View File

@@ -0,0 +1,771 @@
/**
* Hand flow (docs/games/poker.md §5): forced bets, dealing, betting rounds through the shared engine,
* simultaneous steps (초이스 / 교환 / 선언), showdown, payouts, rebuy/elimination and game end.
* All functions mutate the (already cloned) state passed in.
*/
import {
applyBet,
bettingClosed,
buildPots,
chipsInPlay,
handDecided,
newBetting,
nextSeat,
postAnte,
postBlind,
potTotal,
returnUncalled,
setFirstToAct,
splitAmount,
startRound,
type BetAction,
type GameEvent,
type PlayerId,
type Rng,
} from '@bg/engine';
import { cmpKey, deck52, deckD20, hi, rankLabel, suitVal, type CardId } from './cards';
import { openCardsKey } from './eval/korean';
import { indianRank, indianTop } from './eval/indian';
import { DRAW_NAMES, isSeven, planFor, type Step } from './modes';
import { usesBlinds } from './options';
import { awardHigh, awardHiLo, bestPlayers, evaluate, type Evaluated } from './showdown';
import type { Award, HandPlayer, HandState, PokerState, SeatState, Side } from './types';
export interface Ctx {
rng: Rng;
now: number;
events: GameEvent[];
}
/** Safety cap for 최후 1인 games (§6.7). */
export const LAST_STANDING_CAP = 300;
const MAX_LOG = 100;
export const seatOf = (s: PokerState, id: PlayerId): SeatState | undefined => s.seats.find((x) => x.id === id);
export const idxOf = (h: HandState, id: PlayerId): number => h.order.indexOf(id);
export function inPlay(h: HandState, id: PlayerId): boolean {
const p = h.bet.players[idxOf(h, id)];
return !!p && (p.status === 'active' || p.status === 'allIn');
}
/** Players still contesting the pot (not folded), from the dealer's left. */
export const contenderIds = (h: HandState): PlayerId[] => h.order.filter((id) => inPlay(h, id));
export function log(s: PokerState, by: PlayerId | null, msg: string): void {
s.log.push({ hand: s.hand?.no ?? s.handsPlayed, by, msg });
if (s.log.length > MAX_LOG) s.log.splice(0, s.log.length - MAX_LOG);
}
/** Remaining rebuys: null = unlimited. */
export function rebuysLeft(s: PokerState, seat: SeatState): number | null {
const o = s.options;
if (o.rebuy === 'off') return 0;
if (o.rebuy === 'unlimited') return null;
return Math.max(0, o.rebuyLimit - seat.rebuys);
}
/** Is the hand being played the last one? */
function isLastHand(s: PokerState): boolean {
const o = s.options;
const cap = o.endCondition === 'hands' ? o.handLimit : LAST_STANDING_CAP;
return s.handsPlayed + 1 >= cap;
}
/** Σ chips on the table (conservation check): stacks + pots + carried pot + held penalties. */
export function chipsOnTable(s: PokerState): number {
const h = s.hand;
const live = h && !h.settled ? new Set(h.order) : new Set<PlayerId>();
let sum = s.carryOver;
for (const seat of s.seats) if (!live.has(seat.id)) sum += seat.stack;
if (h && !h.settled) sum += chipsInPlay(h.bet) + h.penalties.reduce((a, p) => a + p.amount, 0);
return sum;
}
// ---------------------------------------------------------------- hand start
const isParticipant = (seat: SeatState) => seat.stack > 0 && (seat.status === 'playing' || seat.status === 'left');
function newPlayer(id: PlayerId): HandPlayer {
return { id, hole: [], up: [], forehead: null, choice: null, choiceSel: null, discarded: null, drawPending: null, drawCounts: [], declare: null, ready: false, lastAction: null };
}
/** Starts the next hand (§5.1). `fixed` pins the dealer seat and the deck order (tests). */
export function startHand(s: PokerState, ctx: Ctx, fixed?: { dealerSeat?: number; deck?: CardId[] }): void {
const o = s.options;
const n = s.seats.length;
const part = (i: number) => isParticipant(s.seats[i]!);
const parts = s.seats.map((_, i) => i).filter(part);
if (parts.length < 2) return finishGame(s, ctx);
let dealer: number;
const prev = s.hand?.dealerSeat;
if (fixed?.dealerSeat !== undefined) dealer = fixed.dealerSeat;
else if (prev === undefined) dealer = parts[ctx.rng.int(parts.length)]!;
else {
const w = o.dealerRule === 'winner' && s.lastWinner ? s.seats.findIndex((x) => x.id === s.lastWinner) : -1;
dealer = w >= 0 && part(w) ? w : nextSeat(prev, n, (i) => !part(i));
}
const order: PlayerId[] = [];
for (let k = 1; k <= n; k++) {
const i = (dealer + k) % n;
if (part(i)) order.push(s.seats[i]!.id);
}
let deck: CardId[];
if (o.mode === 'indian') {
const need = order.length * o.indianCards;
const full = o.indianDeck === 'd20' ? deckD20() : deck52();
if (fixed?.deck) {
deck = fixed.deck.slice();
s.indianUsed = [];
} else if (o.indianReshuffle === 'everyHand' || !s.indianDeck || s.indianDeck.length < need) {
if (s.indianDeck && o.indianReshuffle === 'whenEmpty') log(s, null, '덱이 모자라서 쓴 카드를 모두 모아 다시 섞었어요');
deck = ctx.rng.shuffle(full);
s.indianUsed = [];
} else deck = s.indianDeck;
s.indianDeck = null;
} else deck = fixed?.deck?.slice() ?? ctx.rng.shuffle(deck52());
for (const seat of s.seats) if (order.includes(seat.id)) seat.stackAtHandStart = seat.stack;
const players: Record<PlayerId, HandPlayer> = {};
for (const id of order) players[id] = newPlayer(id);
let bet = newBetting(
order.map((id) => ({ id, stack: seatOf(s, id)!.stack })),
{ style: s.betStyle, baseUnit: s.baseUnit, koreanMax: o.koreanMaxRaise },
);
let sb: PlayerId | null = null;
let bb: PlayerId | null = null;
if (usesBlinds(o.mode)) {
const hu = order.length === 2;
const sbIdx = hu ? 1 : 0;
const bbIdx = hu ? 0 : 1;
bet = startRound(bet, 0);
bet = postBlind(bet, sbIdx, Math.max(1, Math.floor(s.baseUnit / 2)));
bet = postBlind(bet, bbIdx, s.baseUnit);
sb = order[sbIdx]!;
bb = order[bbIdx]!;
players[sb]!.lastAction = `스몰 블라인드 ${bet.players[sbIdx]!.round} 칩`;
players[bb]!.lastAction = `빅 블라인드 ${bet.players[bbIdx]!.round} 칩`;
} else bet = postAnte(bet, s.baseUnit);
// Players who left the room still pay forced bets, then fold automatically (§5.8).
order.forEach((id, i) => {
const bp = bet.players[i]!;
if (seatOf(s, id)!.status === 'left' && (bp.status === 'active' || bp.status === 'allIn')) {
bp.status = 'folded';
players[id]!.lastAction = '자리 비움 · 다이';
}
});
const no = s.handsPlayed + 1;
s.hand = {
no,
phase: 'deal',
step: 0,
waiting: false,
street: 0,
dealerSeat: dealer,
order,
sb,
bb,
deck,
muck: [],
board: [],
players,
bet,
penalties: [],
carryIn: s.carryOver,
declaresRevealed: false,
settled: false,
foldWin: false,
showdown: null,
awards: null,
winners: [],
potTotal: 0,
carriedOut: 0,
};
s.phase = 'playing';
ctx.events.push({ type: 'handStart', handNo: no, dealer: s.seats[dealer]!.id });
log(s, null, `${no}번째 판 시작 · 기본 단위 ${s.baseUnit} 칩`);
advance(s, ctx);
}
// ---------------------------------------------------------------- step machine
/** Runs automatic steps until a player has to act or the hand is settled. */
export function advance(s: PokerState, ctx: Ctx): void {
const plan = planFor(s.options);
for (let guard = 0; guard < 64; guard++) {
const h = s.hand!;
if (h.settled) return;
if (handDecided(h.bet)) return settleFold(s, ctx);
if (h.waiting) {
if (!stepComplete(h, plan[h.step]!)) return;
completeStep(s, ctx, plan[h.step]!);
h.waiting = false;
h.step++;
continue;
}
if (h.step >= plan.length) return settleShowdown(s, ctx);
const st = plan[h.step]!;
if (st.k === 'deal') {
deal(s, ctx, st);
h.step++;
continue;
}
if (st.k === 'bet') {
if (bettingClosed(h.bet)) {
// ≤1 player can still bet: run the cards out without betting (§5.2 라운드 종료).
h.bet = returnUncalled(h.bet).state;
h.street++;
h.step++;
continue;
}
const first = firstToAct(s);
const blindRound = h.street === 0 && usesBlinds(s.options.mode);
h.bet = blindRound ? setFirstToAct(h.bet, first) : startRound(h.bet, first);
if (!blindRound) for (const p of Object.values(h.players)) if (inPlay(h, p.id)) p.lastAction = null;
}
h.phase = st.k === 'bet' ? 'betting' : st.k;
h.waiting = true;
}
throw new Error('poker: hand did not progress');
}
function stepComplete(h: HandState, st: Step): boolean {
const cs = contenderIds(h);
switch (st.k) {
case 'bet':
return h.bet.turn === null;
case 'choice':
return cs.every((id) => h.players[id]!.choiceSel !== null);
case 'draw':
return cs.every((id) => h.players[id]!.drawPending !== null);
case 'declare':
return cs.every((id) => h.players[id]!.declare !== null);
default:
return true;
}
}
function completeStep(s: PokerState, ctx: Ctx, st: Step): void {
const h = s.hand!;
switch (st.k) {
case 'bet':
h.bet = returnUncalled(h.bet).state;
h.street++;
return;
case 'choice': {
const up: Record<PlayerId, CardId> = {};
for (const id of contenderIds(h)) {
const p = h.players[id]!;
const sel = p.choiceSel!;
p.discarded = sel.discard;
p.up = [sel.open];
p.hole = p.choice!.filter((c) => c !== sel.discard && c !== sel.open);
p.choice = null;
p.choiceSel = null;
up[id] = sel.open;
}
ctx.events.push({ type: 'choiceRevealed', up });
log(s, null, '모두 골라서 오픈 카드를 공개했어요');
return;
}
case 'draw':
return resolveDraws(s, ctx);
case 'declare': {
h.declaresRevealed = true;
const decl: Record<PlayerId, Side> = {};
for (const id of contenderIds(h)) {
decl[id] = h.players[id]!.declare!;
log(s, id, `선언: ${SIDE_KO[decl[id]!]}`);
}
ctx.events.push({ type: 'declared', declares: decl });
return;
}
}
}
export const SIDE_KO: Record<Side, string> = { high: '하이', low: '로우', swing: '스윙' };
function deal(s: PokerState, ctx: Ctx, st: Extract<Step, { k: 'deal' }>): void {
const h = s.hand!;
const take = (): CardId => {
const c = h.deck.shift();
if (!c) throw new Error('poker: deck exhausted');
return c;
};
if (st.burn) h.muck.push(take());
if (st.board) {
const cards: CardId[] = [];
for (let i = 0; i < st.board; i++) cards.push(take());
h.board.push(...cards);
ctx.events.push({ type: 'board', cards });
return;
}
const cs = contenderIds(h);
const per = st.hole ?? st.up ?? st.choice ?? (st.forehead ? 1 : 0);
for (let r = 0; r < per; r++) {
for (const id of cs) {
const c = take();
const p = h.players[id]!;
if (st.hole) p.hole.push(c);
else if (st.up) p.up.push(c);
else if (st.choice) (p.choice ??= []).push(c);
else p.forehead = c;
}
}
if (st.up) ctx.events.push({ type: 'dealt', to: cs, count: per, faceUp: Object.fromEntries(cs.map((id) => [id, h.players[id]!.up.at(-1)!])) });
else ctx.events.push({ type: 'dealt', to: cs, count: per });
}
/** 보스 (7포커): best open cards among players still in the hand (§5.4.3). */
export function bossOf(h: HandState): PlayerId | null {
const cs = contenderIds(h).filter((id) => h.players[id]!.up.length > 0);
if (cs.length === 0) return null;
return bestPlayers(cs, (id) => openCardsKey(h.players[id]!.up), h.order)[0] ?? null;
}
function firstToAct(s: PokerState): number {
const h = s.hand!;
const n = h.order.length;
if (usesBlinds(s.options.mode) && h.street === 0) return n === 2 ? 1 : 2 % n;
if (isSeven(s.options.mode)) {
const boss = bossOf(h);
return boss ? idxOf(h, boss) : 0;
}
return 0;
}
// ---------------------------------------------------------------- player actions inside a hand
const BET_KO: Record<BetAction['kind'], string> = {
check: '체크',
ping: '삥',
call: '콜',
double: '따당',
quarter: '쿼터',
half: '하프',
full: '풀',
die: '다이',
fold: '폴드',
raise: '레이즈',
allIn: '올인',
};
export function doBet(s: PokerState, ctx: Ctx, actor: PlayerId, ba: BetAction): void {
const h = s.hand!;
const before = h.bet.currentBet;
const { state, event } = applyBet(h.bet, idxOf(h, actor), ba);
h.bet = state;
const name = ba.kind === 'raise' && before === 0 ? '벳' : BET_KO[ba.kind];
const label = `${name}${event.amount > 0 ? ` ${event.amount} 칩` : ''}${event.allIn && ba.kind !== 'allIn' ? ' (올인)' : ''}`;
h.players[actor]!.lastAction = label;
log(s, actor, label);
ctx.events.push({ ...event });
if (ba.kind === 'fold' || ba.kind === 'die') indianPenalty(s, actor);
advance(s, ctx);
}
/** Folds a player outside of their turn (left the room). */
export function forceFold(s: PokerState, ctx: Ctx, id: PlayerId): void {
const h = s.hand!;
const i = idxOf(h, id);
if (h.settled || i < 0 || !inPlay(h, id)) return;
if (h.phase === 'betting' && h.waiting && h.bet.turn === i) {
return doBet(s, ctx, id, { kind: s.betStyle === 'korean' ? 'die' : 'fold' });
}
h.bet.players[i]!.status = 'folded';
h.players[id]!.lastAction = '나감 · 다이';
log(s, id, '방을 나가서 다이했어요');
indianPenalty(s, id);
advance(s, ctx);
}
/** 인디언 10 벌칙 (§5.4.7): folding with the deck's top card on the forehead. */
function indianPenalty(s: PokerState, id: PlayerId): void {
const o = s.options;
const h = s.hand!;
const f = h.players[id]!.forehead;
if (o.mode !== 'indian' || !o.indianFoldPenalty || !f || indianRank(f, o.indianDeck) !== indianTop(o.indianDeck)) return;
const bp = h.bet.players[idxOf(h, id)]!;
const amount = Math.min(bp.stack, o.indianPenaltyUnits * s.baseUnit);
if (amount <= 0) return;
bp.stack -= amount;
h.penalties.push({ from: id, amount });
log(s, id, `가장 높은 카드(${rankLabel(indianTop(o.indianDeck))})를 단 채 다이해서 벌칙 ${amount} 칩`);
}
/** 초이스 auto pick (§8.5): discard the lowest card, open the lowest of the rest. */
export function autoChoice(cards: CardId[]): { discard: CardId; open: CardId } {
const asc = [...cards].sort((a, b) => hi(a) - hi(b) || suitVal(a) - suitVal(b));
return { discard: asc[0]!, open: asc[1]! };
}
function resolveDraws(s: PokerState, ctx: Ctx): void {
const h = s.hand!;
const cs = contenderIds(h);
const current: CardId[] = [];
for (const id of cs) {
const p = h.players[id]!;
p.hole = p.hole.filter((c) => !p.drawPending!.includes(c));
current.push(...p.drawPending!);
}
// §5.4.5 덱 부족: deck first, then earlier discards reshuffled, then this round's discards.
const take = (): CardId => {
if (h.deck.length === 0) {
if (h.muck.length) {
h.deck = ctx.rng.shuffle(h.muck);
h.muck = [];
} else {
h.deck = ctx.rng.shuffle(current);
current.length = 0;
}
log(s, null, '덱이 모자라서 버린 카드를 섞어 다시 썼어요');
}
const c = h.deck.shift();
if (!c) throw new Error('poker: no cards left to draw');
return c;
};
const counts: Record<PlayerId, number> = {};
for (const id of cs) {
const p = h.players[id]!;
const k = p.drawPending!.length;
const round = p.drawCounts.length;
for (let i = 0; i < k; i++) p.hole.push(take());
p.drawCounts.push(k);
p.drawPending = null;
counts[id] = k;
const what = k === 0 ? '패스' : s.options.mode === 'badugi' && k === 4 ? '박스(4장)' : `${k}장 바꿈`;
const label = s.options.mode === 'badugi' ? `${DRAW_NAMES[round] ?? '교환'} ${what}` : `교환 ${what}`;
p.lastAction = label;
log(s, id, label);
}
h.muck.push(...current);
ctx.events.push({ type: 'drew', counts });
}
// ---------------------------------------------------------------- settlement
function settleFold(s: PokerState, ctx: Ctx): void {
const h = s.hand!;
h.bet = returnUncalled(h.bet).state;
const cs = contenderIds(h);
const total = potTotal(h.bet);
const penalty = h.penalties.reduce((a, p) => a + p.amount, 0);
h.potTotal = total + s.carryOver + penalty;
const awards: Award[] = [];
if (cs.length === 1) {
const w = cs[0]!;
awards.push({ potIndex: 0, to: w, amount: total });
if (s.carryOver) awards.push({ potIndex: 0, to: w, amount: s.carryOver, side: 'carry' });
if (penalty) awards.push({ potIndex: 0, to: w, amount: penalty, side: 'penalty' });
s.carryOver = 0;
s.carryFrom = [];
log(s, w, `혼자 남아서 ${h.potTotal} 칩을 가져갔어요`);
} else {
// Everyone left at once: give every contribution back.
for (const bp of h.bet.players) if (bp.total > 0) awards.push({ potIndex: 0, to: bp.id, amount: bp.total, side: 'refund' });
for (const p of h.penalties) awards.push({ potIndex: 0, to: p.from, amount: p.amount, side: 'refund' });
}
h.foldWin = true;
finishHand(s, ctx, awards);
}
function settleShowdown(s: PokerState, ctx: Ctx): void {
const h = s.hand!;
const o = s.options;
h.bet = returnUncalled(h.bet).state;
const pots = buildPots(h.bet.players);
const cs = contenderIds(h);
const evals: Record<PlayerId, Evaluated> = {};
for (const id of cs) evals[id] = evaluate(o, h.players[id]!, h.board);
const hiLo = o.mode === 'sevenHiLo';
h.showdown = cs.map((id) => ({
id,
cards: evals[id]!.cards,
best: evals[id]!.high.best,
handName: evals[id]!.high.name,
...(hiLo ? { lowName: evals[id]!.low?.name ?? null, declare: h.players[id]!.declare } : {}),
}));
const declares = Object.fromEntries(cs.map((id) => [id, h.players[id]!.declare]));
const penalty = h.penalties.reduce((a, p) => a + p.amount, 0);
h.potTotal = potTotal(h.bet) + s.carryOver + penalty;
const awards: Award[] = [];
const tied: { pi: number; amount: number; winners: PlayerId[] }[] = [];
pots.forEach((pot, pi) => {
if (pot.eligible.length === 1) {
awards.push({ potIndex: pi, to: pot.eligible[0]!, amount: pot.amount, side: 'refund' });
return;
}
if (hiLo) {
awards.push(...awardHiLo(pot, pi, h.order, evals, declares));
return;
}
const r = awardHigh(pot, pi, h.order, evals);
if (r.winners.length > 1 && o.mode === 'indian' && o.indianTie === 'carry') tied.push({ pi, amount: pot.amount, winners: r.winners });
else awards.push(...r.awards);
});
// 인디언 동점 이월 (§6.5): only if the game goes on and at least two connected players keep chips.
if (tied.length) {
const after = (id: PlayerId) => h.bet.players[idxOf(h, id)]!.stack + awards.filter((a) => a.to === id).reduce((x, a) => x + a.amount, 0);
const alive = s.seats.filter((seat) => seat.status === 'playing' && (h.order.includes(seat.id) ? after(seat.id) : seat.stack) > 0).length;
const canCarry = !isLastHand(s) && alive >= 2;
for (const t of tied) {
if (canCarry) {
h.carriedOut += t.amount;
s.carryFrom = t.winners;
} else awards.push(...splitAwards(t.pi, t.amount, t.winners));
}
}
// Carried pot from earlier hands and indian penalties go to the main pot's winners (or carry on).
const main = [...new Set(awards.filter((a) => a.potIndex === 0).map((a) => a.to))].sort((a, b) => h.order.indexOf(a) - h.order.indexOf(b));
const extra = s.carryOver + penalty;
if (main.length) {
if (s.carryOver) awards.push(...splitAwards(0, s.carryOver, main, 'carry'));
if (penalty) awards.push(...splitAwards(0, penalty, main, 'penalty'));
s.carryOver = h.carriedOut;
if (!h.carriedOut) s.carryFrom = [];
} else {
s.carryOver = extra + h.carriedOut;
}
if (h.carriedOut) log(s, null, `동점이라 ${h.carriedOut} 칩이 다음 판으로 넘어가요`);
finishHand(s, ctx, awards);
// Best hand stat (showdown winners only).
for (const id of h.winners) {
const v = evals[id];
if (!v) continue;
const b = s.stats.bestHand;
if (!b || cmpKey(v.high.key, b.key) > 0) s.stats.bestHand = { playerId: id, handName: v.high.name, handNo: h.no, key: v.high.key };
}
}
function splitAwards(potIndex: number, amount: number, winners: PlayerId[], side?: Award['side']): Award[] {
return Object.entries(splitAmount(amount, winners)).map(([to, amt]) => ({ potIndex, to, amount: amt, ...(side ? { side } : {}) }));
}
function finishHand(s: PokerState, ctx: Ctx, awards: Award[]): void {
const h = s.hand!;
for (const bp of h.bet.players) seatOf(s, bp.id)!.stack = bp.stack;
const won: Record<PlayerId, number> = {};
for (const a of awards) {
seatOf(s, a.to)!.stack += a.amount;
if (a.side !== 'refund') won[a.to] = (won[a.to] ?? 0) + a.amount;
}
h.awards = awards;
h.winners = h.order.filter((id) => won[id]);
h.settled = true;
h.waiting = false;
h.phase = 'handEnd';
s.handsPlayed++;
if (h.winners.length) {
s.lastWinner = h.winners.reduce((a, b) => (won[b]! > won[a]! ? b : a));
for (const id of h.winners) if (!h.foldWin || s.options.mode === 'indian') log(s, id, `${won[id]} 칩을 가져갔어요`);
}
const big = s.stats.biggestPot;
if (!big || h.potTotal > big.amount) s.stats.biggestPot = { handNo: h.no, amount: h.potTotal, winners: h.winners };
if (s.options.mode === 'indian') {
for (const id of h.order) {
const p = h.players[id]!;
s.indianUsed.push(...(p.forehead ? [p.forehead] : []), ...p.hole);
}
s.indianDeck = h.deck;
h.deck = [];
}
for (const id of h.order) h.players[id]!.ready = seatOf(s, id)!.status === 'left';
ctx.events.push({ type: 'handEnd', handNo: h.no, awards, foldWin: h.foldWin });
if (h.order.every((id) => h.players[id]!.ready)) closeHand(s, ctx);
}
// ---------------------------------------------------------------- between hands
function eliminate(s: PokerState, seat: SeatState, handNo: number): void {
seat.status = 'eliminated';
seat.eliminatedAtHand = handNo;
const last = s.eliminationSeq.at(-1);
if (last && seatOf(s, last[0]!)!.eliminatedAtHand === handNo) last.push(seat.id);
else s.eliminationSeq.push([seat.id]);
s.log.push({ hand: handNo, by: seat.id, msg: '칩이 없어 탈락했어요' });
}
/** After the result screen: busted players, level up, then rebuy / next hand / game end (§5.6, §5.7, §6.7). */
export function closeHand(s: PokerState, ctx: Ctx): void {
const h = s.hand!;
const o = s.options;
const endByHands = o.endCondition === 'hands' && s.handsPlayed >= o.handLimit;
for (const id of h.order) {
const seat = seatOf(s, id)!;
if (seat.stack > 0 || seat.status === 'eliminated') continue;
if (seat.status === 'left' || o.rebuy === 'off') eliminate(s, seat, h.no);
else if (endByHands) seat.status = 'busted';
else if (rebuysLeft(s, seat) !== 0) {
seat.status = 'busted';
s.rebuyPending.push(id);
} else eliminate(s, seat, h.no);
}
if (o.blindUpEvery > 0 && s.handsPlayed % o.blindUpEvery === 0) {
const cap = Math.max(s.baseUnit, Math.floor(o.startingChips / 5));
const next = Math.min(s.baseUnit * 2, cap);
if (next > s.baseUnit) {
s.baseUnit = next;
s.level++;
log(s, null, `기본 단위가 ${next} 칩으로 올랐어요(레벨 ${s.level})`);
}
}
proceed(s, ctx);
}
function shouldFinish(s: PokerState): boolean {
if (s.rebuyPending.length) return false;
const o = s.options;
if (o.endCondition === 'hands' && s.handsPlayed >= o.handLimit) return true;
if (s.handsPlayed >= LAST_STANDING_CAP) return true;
return s.seats.filter((x) => x.status === 'playing' && x.stack > 0).length <= 1;
}
export function proceed(s: PokerState, ctx: Ctx): void {
if (shouldFinish(s)) return finishGame(s, ctx);
if (s.rebuyPending.length) {
s.phase = 'rebuy';
return;
}
startHand(s, ctx);
}
export function decideRebuy(s: PokerState, ctx: Ctx, id: PlayerId, accept: boolean): void {
const seat = seatOf(s, id)!;
s.rebuyPending = s.rebuyPending.filter((x) => x !== id);
if (accept) {
seat.stack += s.options.startingChips;
seat.rebuys++;
seat.status = 'playing';
s.chipsIssued += s.options.startingChips;
log(s, id, `칩 ${s.options.startingChips}개를 다시 받았어요(${seat.rebuys}번째)`);
} else eliminate(s, seat, s.hand?.no ?? s.handsPlayed);
if (s.rebuyPending.length === 0) proceed(s, ctx);
}
/** Ends the game: pays out a pending carried pot and builds the ranking (§6.7, §8.6). */
export function finishGame(s: PokerState, ctx: Ctx): void {
const o = s.options;
if (s.carryOver > 0) {
const to = (s.carryFrom.length ? s.carryFrom : s.seats.filter((x) => x.stack > 0).map((x) => x.id)).filter((id) => seatOf(s, id));
const order = s.seats.map((x) => x.id);
to.sort((a, b) => order.indexOf(a) - order.indexOf(b));
for (const [id, amt] of Object.entries(splitAmount(s.carryOver, to.length ? to : order))) seatOf(s, id)!.stack += amt;
s.carryOver = 0;
s.carryFrom = [];
}
for (const id of s.rebuyPending) eliminate(s, seatOf(s, id)!, s.handsPlayed);
s.rebuyPending = [];
s.phase = 'finished';
const N = s.seats.length;
const net = (x: SeatState) => x.stack - o.startingChips * x.rebuys;
const group = <T>(xs: T[], key: (x: T) => number): T[][] => {
const out: T[][] = [];
for (const x of xs) {
const last = out.at(-1);
if (last && key(last[0]!) === key(x)) last.push(x);
else out.push([x]);
}
return out;
};
const lastStanding = o.endCondition === 'lastStanding' && s.handsPlayed < LAST_STANDING_CAP;
let groups: SeatState[][];
if (!lastStanding) {
groups = group([...s.seats].sort((a, b) => net(b) - net(a)), net);
} else {
const alive = group(s.seats.filter((x) => x.stack > 0).sort((a, b) => b.stack - a.stack), (x) => x.stack);
const placed = new Set(alive.flat().map((x) => x.id));
const elim: SeatState[][] = [];
for (const g of [...s.eliminationSeq].reverse()) {
const seats = g.map((id) => seatOf(s, id)!).filter((x) => !placed.has(x.id));
seats.forEach((x) => placed.add(x.id));
elim.push(...group(seats.sort((a, b) => b.stackAtHandStart - a.stackAtHandStart), (x) => x.stackAtHandStart));
}
const rest = s.seats.filter((x) => !placed.has(x.id));
groups = [...alive, ...elim, ...(rest.length ? [rest] : [])];
}
const scores: Record<PlayerId, number> = {};
let rank = 1;
for (const g of groups) {
for (const x of g) scores[x.id] = lastStanding ? N - rank + 1 : net(x);
rank += g.length;
}
const connected = s.seats.filter((x) => x.status === 'playing' && x.stack > 0).length;
const abandoned = connected <= 1 && s.seats.some((x) => x.status === 'left') && !(o.endCondition === 'hands' && s.handsPlayed >= o.handLimit);
const top = groups[0] ?? [];
const fmt = (n: number) => `${n > 0 ? '+' : ''}${n.toLocaleString('ko-KR')}칩`;
let summary: string;
if (abandoned) summary = `다른 참가자가 모두 나가서 ${s.handsPlayed}판 만에 끝났어요`;
else if (lastStanding) summary = `${s.handsPlayed}판 만에 최후의 1인이 정해졌어요`;
else summary = `${s.handsPlayed}판 끝 · ${top.length > 1 ? '공동 1위' : '1위'} ${fmt(net(top[0]!))}`;
s.result = { ranking: groups.map((g) => g.map((x) => x.id)), scores, summary, abandoned };
ctx.events.push({ type: 'gameEnd' });
}
// ---------------------------------------------------------------- who we wait for, deadlines
/** Players whose input the game is waiting for (also the timeout targets). */
export function waitingFor(s: PokerState): PlayerId[] {
if (s.phase === 'finished') return [];
if (s.phase === 'rebuy') return [...s.rebuyPending];
const h = s.hand;
if (!h) return [];
if (h.phase === 'handEnd') return h.order.filter((id) => !h.players[id]!.ready && seatOf(s, id)!.status !== 'left');
if (!h.waiting) return [];
const cs = contenderIds(h);
switch (h.phase) {
case 'betting':
return h.bet.turn === null ? [] : [h.order[h.bet.turn]!];
case 'choice':
return cs.filter((id) => !h.players[id]!.choiceSel);
case 'draw':
return cs.filter((id) => h.players[id]!.drawPending === null);
case 'declare':
return cs.filter((id) => !h.players[id]!.declare);
default:
return [];
}
}
/** Identifies the current timed situation; the deadline restarts only when this changes. */
export function deadlineSig(s: PokerState): string {
if (s.phase === 'finished') return 'end';
if (s.phase === 'rebuy') return `rebuy:${s.handsPlayed}`;
const h = s.hand!;
if (h.phase === 'betting') return `bet:${h.no}:${h.street}:${h.bet.turn}`;
return `${h.phase}:${h.no}:${h.step}`;
}
/** Length of the current deadline window in ms (§8.5), or 0 for none. */
export function deadlineWindow(s: PokerState): number {
const o = s.options;
if (s.phase === 'finished') return 0;
if (s.phase === 'rebuy') return o.rebuySeconds * 1000;
const h = s.hand!;
switch (h.phase) {
case 'betting': {
if (h.bet.turn === null) return 0;
const seat = seatOf(s, h.order[h.bet.turn]!)!;
return (seat.timeoutsInRow >= 2 ? o.awaySeconds : o.turnSeconds) * 1000;
}
case 'choice':
case 'draw':
case 'declare':
return (o.turnSeconds + 10) * 1000;
case 'handEnd':
return o.handEndSeconds * 1000;
default:
return 0;
}
}
export function syncDeadline(s: PokerState, now: number, prevSig: string): void {
const sig = deadlineSig(s);
if (sig === prevSig && s.deadlineAt !== null) return;
const ms = deadlineWindow(s);
s.deadlineSig = sig;
s.deadlineMs = ms;
s.deadlineAt = ms > 0 ? now + ms : null;
}

View File

@@ -0,0 +1,394 @@
/** 포커 (docs/games/poker.md): 7 modes on one engine, betting via @bg/engine betting.ts. */
import { z } from 'zod';
import {
fail,
legalBets,
ok,
timeoutBet,
toCallOf,
validateBet,
type BetAction,
type GameDefinition,
type GameResult,
type PlayerId,
type Rng,
type Validation,
} from '@bg/engine';
import { hi, type CardId } from './cards';
import {
advance,
autoChoice,
chipsOnTable,
closeHand,
decideRebuy,
deadlineSig,
doBet,
finishGame,
forceFold,
idxOf,
inPlay,
log,
seatOf,
startHand,
syncDeadline,
waitingFor,
type Ctx,
} from './hand';
import { playerRange, pokerOptionsSchema, resolveBetStyle, type PokerOptions } from './options';
import { pokerActionSchema, type BetActionType, type PokerAction, type PokerClientAction, type PokerState, type PokerView, type Side } from './types';
import { betTypeOf, buildView, drawLimitFor } from './view';
export { pokerOptionsSchema, playerRange, resolveBetStyle, MODE_KO, MODES, type PokerMode, type PokerOptions } from './options';
export { pokerActionSchema } from './types';
export type * from './types';
export { chipsOnTable, LAST_STANDING_CAP } from './hand';
const BET_KIND: Record<BetActionType, BetAction['kind']> = {
'k.check': 'check',
'k.ping': 'ping',
'k.call': 'call',
'k.ddadang': 'double',
'k.quarter': 'quarter',
'k.half': 'half',
'k.full': 'full',
'k.die': 'die',
'nl.fold': 'fold',
'nl.check': 'check',
'nl.call': 'call',
'nl.raise': 'raise',
'nl.allIn': 'allIn',
};
const isBetType = (t: string): t is BetActionType => t in BET_KIND;
export function toBetAction(a: PokerClientAction): BetAction {
if (a.type === 'nl.raise') return { kind: 'raise', to: a.to };
return { kind: BET_KIND[a.type as BetActionType] } as BetAction;
}
// ---------------------------------------------------------------- setup
export interface FixedHand {
dealerSeat?: number;
deck?: CardId[];
}
/** Builds the initial state and deals the first hand. `fixed` is for tests (dealer seat, deck order). */
export function setupPoker(ctx: { players: PlayerId[]; options: PokerOptions; rng: Rng; now: number }, fixed?: FixedHand): PokerState {
const o = ctx.options;
const range = playerRange(o);
if (ctx.players.length < range.min || ctx.players.length > range.max) throw new Error(`이 모드는 ${range.min}~${range.max}명이 할 수 있어요.`);
const s: PokerState = {
options: o,
betStyle: resolveBetStyle(o),
seats: ctx.players.map((id) => ({
id,
stack: o.startingChips,
rebuys: 0,
status: 'playing',
timeoutsInRow: 0,
eliminatedAtHand: null,
stackAtHandStart: o.startingChips,
})),
baseUnit: o.baseUnit,
level: 1,
hand: null,
handsPlayed: 0,
carryOver: 0,
carryFrom: [],
indianDeck: null,
indianUsed: [],
phase: 'playing',
rebuyPending: [],
deadlineAt: null,
deadlineMs: 0,
deadlineSig: '',
lastWinner: null,
eliminationSeq: [],
log: [],
stats: { biggestPot: null, bestHand: null },
chipsIssued: o.startingChips * ctx.players.length,
result: null,
};
startHand(s, { rng: ctx.rng, now: ctx.now, events: [] }, fixed);
syncDeadline(s, ctx.now, '');
return s;
}
// ---------------------------------------------------------------- validation
const KOREAN_RAISES: BetActionType[] = ['k.ping', 'k.ddadang', 'k.quarter', 'k.half', 'k.full'];
function validateBetAction(s: PokerState, actor: PlayerId, a: PokerClientAction & { type: BetActionType }): Validation {
const h = s.hand!;
if (h.phase !== 'betting' || !h.waiting) return fail('지금은 베팅할 때가 아니에요.');
if (!inPlay(h, actor)) return fail('이번 판에서 이미 다이했어요.');
const i = idxOf(h, actor);
if (h.bet.turn !== i) return fail('지금은 내 차례가 아니에요.');
const korean = s.betStyle === 'korean';
if (a.type.startsWith('k.') !== korean) return fail(korean ? '이 방은 한국식 베팅이에요.' : '이 방은 노리밋·팟리밋 베팅이에요.');
const p = h.bet.players[i]!;
const toCall = toCallOf(h.bet, p);
if ((a.type === 'k.check' || a.type === 'nl.check') && toCall > 0) return fail('콜 금액이 있어서 체크할 수 없어요.');
if ((a.type === 'k.call' || a.type === 'nl.call') && toCall === 0) return fail('낼 콜 금액이 없어요. 체크해 주세요.');
if (korean) {
if (KOREAN_RAISES.includes(a.type) && (p.acted || p.raised)) return fail('이번 라운드에서는 더 올릴 수 없어요(콜 또는 다이만 가능).');
if (a.type === 'k.full' && s.options.koreanMaxRaise !== 'full') return fail('이 방은 하프까지만 올릴 수 있어요.');
if (a.type === 'k.ping' && h.bet.opened) return fail('이미 누가 걸어서 삥은 할 수 없어요.');
if (a.type === 'k.ddadang' && toCall === 0) return fail('따당은 앞사람이 건 칩이 있을 때만 할 수 있어요.');
} else if (a.type === 'nl.raise' || a.type === 'nl.allIn') {
if (!p.mayRaise) return fail('이번 라운드에서는 더 올릴 수 없어요(콜 또는 폴드만 가능).');
const r = legalBets(h.bet, i).find((l) => l.kind === 'raise');
const stackTo = p.round + p.stack;
if (a.type === 'nl.raise' && r) {
if (a.to < r.minTo! && a.to !== stackTo) return fail(`최소 레이즈는 ${r.minTo} 칩이에요.`);
if (a.to > r.maxTo!) return fail(s.betStyle === 'potLimit' ? `팟리밋 최대는 ${r.maxTo} 칩이에요.` : '가진 칩보다 많이 걸 수 없어요.');
}
if (a.type === 'nl.allIn' && r && s.betStyle === 'potLimit' && stackTo > r.maxTo!) return fail(`팟리밋에서는 ${r.maxTo} 칩까지만 올릴 수 있어요.`);
}
const err = validateBet(h.bet, i, toBetAction(a));
return err ? fail(err) : ok;
}
export function validateDraw(s: PokerState, hole: CardId[], ids: CardId[]): Validation {
if (new Set(ids).size !== ids.length) return fail('같은 카드를 두 번 고를 수 없어요.');
if (!ids.every((c) => hole.includes(c))) return fail('내 손에 있는 카드만 바꿀 수 있어요.');
const { max, aceBonus } = drawLimitFor(s);
if (ids.length <= max) return ok;
const kept = hole.filter((c) => !ids.includes(c));
if (aceBonus && ids.length === max + 1 && kept.some((c) => hi(c) === 14)) return ok;
return fail(aceBonus ? '3장까지 바꿀 수 있어요(A를 남기면 4장).' : `${max}장까지 바꿀 수 있어요.`);
}
function validate(s: PokerState, actor: PlayerId, a: PokerAction): Validation {
const seat = seatOf(s, actor);
if (!seat) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
if (a.type === 'leave') return seat.status === 'left' ? fail('이미 나갔어요.') : ok;
if (a.type === 'timeout') return waitingFor(s).includes(actor) ? ok : fail('지금은 시간 초과를 적용할 수 없어요.');
if (a.type === 'rebuy') {
if (s.phase !== 'rebuy' || !s.rebuyPending.includes(actor)) return fail('지금은 칩을 다시 받을 수 없어요.');
return ok;
}
const h = s.hand;
if (s.phase !== 'playing' || !h) return fail('지금은 할 수 없어요.');
const hp = h.players[actor];
if (!hp) return fail('이번 판에 참가하지 않았어요.');
if (seat.status === 'left') return fail('이미 나간 자리예요.');
switch (a.type) {
case 'ready':
if (h.phase !== 'handEnd') return fail('아직 판이 끝나지 않았어요.');
return hp.ready ? fail('이미 준비했어요.') : ok;
case 'choose':
if (h.phase !== 'choice' || !h.waiting || !hp.choice) return fail('지금은 카드를 고를 때가 아니에요.');
if (!inPlay(h, actor)) return fail('이번 판에서 이미 다이했어요.');
if (hp.choiceSel) return fail('이미 골랐어요.');
if (a.discardId === a.openId) return fail('버릴 카드와 보여 줄 카드는 달라야 해요.');
if (!hp.choice.includes(a.discardId) || !hp.choice.includes(a.openId)) return fail('내 카드 중에서 골라 주세요.');
return ok;
case 'draw':
if (h.phase !== 'draw' || !h.waiting) return fail('지금은 카드를 바꿀 때가 아니에요.');
if (!inPlay(h, actor)) return fail('이번 판에서 이미 다이했어요.');
if (hp.drawPending) return fail('이미 바꿀 카드를 냈어요.');
return validateDraw(s, hp.hole, a.cardIds);
case 'declare':
if (h.phase !== 'declare' || !h.waiting) return fail('지금은 선언할 때가 아니에요.');
if (!inPlay(h, actor)) return fail('이번 판에서 이미 다이했어요.');
return hp.declare ? fail('이미 선언했어요.') : ok;
default:
if (!isBetType(a.type)) return fail('알 수 없는 행동이에요.');
return validateBetAction(s, actor, a as PokerClientAction & { type: BetActionType });
}
}
// ---------------------------------------------------------------- apply
function applyTimeout(s: PokerState, ctx: Ctx, actor: PlayerId): void {
if (s.phase === 'rebuy') return decideRebuy(s, ctx, actor, false);
const h = s.hand!;
const seat = seatOf(s, actor)!;
if (h.phase === 'handEnd') {
for (const id of h.order) h.players[id]!.ready = true;
return closeHand(s, ctx);
}
seat.timeoutsInRow++;
if (seat.timeoutsInRow === 2) log(s, actor, '시간 초과가 이어져 자리 비움으로 표시했어요');
const hp = h.players[actor]!;
switch (h.phase) {
case 'betting':
return doBet(s, ctx, actor, timeoutBet(h.bet, idxOf(h, actor)));
case 'choice':
hp.choiceSel = autoChoice(hp.choice!);
break;
case 'draw':
hp.drawPending = [];
break;
case 'declare':
hp.declare = 'high';
break;
}
advance(s, ctx);
}
function applyLeave(s: PokerState, ctx: Ctx, actor: PlayerId): void {
const seat = seatOf(s, actor)!;
if (seat.status !== 'eliminated') seat.status = 'left';
log(s, actor, '방을 나갔어요');
if (s.phase === 'rebuy' && s.rebuyPending.includes(actor)) decideRebuy(s, ctx, actor, false);
else if (s.phase === 'playing' && s.hand) {
const h = s.hand;
if (!h.settled) forceFold(s, ctx, actor);
else if (h.players[actor] && !h.players[actor]!.ready) {
h.players[actor]!.ready = true;
if (h.order.every((id) => h.players[id]!.ready)) closeHand(s, ctx);
}
}
// §6.7 공통: one or no connected player with chips left → end right away.
const connected = s.seats.filter((x) => x.status === 'playing' && x.stack > 0).length;
if (s.phase === 'playing' && s.hand?.settled && connected <= 1 && s.rebuyPending.length === 0) finishGame(s, ctx);
}
function apply(prev: PokerState, actor: PlayerId, a: PokerAction, { rng, now }: { rng: Rng; now: number }) {
const s = structuredClone(prev);
const ctx: Ctx = { rng, now, events: [] };
const sig = deadlineSig(prev);
const seat = seatOf(s, actor)!;
if (a.type !== 'timeout' && a.type !== 'leave') seat.timeoutsInRow = 0;
const hp = s.hand?.players[actor];
switch (a.type) {
case 'timeout':
applyTimeout(s, ctx, actor);
break;
case 'leave':
applyLeave(s, ctx, actor);
break;
case 'rebuy':
decideRebuy(s, ctx, actor, a.accept);
break;
case 'ready': {
const h = s.hand!;
hp!.ready = true;
if (h.order.every((id) => h.players[id]!.ready)) closeHand(s, ctx);
break;
}
case 'choose':
hp!.choiceSel = { discard: a.discardId, open: a.openId };
advance(s, ctx);
break;
case 'draw':
hp!.drawPending = [...a.cardIds];
log(s, actor, '바꿀 카드를 냈어요');
advance(s, ctx);
break;
case 'declare':
hp!.declare = a.side;
advance(s, ctx);
break;
default:
doBet(s, ctx, actor, toBetAction(a));
}
syncDeadline(s, now, sig);
return { state: s, events: ctx.events };
}
// ---------------------------------------------------------------- test helpers
function legalActions(s: PokerState, p: PlayerId): PokerAction[] {
if (!waitingFor(s).includes(p)) return [];
if (s.phase === 'rebuy') return [{ type: 'rebuy', accept: true }, { type: 'rebuy', accept: false }];
const h = s.hand!;
const hp = h.players[p]!;
switch (h.phase) {
case 'handEnd':
return [{ type: 'ready' }];
case 'choice': {
const out: PokerAction[] = [];
for (const d of hp.choice!) for (const o of hp.choice!) if (d !== o) out.push({ type: 'choose', discardId: d, openId: o });
return out;
}
case 'draw': {
const out: PokerAction[] = [];
for (let mask = 0; mask < 1 << hp.hole.length; mask++) {
const ids = hp.hole.filter((_, i) => mask & (1 << i));
if (validateDraw(s, hp.hole, ids).ok) out.push({ type: 'draw', cardIds: ids });
}
return out;
}
case 'declare':
return (['high', 'low', 'swing'] as Side[]).map((side) => ({ type: 'declare', side }));
case 'betting': {
const korean = s.betStyle === 'korean';
const out: PokerAction[] = [];
for (const l of legalBets(h.bet, idxOf(h, p))) {
const type = betTypeOf(korean, l.kind);
if (type === 'nl.raise') {
const tos = new Set([l.minTo!, l.maxTo!, Math.floor((l.minTo! + l.maxTo!) / 2)]);
for (const to of tos) out.push({ type, to });
} else out.push({ type } as PokerAction);
}
return out;
}
default:
return [];
}
}
/** Card ids only `player` (or nobody) may see: hidden cards, 초이스 cards, deck, burns and discards. */
function secretsOf(s: PokerState, player: PlayerId): string[] {
const out: string[] = [];
const h = s.hand;
if (h) {
const hp = h.players[player];
const revealed = !!h.showdown?.some((e) => e.id === player) || (s.options.mode === 'indian' && h.settled);
if (hp) {
if (!revealed) out.push(...hp.hole);
if (hp.choice) out.push(...hp.choice);
if (hp.discarded) out.push(hp.discarded);
}
out.push(...h.deck, ...h.muck);
}
if (s.indianDeck) out.push(...s.indianDeck);
return out;
}
function result(s: PokerState): (GameResult & { stats: PokerState['stats']; handsPlayed: number }) | null {
if (!s.result) return null;
return {
ranking: s.result.ranking,
scores: s.result.scores,
summary: s.result.summary,
reason: s.result.abandoned ? 'abandoned' : 'normal',
stats: s.stats,
handsPlayed: s.handsPlayed,
};
}
export const poker: GameDefinition<PokerState, PokerAction, PokerView, PokerOptions> = {
id: 'poker',
nameKo: '포커',
minPlayers: 2,
maxPlayers: 10,
stateVersion: 1,
defaultOptions: pokerOptionsSchema.parse({}),
optionsSchema: pokerOptionsSchema,
actionSchema: pokerActionSchema as z.ZodType<PokerAction>,
playersFor: (o) => playerRange(o),
setup: ({ players, options, rng, now }) => setupPoker({ players, options, rng, now }),
validate,
apply,
view: buildView,
activePlayers: waitingFor,
deadline: (s) => s.deadlineAt,
timeoutPlayers: waitingFor,
onTimeout: () => ({ type: 'timeout' }),
onLeave: (s, p) => {
const seat = seatOf(s, p);
if (!seat || s.phase === 'finished' || seat.status === 'left' || seat.status === 'eliminated') return null;
return { type: 'leave' };
},
result,
legalActions,
secretsOf,
};
/** Conservation helper for tests: chips on the table must equal chips ever issued. */
export const chipsBalanced = (s: PokerState): boolean => chipsOnTable(s) === s.chipsIssued;

View File

@@ -0,0 +1,72 @@
/** Per-mode hand plans (docs/games/poker.md §5.4). */
import type { PokerMode, PokerOptions } from './options';
export type Step =
| { k: 'deal'; hole?: number; up?: number; choice?: number; board?: number; burn?: boolean; forehead?: boolean }
| { k: 'bet' }
| { k: 'choice' }
| { k: 'draw' }
| { k: 'declare' };
const BET: Step = { k: 'bet' };
const DRAW: Step = { k: 'draw' };
const SEVEN: Step[] = [
{ k: 'deal', choice: 4 },
{ k: 'choice' },
{ k: 'deal', up: 1 },
BET,
{ k: 'deal', up: 1 },
BET,
{ k: 'deal', up: 1 },
BET,
{ k: 'deal', hole: 1 },
BET,
];
const COMMUNITY = (hole: number): Step[] => [
{ k: 'deal', hole },
BET,
{ k: 'deal', burn: true, board: 3 },
BET,
{ k: 'deal', burn: true, board: 1 },
BET,
{ k: 'deal', burn: true, board: 1 },
BET,
];
export function planFor(o: Pick<PokerOptions, 'mode' | 'indianCards'>): Step[] {
switch (o.mode) {
case 'holdem':
return COMMUNITY(2);
case 'omaha':
return COMMUNITY(4);
case 'sevenPoker':
return SEVEN;
case 'sevenHiLo':
return [...SEVEN, { k: 'declare' }];
case 'badugi':
return [{ k: 'deal', hole: 4 }, BET, DRAW, BET, DRAW, BET, DRAW, BET];
case 'fiveDraw':
return [{ k: 'deal', hole: 5 }, BET, DRAW, BET];
case 'indian':
return o.indianCards === 2
? [{ k: 'deal', forehead: true }, BET, { k: 'deal', hole: 1 }, BET]
: [{ k: 'deal', forehead: true }, BET];
}
}
const STREETS: Record<PokerMode, string[]> = {
holdem: ['프리플랍', '플랍', '턴', '리버'],
omaha: ['프리플랍', '플랍', '턴', '리버'],
sevenPoker: ['4구', '5구', '6구', '7구(히든)'],
sevenHiLo: ['4구', '5구', '6구', '7구(히든)'],
badugi: ['첫 베팅', '아침 뒤 베팅', '점심 뒤 베팅', '저녁 뒤(마지막) 베팅'],
fiveDraw: ['교환 전 베팅', '교환 뒤 베팅'],
indian: ['베팅', '손 카드 뒤 베팅'],
};
export const DRAW_NAMES = ['아침', '점심', '저녁'];
export const streetName = (mode: PokerMode, street: number): string => STREETS[mode][street] ?? '';
export const isSeven = (mode: PokerMode): boolean => mode === 'sevenPoker' || mode === 'sevenHiLo';

View File

@@ -0,0 +1,136 @@
/** Poker options (docs/games/poker.md §2). Every option carries admin-form metadata. */
import { z } from 'zod';
import type { BetStyle } from '@bg/engine';
export const MODES = ['holdem', 'omaha', 'sevenPoker', 'sevenHiLo', 'badugi', 'fiveDraw', 'indian'] as const;
export type PokerMode = (typeof MODES)[number];
export const MODE_KO: Record<PokerMode, string> = {
holdem: '텍사스 홀덤',
omaha: '오마하',
sevenPoker: '7포커',
sevenHiLo: '7포커 하이로우',
badugi: '바둑이',
fiveDraw: '5카드 드로우',
indian: '인디언 포커',
};
export const pokerOptionsSchema = z
.object({
mode: z.enum(MODES).default('sevenPoker').meta({ title: '게임 모드', labels: MODE_KO }),
betting: z
.enum(['auto', 'korean', 'noLimit', 'potLimit'])
.default('auto')
.meta({ title: '베팅 방식', labels: { auto: '모드 기본', korean: '한국식(삥·따당·하프)', noLimit: '노리밋', potLimit: '팟리밋' } }),
startingChips: z
.union([z.literal(500), z.literal(1000), z.literal(2000), z.literal(5000)])
.default(1000)
.meta({ title: '시작 칩', labels: { 500: '500 칩', 1000: '1,000 칩', 2000: '2,000 칩', 5000: '5,000 칩' } }),
baseUnit: z
.union([z.literal(2), z.literal(10), z.literal(20), z.literal(50)])
.default(10)
.meta({ title: '기본 단위(한국식은 앤티, 홀덤·오마하는 빅 블라인드)', labels: { 2: '2 칩', 10: '10 칩', 20: '20 칩', 50: '50 칩' } }),
koreanMaxRaise: z
.enum(['half', 'full'])
.default('half')
.meta({ title: '한국식 최대 레이즈', labels: { half: '하프까지', full: '풀까지' } }),
endCondition: z
.enum(['hands', 'lastStanding'])
.default('hands')
.meta({ title: '게임 종료', labels: { hands: '정해진 판 수', lastStanding: '최후 1인' } }),
handLimit: z
.union([z.literal(10), z.literal(20), z.literal(30), z.literal(50)])
.default(20)
.meta({ title: '판 수(정해진 판 수일 때)', labels: { 10: '10판', 20: '20판', 30: '30판', 50: '50판' } }),
blindUpEvery: z
.union([z.literal(0), z.literal(5), z.literal(10), z.literal(15)])
.default(0)
.meta({ title: '기본 단위 2배로 올리기', labels: { 0: '안 올림', 5: '5판마다', 10: '10판마다', 15: '15판마다' } }),
rebuy: z
.enum(['off', 'limited', 'unlimited'])
.default('unlimited')
.meta({ title: '칩 다시 받기(리바이)', labels: { off: '안 함(칩이 없으면 탈락)', limited: '횟수 제한', unlimited: '무제한' } }),
rebuyLimit: z
.union([z.literal(1), z.literal(2), z.literal(3)])
.default(1)
.meta({ title: '리바이 횟수(횟수 제한일 때)', labels: { 1: '1번', 2: '2번', 3: '3번' } }),
turnSeconds: z
.union([z.literal(15), z.literal(20), z.literal(30), z.literal(60)])
.default(20)
.meta({ title: '행동 제한 시간', labels: { 15: '15초', 20: '20초', 30: '30초', 60: '60초' } }),
dealerRule: z
.enum(['rotate', 'winner'])
.default('rotate')
.meta({ title: '다음 판 딜러(선)', labels: { rotate: '시계 방향으로 돌기', winner: '직전 판 승자' } }),
hints: z.boolean().default(true).meta({ title: '지금 족보 표시' }),
hiLoLow: z
.enum(['korean', 'aceToFive'])
.default('korean')
.meta({ title: '하이로우 로우 판정', labels: { korean: '스트레이트·플러시 불인정(최고 A-2-3-4-6)', aceToFive: '스트레이트·플러시 무시(최고 A-2-3-4-5)' } }),
hiLoQualifier: z
.enum(['none', 'eight'])
.default('none')
.meta({ title: '하이로우 로우 자격', labels: { none: '제한 없음(K탑까지)', eight: '8탑 이하만' } }),
drawLimit: z
.enum(['any', 'three'])
.default('any')
.meta({ title: '5카드 드로우 교환 장수', labels: { any: '0~5장', three: '3장까지(A를 남기면 4장)' } }),
indianCards: z
.union([z.literal(1), z.literal(2)])
.default(1)
.meta({ title: '인디언 포커 카드 수', labels: { 1: '1장(이마)', 2: '2장(이마 + 손)' } }),
indianDeck: z
.enum(['d20', 'd52'])
.default('d20')
.meta({ title: '인디언 포커 덱', labels: { d20: '20장(1~10 두 벌)', d52: '52장' } }),
indianFoldPenalty: z.boolean().default(true).meta({ title: '인디언: 가장 높은 카드를 단 채 다이하면 벌칙' }),
indianPenaltyUnits: z
.union([z.literal(5), z.literal(10), z.literal(20)])
.default(10)
.meta({ title: '인디언 다이 벌칙 크기(기본 단위의 몇 배)', labels: { 5: '5배', 10: '10배', 20: '20배' } }),
indianTie: z
.enum(['carry', 'split'])
.default('carry')
.meta({ title: '인디언 동점일 때', labels: { carry: '모인 칩을 다음 판으로 넘김', split: '나눠 가짐' } }),
indianReshuffle: z
.enum(['whenEmpty', 'everyHand'])
.default('whenEmpty')
.meta({ title: '인디언 덱 섞기', labels: { whenEmpty: '덱을 다 쓰면(카드 기억)', everyHand: '판마다' } }),
handEndSeconds: z
.union([z.literal(3), z.literal(4), z.literal(6), z.literal(10)])
.default(4)
.meta({ title: '판 결과 보여 주는 시간', labels: { 3: '3초', 4: '4초', 6: '6초', 10: '10초' } }),
rebuySeconds: z
.union([z.literal(10), z.literal(15), z.literal(30)])
.default(15)
.meta({ title: '리바이 고르는 시간', labels: { 10: '10초', 15: '15초', 30: '30초' } }),
awaySeconds: z
.union([z.literal(3), z.literal(5), z.literal(10)])
.default(5)
.meta({ title: '자리 비움일 때 제한 시간', labels: { 3: '3초', 5: '5초', 10: '10초' } }),
})
.superRefine((o, ctx) => {
if (o.baseUnit * 20 > o.startingChips) {
ctx.addIssue({ code: 'custom', path: ['baseUnit'], message: '시작 칩은 기본 단위의 20배 이상이어야 해요.' });
}
});
export type PokerOptions = z.infer<typeof pokerOptionsSchema>;
/** §2.1 player range per mode. */
export function playerRange(o: Pick<PokerOptions, 'mode' | 'indianCards' | 'indianDeck'>): { min: number; max: number } {
if (o.mode === 'holdem' || o.mode === 'omaha') return { min: 2, max: 10 };
if (o.mode === 'indian') return { min: 2, max: o.indianCards === 1 && o.indianDeck === 'd52' ? 10 : 6 };
return { min: 2, max: 6 };
}
/** `auto` → holdem noLimit, omaha potLimit, indian noLimit, others korean (§2.2). */
export function resolveBetStyle(o: Pick<PokerOptions, 'mode' | 'betting'>): BetStyle {
if (o.betting !== 'auto') return o.betting;
if (o.mode === 'holdem' || o.mode === 'indian') return 'noLimit';
if (o.mode === 'omaha') return 'potLimit';
return 'korean';
}
/** Blinds only in community-card modes (§2.1); everything else antes. */
export const usesBlinds = (mode: PokerMode): boolean => mode === 'holdem' || mode === 'omaha';

View 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;
}
}
});
});

View File

@@ -0,0 +1,100 @@
/** Showdown evaluation and pot distribution (docs/games/poker.md §6, §6.6). */
import { splitAmount, type PlayerId, type Pot } from '@bg/engine';
import { cmpKey, type CardId, type HandValue } from './cards';
import { bestIntl, bestOmaha } from './eval/international';
import { bestKorean } from './eval/korean';
import { bestLow } from './eval/low';
import { evalBadugi } from './eval/badugi';
import { evalIndian } from './eval/indian';
import type { PokerOptions } from './options';
import type { Award, HandPlayer, Side } from './types';
export interface Evaluated {
/** All cards that are revealed at showdown. */
cards: CardId[];
high: HandValue;
low: HandValue | null;
}
/** Cards a player holds (hole + open + forehead), in display order. */
export const allCards = (p: HandPlayer): CardId[] => [...(p.forehead ? [p.forehead] : []), ...p.hole, ...p.up];
export function evaluate(o: PokerOptions, p: HandPlayer, board: CardId[]): Evaluated {
const cards = allCards(p);
switch (o.mode) {
case 'holdem':
return { cards, high: bestIntl([...p.hole, ...board]), low: null };
case 'omaha':
return { cards, high: bestOmaha(p.hole, board) ?? bestIntl(p.hole), low: null };
case 'fiveDraw':
return { cards, high: bestIntl(p.hole), low: null };
case 'sevenPoker':
return { cards, high: bestKorean(cards), low: null };
case 'sevenHiLo':
return { cards, high: bestKorean(cards), low: bestLow(cards, o.hiLoLow, o.hiLoQualifier) };
case 'badugi':
return { cards, high: evalBadugi(p.hole), low: null };
case 'indian':
return { cards, high: evalIndian(cards, o.indianDeck), low: null };
}
}
/** Best players among `ids` by `key` (ties kept), ordered by `order` for odd-chip priority. */
export function bestPlayers(ids: PlayerId[], key: (id: PlayerId) => number[], order: PlayerId[]): PlayerId[] {
let best: PlayerId[] = [];
for (const id of ids) {
if (best.length === 0) best = [id];
else {
const c = cmpKey(key(id), key(best[0]!));
if (c > 0) best = [id];
else if (c === 0) best.push(id);
}
}
return best.sort((a, b) => order.indexOf(a) - order.indexOf(b));
}
function pay(potIndex: number, amount: number, winners: PlayerId[], side?: Award['side']): Award[] {
return Object.entries(splitAmount(amount, winners)).map(([to, amt]) => ({ potIndex, to, amount: amt, ...(side ? { side } : {}) }));
}
/** Plain high-hand pots: winners split evenly, odd chips from the dealer's left. */
export function awardHigh(pot: Pot, potIndex: number, order: PlayerId[], evals: Record<PlayerId, Evaluated>): { winners: PlayerId[]; awards: Award[] } {
const winners = bestPlayers(pot.eligible, (id) => evals[id]!.high.key, order);
return { winners, awards: pay(potIndex, pot.amount, winners) };
}
/**
* 하이로우 pot split (§6.6): a swing must strictly win both sides to scoop, otherwise it is out of this pot.
* Then best high declarer and best (qualified) low declarer halve it (high gets the odd chip).
* Nobody left on either side → everyone eligible competes on high.
*/
export function awardHiLo(
pot: Pot,
potIndex: number,
order: PlayerId[],
evals: Record<PlayerId, Evaluated>,
declares: Record<PlayerId, Side | null>,
): Award[] {
const E = pot.eligible;
const d = (id: PlayerId): Side => declares[id] ?? 'high';
for (const s of E.filter((id) => d(id) === 'swing')) {
const highComp = E.filter((id) => id !== s && d(id) !== 'low');
const lowComp = E.filter((id) => id !== s && d(id) !== 'high');
const myLow = evals[s]!.low;
const winsHigh = highComp.every((id) => cmpKey(evals[s]!.high.key, evals[id]!.high.key) > 0);
const winsLow = !!myLow && lowComp.every((id) => !evals[id]!.low || cmpKey(myLow.key, evals[id]!.low!.key) > 0);
if (winsHigh && winsLow) return pay(potIndex, pot.amount, [s], 'scoop');
}
const rest = E.filter((id) => d(id) !== 'swing');
const highs = rest.filter((id) => d(id) === 'high');
const lows = rest.filter((id) => d(id) === 'low' && evals[id]!.low);
const hw = highs.length ? bestPlayers(highs, (id) => evals[id]!.high.key, order) : [];
const lw = lows.length ? bestPlayers(lows, (id) => evals[id]!.low!.key, order) : [];
if (hw.length && lw.length) {
const highPart = Math.ceil(pot.amount / 2);
return [...pay(potIndex, highPart, hw, 'high'), ...pay(potIndex, pot.amount - highPart, lw, 'low')];
}
if (hw.length) return pay(potIndex, pot.amount, hw, 'high');
if (lw.length) return pay(potIndex, pot.amount, lw, 'low');
return pay(potIndex, pot.amount, bestPlayers(E, (id) => evals[id]!.high.key, order), 'high');
}

View File

@@ -0,0 +1,262 @@
/** Poker state / action / view types (docs/games/poker.md §8). */
import { z } from 'zod';
import type { BetStyle, BettingState, PlayerId } from '@bg/engine';
import type { CardId } from './cards';
import type { PokerMode, PokerOptions } from './options';
export type Side = 'high' | 'low' | 'swing';
export type SeatStatus = 'playing' | 'busted' | 'eliminated' | 'left';
export type HandPhase = 'deal' | 'choice' | 'betting' | 'draw' | 'declare' | 'handEnd';
const card = z.string().min(2).max(2);
export const pokerActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('choose'), discardId: card, openId: card }),
z.object({ type: z.literal('k.check') }),
z.object({ type: z.literal('k.ping') }),
z.object({ type: z.literal('k.call') }),
z.object({ type: z.literal('k.ddadang') }),
z.object({ type: z.literal('k.quarter') }),
z.object({ type: z.literal('k.half') }),
z.object({ type: z.literal('k.full') }),
z.object({ type: z.literal('k.die') }),
z.object({ type: z.literal('nl.fold') }),
z.object({ type: z.literal('nl.check') }),
z.object({ type: z.literal('nl.call') }),
z.object({ type: z.literal('nl.raise'), to: z.number().int().min(1).max(10_000_000) }),
z.object({ type: z.literal('nl.allIn') }),
z.object({ type: z.literal('draw'), cardIds: z.array(card).max(5) }),
z.object({ type: z.literal('declare'), side: z.enum(['high', 'low', 'swing']) }),
z.object({ type: z.literal('ready') }),
z.object({ type: z.literal('rebuy'), accept: z.boolean() }),
]);
export type PokerClientAction = z.infer<typeof pokerActionSchema>;
/** Client actions plus system-only ones (timer and room leave; not in the schema). */
export type PokerAction = PokerClientAction | { type: 'timeout' } | { type: 'leave' };
export type BetActionType = Extract<PokerClientAction['type'], `k.${string}` | `nl.${string}`>;
export interface SeatState {
id: PlayerId;
stack: number;
rebuys: number;
status: SeatStatus;
timeoutsInRow: number;
eliminatedAtHand: number | null;
stackAtHandStart: number;
}
export interface HandPlayer {
id: PlayerId;
/** Hidden cards: holdem/omaha hole, 7포커 히든, draw hands, indian hand card. */
hole: CardId[];
/** Open cards (7포커), in dealing order. */
up: CardId[];
/** Indian forehead card (everyone but the owner sees it). */
forehead: CardId | null;
/** 7포커 초이스: the 4 cards before choosing; null afterwards. */
choice: CardId[] | null;
choiceSel: { discard: CardId; open: CardId } | null;
/** 7포커 초이스 discarded card (only the owner ever sees it). */
discarded: CardId | null;
drawPending: CardId[] | null;
drawCounts: number[];
declare: Side | null;
ready: boolean;
lastAction: string | null;
}
export interface ShowdownEntry {
id: PlayerId;
cards: CardId[];
best: CardId[];
handName: string;
lowName?: string | null;
declare?: Side | null;
}
export interface Award {
potIndex: number;
to: PlayerId;
amount: number;
side?: 'high' | 'low' | 'scoop' | 'carry' | 'penalty' | 'refund';
}
export interface HandState {
no: number;
phase: HandPhase;
/** Index into the mode's step plan. */
step: number;
/** Waiting for player input on the current step. */
waiting: boolean;
/** Betting round counter (0-based). */
street: number;
dealerSeat: number;
/** Participants from the dealer's left, clockwise (deal order, odd-chip priority). */
order: PlayerId[];
sb: PlayerId | null;
bb: PlayerId | null;
deck: CardId[];
/** Burn cards and draw discards (never visible). */
muck: CardId[];
board: CardId[];
players: Record<PlayerId, HandPlayer>;
bet: BettingState;
/** Indian 다이 벌칙 chips held aside for the hand winner. */
penalties: { from: PlayerId; amount: number }[];
/** Carried pot (indian tie) brought into this hand. */
carryIn: number;
declaresRevealed: boolean;
/** Chips have been paid out (stacks are final for this hand). */
settled: boolean;
foldWin: boolean;
showdown: ShowdownEntry[] | null;
awards: Award[] | null;
winners: PlayerId[];
potTotal: number;
/** Chips carried to the next hand from this hand (indian tie). */
carriedOut: number;
}
export interface LogEntry {
hand: number;
by: PlayerId | null;
msg: string;
}
export interface PokerStats {
biggestPot: { handNo: number; amount: number; winners: PlayerId[] } | null;
bestHand: { playerId: PlayerId; handName: string; handNo: number; key: number[] } | null;
}
export interface PokerState {
options: PokerOptions;
betStyle: BetStyle;
seats: SeatState[];
baseUnit: number;
level: number;
hand: HandState | null;
handsPlayed: number;
carryOver: number;
/** Players tied when the pot was carried (fallback receivers if the game ends). */
carryFrom: PlayerId[];
indianDeck: CardId[] | null;
indianUsed: CardId[];
phase: 'playing' | 'rebuy' | 'finished';
rebuyPending: PlayerId[];
/** Current deadline (betting turn, simultaneous step, result screen or rebuy choice). */
deadlineAt: number | null;
deadlineMs: number;
/** Which situation the deadline belongs to (reset only when it changes). */
deadlineSig: string;
lastWinner: PlayerId | null;
/** Eliminated players in order, grouped per hand. */
eliminationSeq: PlayerId[][];
log: LogEntry[];
stats: PokerStats;
/** Total chips ever handed out (start + rebuys) — conservation check. */
chipsIssued: number;
result: { ranking: PlayerId[][]; scores: Record<PlayerId, number>; summary: string; abandoned: boolean } | null;
}
// ---------------------------------------------------------------- view
/** A card slot: the card id, or null when face down for this viewer. */
export type ViewCard = CardId | null;
export interface LegalBetView {
type: BetActionType;
amount: number;
allIn: boolean;
minTo?: number;
maxTo?: number;
}
export interface SeatView {
id: PlayerId;
seat: number;
stack: number;
rebuys: number;
status: SeatStatus;
away: boolean;
inHand: boolean;
folded: boolean;
allIn: boolean;
roundBet: number;
handBet: number;
dealer: boolean;
sb: boolean;
bb: boolean;
boss: boolean;
lastAction: string | null;
hole: ViewCard[];
up: ViewCard[];
/** Indian: undefined = no forehead card; null = hidden for this viewer. */
forehead?: ViewCard;
/** 7포커 초이스 cards still in hand (count only for others). */
choiceCount: number;
choiceDone: boolean;
drawDone: boolean;
drawCounts: number[];
declare: Side | null;
declared: boolean;
ready: boolean;
rebuyPending: boolean;
}
export interface PokerView {
mode: PokerMode;
betStyle: BetStyle;
options: Pick<
PokerOptions,
'koreanMaxRaise' | 'hints' | 'indianCards' | 'indianDeck' | 'handLimit' | 'endCondition' | 'turnSeconds' | 'startingChips' | 'drawLimit' | 'hiLoLow' | 'hiLoQualifier' | 'rebuy' | 'rebuyLimit'
>;
me: PlayerId | null;
seats: SeatView[];
phase: 'playing' | 'rebuy' | 'finished';
handPhase: HandPhase | null;
handNo: number;
handsPlayed: number;
baseUnit: number;
level: number;
street: number;
streetName: string;
board: CardId[];
pot: number;
pots: { amount: number; eligible: PlayerId[] }[];
currentBet: number;
carryOver: number;
penalties: { from: PlayerId; amount: number }[];
toAct: PlayerId | null;
waiting: PlayerId[];
deadlineAt: number | null;
/** Total time of the current deadline window (for timers). */
deadlineTotalMs: number;
my: {
seated: boolean;
choice: CardId[] | null;
choiceSel: { discard: CardId; open: CardId } | null;
discarded: CardId | null;
drawPending: CardId[] | null;
declare: Side | null;
handName: string | null;
lowName: string | null;
toCall: number;
legal: LegalBetView[];
canChoose: boolean;
canDraw: boolean;
drawMax: number;
drawAceBonus: boolean;
canDeclare: boolean;
canReady: boolean;
canRebuy: boolean;
rebuysLeft: number | null;
};
showdown: ShowdownEntry[] | null;
awards: Award[] | null;
winners: PlayerId[];
foldWin: boolean;
indianUsed: CardId[];
log: LogEntry[];
stats: PokerStats;
result: PokerState['result'];
}

View File

@@ -0,0 +1,206 @@
/**
* Per-viewer view (docs/games/poker.md §8.3). Hidden cards are sent as null (no id).
* Spectators (viewer = null) never see hidden cards or forehead cards before the hand ends.
*/
import { buildPots, legalBets, potTotal, toCallOf, type BetAction, type PlayerId } from '@bg/engine';
import type { CardId } from './cards';
import { bestIntl, bestOmaha } from './eval/international';
import { bestKorean } from './eval/korean';
import { bestLow } from './eval/low';
import { evalBadugi } from './eval/badugi';
import { bossOf, idxOf, inPlay, rebuysLeft, seatOf, waitingFor } from './hand';
import { isSeven, streetName } from './modes';
import type { BetActionType, HandPlayer, LegalBetView, PokerState, PokerView, SeatView } from './types';
const KOREAN_TYPE: Partial<Record<BetAction['kind'], BetActionType>> = {
check: 'k.check',
ping: 'k.ping',
call: 'k.call',
double: 'k.ddadang',
quarter: 'k.quarter',
half: 'k.half',
full: 'k.full',
die: 'k.die',
};
const NL_TYPE: Partial<Record<BetAction['kind'], BetActionType>> = {
fold: 'nl.fold',
check: 'nl.check',
call: 'nl.call',
raise: 'nl.raise',
allIn: 'nl.allIn',
};
export function betTypeOf(korean: boolean, kind: BetAction['kind']): BetActionType {
return (korean ? KOREAN_TYPE : NL_TYPE)[kind]!;
}
/** Hand names of the viewer's own cards (hints option). */
function hintNames(s: PokerState, p: HandPlayer, board: CardId[]): { high: string | null; low: string | null } {
const mode = s.options.mode;
switch (mode) {
case 'holdem':
return { high: p.hole.length ? bestIntl([...p.hole, ...board]).name : null, low: null };
case 'omaha':
return { high: bestOmaha(p.hole, board)?.name ?? null, low: null };
case 'fiveDraw':
return { high: p.hole.length ? bestIntl(p.hole).name : null, low: null };
case 'badugi':
return { high: p.hole.length ? evalBadugi(p.hole).name : null, low: null };
case 'sevenPoker':
case 'sevenHiLo': {
const cards = p.choice ?? [...p.hole, ...p.up];
if (!cards.length) return { high: null, low: null };
const low = mode === 'sevenHiLo' && cards.length >= 5 ? (bestLow(cards, s.options.hiLoLow, s.options.hiLoQualifier)?.name ?? '로우 없음') : null;
return { high: bestKorean(cards).name, low };
}
case 'indian':
return { high: null, low: null };
}
}
/** Log lines sent to clients (the state keeps 100). */
const VIEW_LOG = 30;
export function drawLimitFor(s: PokerState): { max: number; aceBonus: boolean } {
if (s.options.mode === 'badugi') return { max: 4, aceBonus: false };
return s.options.drawLimit === 'three' ? { max: 3, aceBonus: true } : { max: 5, aceBonus: false };
}
export function buildView(s: PokerState, viewer: PlayerId | null): PokerView {
const o = s.options;
const h = s.hand;
const live = !!h && !h.settled;
const boss = h && live && isSeven(o.mode) ? bossOf(h) : null;
const waiting = waitingFor(s);
const revealedIds = new Set(h?.showdown?.map((e) => e.id) ?? []);
const viewerInHand = !!(viewer && h && h.order.includes(viewer));
const seats: SeatView[] = s.seats.map((seat, i) => {
const hp = h?.players[seat.id];
const bp = h && hp ? h.bet.players[idxOf(h, seat.id)] : undefined;
const mine = viewer === seat.id;
const shown = !!h && (mine || revealedIds.has(seat.id) || (o.mode === 'indian' && h.settled));
const v: SeatView = {
id: seat.id,
seat: i,
stack: live && bp ? bp.stack : seat.stack,
rebuys: seat.rebuys,
status: seat.status,
away: seat.timeoutsInRow >= 2,
inHand: !!hp,
folded: bp?.status === 'folded',
allIn: bp?.status === 'allIn',
roundBet: live && bp ? bp.round : 0,
handBet: bp ? bp.total : 0,
dealer: !!h && h.dealerSeat === i,
sb: h?.sb === seat.id,
bb: h?.bb === seat.id,
boss: boss === seat.id,
lastAction: hp?.lastAction ?? null,
hole: hp ? hp.hole.map((c) => (shown ? c : null)) : [],
up: hp ? [...hp.up] : [],
choiceCount: hp?.choice?.length ?? 0,
choiceDone: !!hp?.choiceSel,
drawDone: hp?.drawPending != null,
drawCounts: hp ? [...hp.drawCounts] : [],
declare: hp && (mine || h!.declaresRevealed) ? hp.declare : null,
declared: !!hp?.declare,
ready: !!hp?.ready,
rebuyPending: s.rebuyPending.includes(seat.id),
};
if (hp?.forehead) {
// Never to its owner or spectators while the hand is running (§8.3 "고스트" 방지).
const visible = h!.settled || (viewerInHand && !mine);
v.forehead = visible ? hp.forehead : null;
}
return v;
});
const me = viewer ? seatOf(s, viewer) : undefined;
const hp = viewer && h ? h.players[viewer] : undefined;
const myIdx = h && viewer ? idxOf(h, viewer) : -1;
const bp = h && myIdx >= 0 ? h.bet.players[myIdx] : undefined;
const toAct = h && live && h.phase === 'betting' && h.waiting && h.bet.turn !== null ? h.order[h.bet.turn]! : null;
const korean = s.betStyle === 'korean';
const legal: LegalBetView[] =
toAct !== null && toAct === viewer
? legalBets(h!.bet, myIdx).map((l) => ({
type: betTypeOf(korean, l.kind),
amount: l.amount,
allIn: l.allIn,
...(l.minTo !== undefined ? { minTo: l.minTo, maxTo: l.maxTo } : {}),
}))
: [];
const hints = o.hints && hp && live && inPlay(h!, viewer!) ? hintNames(s, hp, h!.board) : { high: null, low: null };
const contesting = !!(h && live && viewer && inPlay(h, viewer));
const dl = drawLimitFor(s);
const left = me ? rebuysLeft(s, me) : 0;
return {
mode: o.mode,
betStyle: s.betStyle,
options: {
koreanMaxRaise: o.koreanMaxRaise,
hints: o.hints,
indianCards: o.indianCards,
indianDeck: o.indianDeck,
handLimit: o.handLimit,
endCondition: o.endCondition,
turnSeconds: o.turnSeconds,
startingChips: o.startingChips,
drawLimit: o.drawLimit,
hiLoLow: o.hiLoLow,
hiLoQualifier: o.hiLoQualifier,
rebuy: o.rebuy,
rebuyLimit: o.rebuyLimit,
},
me: me ? me.id : null,
seats,
phase: s.phase,
handPhase: h?.phase ?? null,
handNo: h?.no ?? 0,
handsPlayed: s.handsPlayed,
baseUnit: s.baseUnit,
level: s.level,
street: h?.street ?? 0,
streetName: h && h.phase === 'betting' ? streetName(o.mode, h.street) : '',
board: h ? [...h.board] : [],
pot: h ? (live ? potTotal(h.bet) : h.potTotal) : 0,
pots: h && live ? buildPots(h.bet.players).map((p) => ({ amount: p.amount, eligible: p.eligible })) : [],
currentBet: h && live ? h.bet.currentBet : 0,
carryOver: s.carryOver,
penalties: h ? h.penalties.map((p) => ({ ...p })) : [],
toAct,
waiting,
deadlineAt: s.deadlineAt,
deadlineTotalMs: s.deadlineMs,
my: {
seated: !!me,
choice: hp?.choice ? [...hp.choice] : null,
choiceSel: hp?.choiceSel ? { ...hp.choiceSel } : null,
discarded: hp?.discarded ?? null,
drawPending: hp?.drawPending ? [...hp.drawPending] : null,
declare: hp?.declare ?? null,
handName: hints.high,
lowName: hints.low,
toCall: bp && live ? toCallOf(h!.bet, bp) : 0,
legal,
canChoose: contesting && h!.phase === 'choice' && !hp!.choiceSel && !!hp!.choice,
canDraw: contesting && h!.phase === 'draw' && h!.waiting && hp!.drawPending === null,
drawMax: dl.max,
drawAceBonus: dl.aceBonus,
canDeclare: contesting && h!.phase === 'declare' && h!.waiting && !hp!.declare,
canReady: !!(s.phase === 'playing' && h?.phase === 'handEnd' && hp && !hp.ready && me?.status !== 'left'),
canRebuy: s.phase === 'rebuy' && !!viewer && s.rebuyPending.includes(viewer),
rebuysLeft: left,
},
showdown: h?.showdown ? h.showdown.map((e) => ({ ...e, cards: [...e.cards], best: [...e.best] })) : null,
awards: h?.awards ? h.awards.map((a) => ({ ...a })) : null,
winners: h ? [...h.winners] : [],
foldWin: !!h?.foldWin,
indianUsed: [...s.indianUsed],
log: s.log.slice(-VIEW_LOG).map((e) => ({ ...e })),
stats: structuredClone(s.stats),
result: s.result ? structuredClone(s.result) : null,
};
}