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

@@ -4,6 +4,8 @@ import { othello } from './othello';
import { chess } from './chess';
import { janggi } from './janggi';
import { baduk } from './baduk';
import { seotda } from './seotda';
import { poker } from './poker';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -11,4 +13,6 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[chess.id]: chess,
[janggi.id]: janggi,
[baduk.id]: baduk,
[seotda.id]: seotda,
[poker.id]: poker,
};

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,
};
}

View File

@@ -0,0 +1,197 @@
/**
* 섯다 족보와 쇼다운 판정 (docs/games/seotda.md §6.1–6.3). Pure functions, table-tested.
*
* Card ids are the shared HWATU ids from SEOTDA_DECK (packages/engine/src/cards.ts):
* month m (1..10) → special card id (m-1)*4, plain card id (m-1)*4+1.
*/
export type SpecialKind = 'ttaengjabi' | 'amhaeng' | 'gusa' | 'mungGusa';
export interface HandRules {
gwangTtaeng: 'all' | 'only38';
ttaengjabi: 'off' | 'strict' | 'loose';
amhaeng: boolean;
gusa: boolean;
mungGusa: 'off' | 'upTo8' | 'upTo9' | 'upToJang';
}
export interface HandEval {
/** Comparison value of the normal hand (special hands keep their normal value). */
value: number;
/** Display name: the special name when a special is active, otherwise the normal name. */
name: string;
/** The normal name ("3끗", "알리", …). */
baseName: string;
special: SpecialKind | null;
}
export const monthOf = (id: number): number => Math.floor(id / 4) + 1;
/** Slot 0 of each month: 광 for 1·3·8월, 열끗 for the others. */
export const isSpecialCard = (id: number): boolean => id % 4 === 0;
export const isGwang = (id: number): boolean => isSpecialCard(id) && [1, 3, 8].includes(monthOf(id));
/** 열끗 of a month (4·7·9월 matter for special hands). */
export const isYeol = (id: number, month: number): boolean => isSpecialCard(id) && monthOf(id) === month;
export const SPECIAL_NAME: Record<SpecialKind, string> = {
ttaengjabi: '땡잡이',
amhaeng: '암행어사',
gusa: '구사',
mungGusa: '멍텅구리구사',
};
const PAIRS: [number, number, number, string][] = [
[1, 2, 806, '알리'],
[1, 4, 805, '독사'],
[1, 9, 804, '구삥'],
[1, 10, 803, '장삥'],
[4, 10, 802, '장사'],
[4, 6, 801, '세륙'],
];
function normal(a: number, b: number, rules: HandRules): { value: number; name: string } {
const ma = monthOf(a);
const mb = monthOf(b);
if (isGwang(a) && isGwang(b)) {
const set = new Set([ma, mb]);
if (set.has(3) && set.has(8)) return { value: 1000, name: '38광땡' };
if (rules.gwangTtaeng === 'all') {
if (set.has(1) && set.has(8)) return { value: 990, name: '18광땡' };
if (set.has(1) && set.has(3)) return { value: 980, name: '13광땡' };
}
}
if (ma === mb) {
if (ma === 10) return { value: 910, name: '장땡' };
if (ma === 1) return { value: 901, name: '삥땡' };
return { value: 900 + ma, name: `${ma}땡` };
}
const lo = Math.min(ma, mb);
const hi = Math.max(ma, mb);
for (const [x, y, v, n] of PAIRS) if (lo === x && hi === y) return { value: v, name: n };
const k = (ma + mb) % 10;
if (k === 9) return { value: 9, name: '갑오' };
if (k === 0) return { value: 0, name: '망통' };
return { value: k, name: `${k}끗` };
}
function specialOf(a: number, b: number, rules: HandRules): SpecialKind | null {
const months = new Set([monthOf(a), monthOf(b)]);
const has = (m: number) => months.has(m);
const yeol = (m: number) => isYeol(a, m) || isYeol(b, m);
if (has(3) && has(7)) {
if (rules.ttaengjabi === 'loose') return 'ttaengjabi';
if (rules.ttaengjabi === 'strict' && isGwang(a) !== isGwang(b) && (isGwang(a) ? monthOf(a) : monthOf(b)) === 3 && yeol(7)) return 'ttaengjabi';
}
if (rules.amhaeng && has(4) && has(7) && yeol(4) && yeol(7)) return 'amhaeng';
if (rules.gusa && has(4) && has(9)) {
if (rules.mungGusa !== 'off' && yeol(4) && yeol(9)) return 'mungGusa';
return 'gusa';
}
return null;
}
/** 족보 of two cards (order-independent). */
export function evalSeotda(a: number, b: number, rules: HandRules): HandEval {
const n = normal(a, b, rules);
const special = specialOf(a, b, rules);
return { value: n.value, baseName: n.name, name: special ? SPECIAL_NAME[special] : n.name, special };
}
/** 구사 재경기 기준값 (§6.2): the best other normal hand must be ≤ this. */
export function gusaThreshold(special: SpecialKind | null, rules: HandRules): number | null {
if (special === 'gusa') return 806;
if (special === 'mungGusa') return rules.mungGusa === 'upTo9' ? 909 : rules.mungGusa === 'upToJang' ? 910 : 908;
return null;
}
export type Resolution =
| { type: 'win'; winners: string[]; reason: 'normal' | 'ttaengjabi' | 'amhaeng' }
| { type: 'split'; winners: string[] }
| { type: 'rematch'; reason: 'gusa' | 'mungGusa' | 'tie'; participants: string[] };
/**
* Showdown judgement for one pot (§6.3). `players` are the pot's eligible contenders in
* seat order from the dealer (선) — split winners keep that order for odd chips.
*/
export function resolve(players: { id: string; hand: HandEval }[], tieRule: 'rematch' | 'split', rules: HandRules): Resolution {
const vmax = Math.max(...players.map((p) => p.hand.value));
const holders = (k: SpecialKind) => players.filter((p) => p.hand.special === k).map((p) => p.id);
let W: string[];
let reason: 'normal' | 'ttaengjabi' | 'amhaeng' = 'normal';
if (holders('ttaengjabi').length > 0 && vmax >= 901 && vmax <= 909) {
W = holders('ttaengjabi');
reason = 'ttaengjabi';
} else if (holders('amhaeng').length > 0 && (vmax === 980 || vmax === 990)) {
W = holders('amhaeng');
reason = 'amhaeng';
} else {
W = players.filter((p) => p.hand.value === vmax).map((p) => p.id);
}
// 구사 재경기: a 구사 holder who does not already win, with every other hand ≤ its threshold.
let gusa: 'gusa' | 'mungGusa' | null = null;
for (const h of players) {
if (h.hand.special !== 'gusa' && h.hand.special !== 'mungGusa') continue;
if (W.includes(h.id)) continue;
const t = gusaThreshold(h.hand.special, rules);
const best = Math.max(...players.filter((q) => q.id !== h.id).map((q) => q.hand.value));
if (t !== null && best <= t) gusa = gusa === 'mungGusa' ? gusa : h.hand.special;
}
if (gusa) return { type: 'rematch', reason: gusa, participants: players.map((p) => p.id) };
if (W.length === 1) return { type: 'win', winners: W, reason };
return tieRule === 'rematch' ? { type: 'rematch', reason: 'tie', participants: W } : { type: 'split', winners: W };
}
/** The three 2-card combinations of a 3-card hand. */
export function combos(hand: number[]): [number, number][] {
const out: [number, number][] = [];
for (let i = 0; i < hand.length; i++) for (let j = i + 1; j < hand.length; j++) out.push([hand[i]!, hand[j]!]);
return out;
}
/** Auto choice (§8.5): highest normal value, then a special combo, then the smaller id sum. */
export function bestChoice(hand: number[], rules: HandRules): [number, number] {
let best: [number, number] | null = null;
let bestEval: HandEval | null = null;
for (const c of combos(hand)) {
const e = evalSeotda(c[0], c[1], rules);
if (!best || !bestEval) {
best = c;
bestEval = e;
continue;
}
const better =
e.value > bestEval.value ||
(e.value === bestEval.value && !!e.special && !bestEval.special) ||
(e.value === bestEval.value && !!e.special === !!bestEval.special && c[0] + c[1] < best[0] + best[1]);
if (better) {
best = c;
bestEval = e;
}
}
return [Math.min(...best!), Math.max(...best!)] as [number, number];
}
/** Rough strength for beginner hints (§9). */
export function strengthOf(e: HandEval): 'high' | 'mid' | 'low' {
if (e.value >= 801) return 'high';
if (e.value >= 5 || e.special === 'gusa' || e.special === 'mungGusa' || e.special === 'ttaengjabi' || e.special === 'amhaeng') return 'mid';
return 'low';
}
/** Beginner sentence for my current hand (hints option). */
export function hintText(e: HandEval, rules: HandRules): string {
const lvl = { high: '아주 센 패예요.', mid: '중간 정도 패예요.', low: '약한 패예요.' }[strengthOf(e)];
switch (e.special) {
case 'ttaengjabi':
return '땡잡이예요. 상대 최고 패가 1땡~9땡이면 이겨요. 아니면 망통이에요.';
case 'amhaeng':
return '암행어사예요. 상대 최고 패가 13광땡이나 18광땡이면 이겨요. 아니면 1끗이에요.';
case 'gusa':
return '구사예요. 상대가 알리 이하면 재경기예요. 아니면 3끗이에요.';
case 'mungGusa': {
const upTo = rules.mungGusa === 'upTo9' ? '9땡' : rules.mungGusa === 'upToJang' ? '장땡' : '8땡';
return `멍텅구리구사예요. 상대가 ${upTo} 이하면 재경기예요. 아니면 3끗이에요.`;
}
default:
return `지금 패는 ${e.name}이에요. ${lvl}`;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,703 @@
import { describe, expect, test } from 'bun:test';
import { HWATU, SEOTDA_DECK, SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
bestChoice,
chipsTotal,
evalSeotda,
resolve,
seotda,
type HandEval,
type HandRules,
type SeotdaAction,
type SeotdaOptions,
type SeotdaState,
type SpecialKind,
} from './index';
// ---------------------------------------------------------------- helpers
/** Special card of a month (1·3·8 광, others 열끗) and the plain card. */
const S = (m: number) => (m - 1) * 4;
const P = (m: number) => (m - 1) * 4 + 1;
const DEFAULT_RULES: HandRules = { gwangTtaeng: 'all', ttaengjabi: 'strict', amhaeng: true, gusa: true, mungGusa: 'upTo8' };
const ev = (a: number, b: number, r: Partial<HandRules> = {}) => evalSeotda(a, b, { ...DEFAULT_RULES, ...r });
let clock = 1000;
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
/** Fresh game whose first dealer is `dealer` (seat order = players). */
function make(opts: Partial<SeotdaOptions> = {}, players = ['A', 'B', 'C'], dealer = 'A'): SeotdaState {
const options = seotda.optionsSchema.parse(opts);
for (let seed = 1; seed < 500; seed++) {
const s = seotda.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
if (s.hand!.dealer === dealer) return s;
}
throw new Error('no seed with that dealer');
}
/** Replaces the dealt cards; `next` are the next cards off the deck (in dealing order). */
function rig(s: SeotdaState, cards: Record<string, number[]>, next: number[] = []): SeotdaState {
const h = s.hand!;
for (const [id, cs] of Object.entries(cards)) h.cards[id] = [...cs];
const used = new Set([...Object.values(h.cards).flat(), ...next]);
h.deck = [...next, ...SEOTDA_DECK.filter((c) => !used.has(c))];
return s;
}
function act(s: SeotdaState, p: string, a: SeotdaAction, now = (clock += 1000)): SeotdaState {
const v = seotda.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return seotda.apply(s, p, a, { rng: rng(), now }).state;
}
function actE(s: SeotdaState, p: string, a: SeotdaAction): { state: SeotdaState; events: GameEvent[] } {
const v = seotda.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return seotda.apply(s, p, a, { rng: rng(), now: (clock += 1000) });
}
const reject = (s: SeotdaState, p: string, a: SeotdaAction) => {
const v = seotda.validate(s, p, a);
return v.ok ? null : v.reason;
};
function readyAll(s: SeotdaState): SeotdaState {
for (const p of seotda.activePlayers(s)) s = act(s, p, { type: 'ready' });
return s;
}
const stack = (s: SeotdaState, id: string) => s.seats.find((x) => x.id === id)!.stack;
const total = (s: SeotdaState) => s.seats.length * s.options.startingChips + s.seats.reduce((a, x) => a + x.rebuys, 0) * s.options.startingChips;
// ---------------------------------------------------------------- 족보
/** Independent reference straight from the §6.1/§6.2 tables, using the shared HWATU kinds. */
function reference(a: number, b: number, r: HandRules): { value: number; special: SpecialKind | null } {
const ca = HWATU[a]!;
const cb = HWATU[b]!;
const ms = [ca.month, cb.month].sort((x, y) => x - y) as [number, number];
const both = (x: number, y: number) => ms[0] === x && ms[1] === y;
const gw = ca.kind === 'gwang' && cb.kind === 'gwang';
const yeol = (m: number) => [ca, cb].some((c) => c.month === m && c.kind === 'yeol');
const gwangOf = (m: number) => [ca, cb].some((c) => c.month === m && c.kind === 'gwang');
let value: number;
if (gw && both(3, 8)) value = 1000;
else if (gw && both(1, 8) && r.gwangTtaeng === 'all') value = 990;
else if (gw && both(1, 3) && r.gwangTtaeng === 'all') value = 980;
else if (ms[0] === ms[1]) value = 900 + ms[0];
else if (both(1, 2)) value = 806;
else if (both(1, 4)) value = 805;
else if (both(1, 9)) value = 804;
else if (both(1, 10)) value = 803;
else if (both(4, 10)) value = 802;
else if (both(4, 6)) value = 801;
else value = (ms[0] + ms[1]) % 10;
let special: SpecialKind | null = null;
if (both(3, 7) && (r.ttaengjabi === 'loose' || (r.ttaengjabi === 'strict' && gwangOf(3) && yeol(7)))) special = 'ttaengjabi';
else if (both(4, 7) && r.amhaeng && yeol(4) && yeol(7)) special = 'amhaeng';
else if (both(4, 9) && r.gusa) special = r.mungGusa !== 'off' && yeol(4) && yeol(9) ? 'mungGusa' : 'gusa';
return { value, special };
}
describe('족보 (§6.1, §6.2)', () => {
const pairs: [number, number][] = [];
for (let i = 0; i < SEOTDA_DECK.length; i++) for (let j = i + 1; j < SEOTDA_DECK.length; j++) pairs.push([SEOTDA_DECK[i]!, SEOTDA_DECK[j]!]);
test('190 two-card combinations', () => expect(pairs).toHaveLength(190));
const variants: [string, HandRules][] = [
['기본', DEFAULT_RULES],
['38광땡만·땡잡이 느슨', { ...DEFAULT_RULES, gwangTtaeng: 'only38', ttaengjabi: 'loose' }],
['특수패 모두 끔', { gwangTtaeng: 'all', ttaengjabi: 'off', amhaeng: false, gusa: false, mungGusa: 'upTo8' }],
['멍구사 끔', { ...DEFAULT_RULES, mungGusa: 'off' }],
['멍구사 장땡까지', { ...DEFAULT_RULES, mungGusa: 'upToJang' }],
];
test.each(variants)('every combination matches the table (%s)', (_n, rules) => {
for (const [a, b] of pairs) {
const e = evalSeotda(a, b, rules);
expect({ value: e.value, special: e.special }).toEqual(reference(a, b, rules));
expect(evalSeotda(b, a, rules)).toEqual(e);
}
});
test('counts per name with default options', () => {
const count: Record<string, number> = {};
for (const [a, b] of pairs) {
const n = ev(a, b).name;
count[n] = (count[n] ?? 0) + 1;
}
expect([count['38광땡'], count['18광땡'], count['13광땡'], count['장땡'], count['삥땡'], count['5땡']]).toEqual([1, 1, 1, 1, 1, 1]);
expect([count['알리'], count['독사'], count['구삥'], count['장삥'], count['장사'], count['세륙']]).toEqual([4, 4, 4, 4, 4, 4]);
expect([count['땡잡이'], count['암행어사'], count['구사'], count['멍텅구리구사']]).toEqual([1, 1, 3, 1]);
});
test('1월 광 + 3월 광: 13광땡 or 4끗; 1월 띠 + 8월 광 = 갑오; 3월 띠 + 8월 광 = 1끗', () => {
expect(ev(S(1), S(3)).name).toBe('13광땡');
expect(ev(S(1), S(3), { gwangTtaeng: 'only38' })).toMatchObject({ name: '4끗', value: 4 });
expect(ev(S(1), S(8), { gwangTtaeng: 'only38' })).toMatchObject({ name: '갑오', value: 9 });
expect(ev(P(1), S(8))).toMatchObject({ name: '갑오', value: 9 });
expect(ev(P(3), S(8))).toMatchObject({ name: '1끗', value: 1 });
expect(ev(P(2), S(8))).toMatchObject({ name: '망통', value: 0 });
expect(ev(S(10), P(10))).toMatchObject({ name: '장땡', value: 910 });
});
test('special hands keep their normal value', () => {
expect(ev(S(3), S(7))).toMatchObject({ special: 'ttaengjabi', value: 0, baseName: '망통' });
expect(ev(S(4), S(7))).toMatchObject({ special: 'amhaeng', value: 1 });
expect(ev(P(4), S(9))).toMatchObject({ special: 'gusa', value: 3 });
expect(ev(S(4), S(9))).toMatchObject({ special: 'mungGusa', value: 3 });
expect(ev(S(4), S(9), { mungGusa: 'off' }).special).toBe('gusa');
expect(ev(S(4), S(9), { gusa: false })).toMatchObject({ special: null, name: '3끗' });
});
});
describe('쇼다운 판정 (§6.3, §6.4)', () => {
const run = (hands: [string, HandEval][], tie: 'rematch' | 'split' = 'rematch', r: Partial<HandRules> = {}) =>
resolve(hands.map(([id, hand]) => ({ id, hand })), tie, { ...DEFAULT_RULES, ...r });
const gusa = ev(P(4), S(9));
const mung = ev(S(4), S(9));
const tj = ev(S(3), S(7));
const amh = ev(S(4), S(7));
test('1. 38광땡 vs 암행어사 → 38광땡', () => expect(run([['A', ev(S(3), S(8))], ['B', amh]])).toEqual({ type: 'win', winners: ['A'], reason: 'normal' }));
test('2. 13광땡, 암행어사, 장땡 → 암행어사', () =>
expect(run([['A', ev(S(1), S(3))], ['B', amh], ['C', ev(S(10), P(10))]])).toEqual({ type: 'win', winners: ['B'], reason: 'amhaeng' }));
test('3. 9땡, 땡잡이, 알리 → 땡잡이', () =>
expect(run([['A', ev(S(9), P(9))], ['B', tj], ['C', ev(S(1), P(2))]])).toEqual({ type: 'win', winners: ['B'], reason: 'ttaengjabi' }));
test('4. 장땡 vs 땡잡이 → 장땡', () => expect(run([['A', ev(S(10), P(10))], ['B', tj]])).toMatchObject({ winners: ['A'] }));
test('5. 땡잡이 vs 알리 → 알리', () => expect(run([['A', tj], ['B', ev(S(1), P(2))]])).toMatchObject({ winners: ['B'] }));
test('6. 구사 vs 알리 → 구사 재경기', () =>
expect(run([['A', gusa], ['B', ev(S(1), P(2))]])).toEqual({ type: 'rematch', reason: 'gusa', participants: ['A', 'B'] }));
test('7. 구사 vs 삥땡 → 삥땡', () => expect(run([['A', gusa], ['B', ev(S(1), P(1))]])).toMatchObject({ type: 'win', winners: ['B'] }));
test('8. 구사, 2끗, 망통 → 구사 승(재경기 없음)', () =>
expect(run([['A', gusa], ['B', ev(P(5), P(7))], ['C', ev(P(2), P(8))]])).toMatchObject({ type: 'win', winners: ['A'] }));
test('9. 멍텅구리구사 vs 8땡 → 재경기(upTo8); 9땡 → 승; upToJang이면 장땡까지 재경기; 광땡은 이김', () => {
expect(run([['A', mung], ['B', ev(S(8), P(8))]])).toMatchObject({ type: 'rematch', reason: 'mungGusa' });
expect(run([['A', mung], ['B', ev(S(9), P(9))]])).toMatchObject({ type: 'win', winners: ['B'] });
expect(run([['A', mung], ['B', ev(S(9), P(9))]], 'rematch', { mungGusa: 'upTo9' })).toMatchObject({ type: 'rematch' });
expect(run([['A', mung], ['B', ev(S(10), P(10))]], 'rematch', { mungGusa: 'upToJang' })).toMatchObject({ type: 'rematch' });
expect(run([['A', mung], ['B', ev(S(1), S(3))]], 'rematch', { mungGusa: 'upToJang' })).toMatchObject({ type: 'win', winners: ['B'] });
});
test('10. 5끗 동점 → 재경기 또는 나눔', () => {
const a = ev(P(2), P(3));
const b = ev(P(6), P(9));
expect(run([['A', a], ['B', b]])).toEqual({ type: 'rematch', reason: 'tie', participants: ['A', 'B'] });
expect(run([['A', a], ['B', b]], 'split')).toEqual({ type: 'split', winners: ['A', 'B'] });
});
test('11. 구사, 5땡, 땡잡이 → 땡잡이; 멍구사였다면 재경기', () => {
expect(run([['A', gusa], ['B', ev(S(5), P(5))], ['C', tj]])).toEqual({ type: 'win', winners: ['C'], reason: 'ttaengjabi' });
expect(run([['A', mung], ['B', ev(S(5), P(5))], ['C', ev(P(3), P(7))]], 'rematch', { ttaengjabi: 'loose' })).toMatchObject({ type: 'rematch', reason: 'mungGusa' });
});
test('12. 3장 섯다: 1광·8광·4열끗 → 18광땡 조합', () => {
expect(bestChoice([S(1), S(8), S(4)], DEFAULT_RULES)).toEqual([S(1), S(8)]);
});
test('strict 땡잡이: 3월 띠 + 7월 열끗은 망통이라 9땡을 못 잡고, loose면 잡음', () => {
const weak = ev(P(3), S(7));
expect(weak).toMatchObject({ special: null, name: '망통' });
expect(run([['A', weak], ['B', ev(S(9), P(9))]])).toMatchObject({ winners: ['B'] });
const loose = ev(P(3), S(7), { ttaengjabi: 'loose' });
expect(run([['A', loose], ['B', ev(S(9), P(9))]], 'rematch', { ttaengjabi: 'loose' })).toMatchObject({ winners: ['A'], reason: 'ttaengjabi' });
});
test('loose 땡잡이 두 명 + 5땡 → 땡잡이끼리 동점 처리', () => {
const r: Partial<HandRules> = { ttaengjabi: 'loose' };
const hands: [string, HandEval][] = [
['A', ev(S(3), P(7), r)],
['B', ev(S(5), P(5), r)],
['C', ev(P(3), S(7), r)],
];
expect(run(hands, 'rematch', r)).toEqual({ type: 'rematch', reason: 'tie', participants: ['A', 'C'] });
expect(run(hands, 'split', r)).toEqual({ type: 'split', winners: ['A', 'C'] });
});
});
// ---------------------------------------------------------------- betting & flow
describe('betting flow', () => {
test('§5.4 example: 학교 30 → 삥 → 하프 35 → 따당 70 → 콜 → 콜 = 240', () => {
let s = make();
const v0 = seotda.view(s, 'A');
expect(v0.pot).toBe(30);
expect(v0.legal.map((l) => l.type)).toEqual(['check', 'ping', 'quarter', 'half', 'die']);
s = act(s, 'A', { type: 'ping' });
expect(seotda.view(s, 'B').legal.find((l) => l.type === 'half')!.amount).toBe(35);
s = act(s, 'B', { type: 'half' });
expect(seotda.view(s, 'C').legal.find((l) => l.type === 'ddadang')!.amount).toBe(70);
s = act(s, 'C', { type: 'ddadang' });
expect(seotda.view(s, 'A').legal.map((l) => l.type)).toEqual(['call', 'die']);
s = act(s, 'A', { type: 'call' });
expect(seotda.view(s, null).pot).toBe(205);
s = act(s, 'B', { type: 'call' });
expect(seotda.view(s, null).pot).toBe(240);
// twoRounds: second card dealt, second betting round starts with the dealer.
expect(s.hand!.street).toBe(1);
expect(s.hand!.cards.A).toHaveLength(2);
expect(seotda.view(s, null).toAct).toBe('A');
expect(chipsTotal(s)).toBe(3000);
});
test('check or call, then no raise in the same round; half-only room rejects 풀', () => {
let s = make();
expect(reject(s, 'A', { type: 'full' })).toBe('이 방은 하프까지만 올릴 수 있어요.');
expect(reject(s, 'B', { type: 'check' })).toBe('지금은 내 차례가 아니에요.');
s = act(s, 'A', { type: 'check' });
s = act(s, 'B', { type: 'ping' });
expect(reject(s, 'C', { type: 'check' })).toBe('누가 이미 걸어서 체크할 수 없어요.');
expect(reject(s, 'C', { type: 'ping' })).toBe('누가 이미 걸어서 삥을 할 수 없어요.');
s = act(s, 'C', { type: 'call' });
expect(reject(s, 'A', { type: 'half' })).toBe('이번 라운드에는 더 올릴 수 없어요(콜 또는 다이).');
expect(reject(s, 'A', { type: 'ddadang' })).toBe('이번 라운드에는 더 올릴 수 없어요(콜 또는 다이).');
s = act(s, 'A', { type: 'call' });
expect(s.hand!.street).toBe(1);
const full = make({ maxRaise: 'full' });
expect(seotda.view(full, 'A').legal.map((l) => l.type)).toContain('full');
});
test('everyone checks → next street; second round starts after a dead dealer', () => {
let s = make();
s = act(s, 'A', { type: 'die' });
s = act(s, 'B', { type: 'check' });
s = act(s, 'C', { type: 'check' });
expect(s.hand!.street).toBe(1);
expect(seotda.view(s, null).toAct).toBe('B');
expect(seotda.view(s, null).pot).toBe(30);
});
test('dealMode=oneRound: one betting round then showdown', () => {
let s = make({ dealMode: 'oneRound' });
expect(s.hand!.cards.A).toHaveLength(2);
s = rig(s, { A: [S(10), P(10)], B: [S(1), P(2)], C: [P(3), P(5)] });
s = act(s, 'A', { type: 'check' });
s = act(s, 'B', { type: 'check' });
s = act(s, 'C', { type: 'check' });
expect(s.hand!.phase).toBe('handEnd');
expect(s.hand!.showdown!.pots).toEqual([{ amount: 30, eligible: ['A', 'B', 'C'], type: 'win', winners: ['A'], reason: 'normal' }]);
expect(stack(s, 'A')).toBe(1020);
expect(seotda.view(s, 'B').seats.find((x) => x.id === 'A')!.handName).toBe('장땡');
});
test('fold to one: winner takes the pot (uncalled bet back), no cards revealed', () => {
let s = make({ dealMode: 'oneRound' });
s = act(s, 'A', { type: 'half' }); // 15
s = act(s, 'B', { type: 'die' });
s = act(s, 'C', { type: 'die' });
expect(s.hand!.foldWin).toEqual({ winner: 'A', amount: 30 }); // A's uncalled 15 came back first
expect(stack(s, 'A')).toBe(1020);
expect(s.hand!.showdown).toBeNull();
const bView = seotda.view(s, 'B');
expect(bView.seats.find((x) => x.id === 'A')!.cards).toEqual([{ hidden: true }, { hidden: true }]);
expect(seotda.view(s, null).seats.every((x) => x.cards.every((c) => 'hidden' in c))).toBe(true);
s = readyAll(s);
expect(s.handsPlayed).toBe(1);
expect(s.hand!.dealer).toBe('A'); // dealerRule=winner
});
test('dealerRule=rotate moves the dealer clockwise', () => {
let s = make({ dealMode: 'oneRound', dealerRule: 'rotate' });
s = act(s, 'A', { type: 'die' });
s = act(s, 'B', { type: 'die' });
s = readyAll(s);
expect(s.hand!.dealer).toBe('B');
});
test('all-in run-out with a side pot at showdown', () => {
let s = make({ dealMode: 'oneRound' });
// A has only 20 chips left after the 학교.
s.seats[0]!.stack = 20;
s.hand!.betting!.players[0]!.stack = 20;
s = rig(s, { A: [S(10), P(10)], B: [S(1), P(2)], C: [P(3), P(5)] });
const before = chipsTotal(s);
s = act(s, 'A', { type: 'ping' }); // 10
s = act(s, 'B', { type: 'half' }); // 10 + 25 = 35
s = act(s, 'C', { type: 'call' }); // 35
s = act(s, 'A', { type: 'call' }); // only 10 left → all-in
expect(s.hand!.phase).toBe('handEnd');
const pots = s.hand!.showdown!.pots;
expect(pots).toEqual([
{ amount: 30, eligible: ['B', 'C'], type: 'win', winners: ['B'], reason: 'normal' },
{ amount: 90, eligible: ['A', 'B', 'C'], type: 'win', winners: ['A'], reason: 'normal' },
]);
expect(stack(s, 'A')).toBe(90);
expect(chipsTotal(s)).toBe(before);
});
});
describe('재경기', () => {
test('구사 재경기: 모인 칩 유지, 학교 재징수 없음, 쇼다운 생존자만', () => {
let s = make({ dealMode: 'oneRound' });
s = rig(s, { A: [P(4), S(9)], B: [S(1), P(2)], C: [P(5), P(6)] });
s = act(s, 'A', { type: 'check' });
s = act(s, 'B', { type: 'check' });
s = act(s, 'C', { type: 'die' });
expect(s.hand!.showdown!.pots[0]).toMatchObject({ type: 'rematch', reason: 'gusa', winners: ['A', 'B'] });
expect(s.hand!.next).toBe('rematch');
s = readyAll(s);
const h = s.hand!;
expect([h.handNo, h.rematchNo, s.handsPlayed]).toEqual([1, 1, 0]);
expect(h.order).toEqual(['A', 'B']);
expect(h.dealer).toBe('A');
expect([stack(s, 'A'), stack(s, 'B'), stack(s, 'C')]).toEqual([990, 990, 990]);
const v = seotda.view(s, 'A');
expect([v.pot, v.rematchNo, v.rematchReason]).toEqual([30, 1, 'gusa']);
// 하프 in a rematch sees the carried pot: 10 + floor(30/2) → 15.
expect(v.legal.find((l) => l.type === 'half')!.amount).toBe(15);
expect(reject(s, 'C', { type: 'check' })).toBe('이번 판에 참가하지 않았어요.');
// Rematch resolves the carried pot; winner takes it all.
s = rig(s, { A: [S(10), P(10)], B: [P(2), P(3)] });
s = act(s, 'A', { type: 'ping' });
s = act(s, 'B', { type: 'call' });
expect(s.hand!.showdown!.pots).toEqual([{ amount: 50, eligible: ['A', 'B'], type: 'win', winners: ['A'], reason: 'normal' }]);
expect(stack(s, 'A')).toBe(1030);
s = readyAll(s);
expect(s.handsPlayed).toBe(1);
expect(s.stats.rematches).toBe(1);
});
function gusaHand(opts: Partial<SeotdaOptions>) {
let s = make({ dealMode: 'oneRound', ...opts });
s = rig(s, { A: [P(4), S(9)], B: [S(1), P(2)], C: [P(5), P(6)] });
s = act(s, 'A', { type: 'check' });
s = act(s, 'B', { type: 'check' });
s = act(s, 'C', { type: 'die' });
return readyAll(s);
}
test('rejoinCost=half: the folded player pays half (rounded down) and joins', () => {
let s = gusaHand({ rejoinCost: 'half', baseUnit: 2, startingChips: 500 });
expect(s.hand!.phase).toBe('rejoin');
expect(seotda.activePlayers(s)).toEqual(['C']);
expect(seotda.view(s, 'C').rejoin).toEqual({ pending: ['C'], cost: 3, mine: true, canAfford: true });
const before = chipsTotal(s);
s = act(s, 'C', { type: 'rejoin', accept: true });
expect(s.hand!.rematchNo).toBe(1);
expect(s.hand!.order).toEqual(['A', 'B', 'C']);
expect(stack(s, 'C')).toBe(498 - 3);
expect(seotda.view(s, null).pot).toBe(9);
expect(chipsTotal(s)).toBe(before);
});
test('rejoin: not enough chips → refused; timeout → not joining', () => {
let s = gusaHand({ rejoinCost: 'full' });
expect(s.hand!.rejoinCost).toBe(30);
s.seats[2]!.stack = 20;
expect(reject(s, 'C', { type: 'rejoin', accept: true })).toBe('칩이 모자라 재참가할 수 없어요.');
expect(seotda.legalActions!(s, 'C')).toEqual([{ type: 'rejoin', accept: false }]);
s = act(s, 'C', { type: 'timeout' });
expect(s.hand!.order).toEqual(['A', 'B']);
expect(stack(s, 'C')).toBe(20);
});
test('tie rematch 3 times in a row, then the 4th tie splits (odd chip to the dealer side)', () => {
let s = make({ dealMode: 'oneRound' });
const fives = { A: [P(2), P(3)], B: [P(6), S(9)] };
s = rig(s, { ...fives, C: [P(5), P(6)] });
s = act(s, 'A', { type: 'half' }); // 15 → pot 45
s = act(s, 'B', { type: 'call' });
s = act(s, 'C', { type: 'call' }); // pot 75, C loses with 1끗
expect(s.hand!.showdown!.pots[0]).toMatchObject({ amount: 75, type: 'rematch', reason: 'tie', winners: ['A', 'B'] });
for (let k = 1; k <= 3; k++) {
s = readyAll(s);
expect(s.hand!.rematchNo).toBe(k);
expect(s.hand!.order).toEqual(['A', 'B']);
s = rig(s, fives);
s = act(s, 'A', { type: 'check' });
s = act(s, 'B', { type: 'check' });
}
expect(s.hand!.showdown!.pots).toEqual([{ amount: 75, eligible: ['A', 'B'], type: 'split', winners: ['A', 'B'], reason: 'limit' }]);
expect([stack(s, 'A'), stack(s, 'B'), stack(s, 'C')]).toEqual([975 + 38, 975 + 37, 975]);
expect(chipsTotal(s)).toBe(3000);
});
test('side pot + rematch: side pot paid at once, only the main pot is replayed', () => {
let s = make({ dealMode: 'oneRound' });
s.seats[0]!.stack = 20;
s.hand!.betting!.players[0]!.stack = 20;
s = rig(s, { A: [P(2), P(3)], B: [P(6), S(9)], C: [P(5), P(8)] });
s = act(s, 'A', { type: 'ping' });
s = act(s, 'B', { type: 'half' });
s = act(s, 'C', { type: 'call' });
s = act(s, 'A', { type: 'call' }); // all-in for the main pot only
expect(s.hand!.showdown!.pots).toEqual([
{ amount: 30, eligible: ['B', 'C'], type: 'win', winners: ['B'], reason: 'normal' },
{ amount: 90, eligible: ['A', 'B', 'C'], type: 'rematch', winners: ['A', 'B'], reason: 'tie' },
]);
expect(stack(s, 'B')).toBe(1000 - 45 + 30); // 학교 10 + 하프 35, side pot back
expect(s.hand!.carried).toEqual([{ amount: 90, eligible: ['A', 'B'] }]);
const sum = chipsTotal(s);
// A is all-in: the rematch has no betting decisions and goes straight to showdown.
s = readyAll(s);
expect(s.hand!.rematchNo).toBe(1);
expect(s.hand!.phase).toBe('handEnd');
const pot = s.hand!.showdown!.pots[0]!;
expect([pot.amount, pot.eligible]).toEqual([90, ['A', 'B']]);
expect(chipsTotal(s)).toBe(sum);
});
});
describe('3장 섯다', () => {
function three() {
let s = make({ cardMode: 'three' });
s = rig(s, { A: [S(1), S(8)], B: [P(5), P(6)], C: [P(2), P(7)] }, [S(4), P(9), P(10)]);
s = act(s, 'A', { type: 'check' });
s = act(s, 'B', { type: 'check' });
s = act(s, 'C', { type: 'check' });
return s;
}
test('simultaneous secret choice before the last betting round', () => {
let s = three();
expect(s.hand!.phase).toBe('choose');
expect(s.hand!.cards.A).toEqual([S(1), S(8), S(4)]);
expect(seotda.activePlayers(s).sort()).toEqual(['A', 'B', 'C']);
const mine = seotda.view(s, 'A');
expect(mine.myCombos!.find((c) => c.best)!.name).toBe('18광땡');
expect(mine.myHand!.name).toBe('최고 조합 18광땡');
expect(reject(s, 'A', { type: 'choose', cardIds: [S(1), S(1)] })).toBe('카드 두 장을 골라 주세요.');
expect(reject(s, 'A', { type: 'choose', cardIds: [S(1), P(9)] })).toBe('카드 두 장을 골라 주세요.');
s = act(s, 'A', { type: 'choose', cardIds: [S(1), S(4)] }); // 독사 on purpose
expect(reject(s, 'A', { type: 'choose', cardIds: [S(1), S(8)] })).toBe('이미 골랐어요.');
const other = seotda.view(s, 'B');
expect(other.seats.find((x) => x.id === 'A')!.chosen).toBe(true);
expect(other.seats.find((x) => x.id === 'A')!.cards).toEqual([{ hidden: true }, { hidden: true }]);
expect(JSON.stringify(other)).not.toContain('"id":0,');
expect(other.myChosen).toBeNull();
expect(seotda.view(s, 'A').myDiscard!.id).toBe(S(8));
});
test('timeout picks the best combination; the discard never appears in any view or event', () => {
let s = three();
let events: GameEvent[] = [];
for (const p of ['A', 'B', 'C']) {
const r = actE(s, p, { type: 'timeout' });
s = r.state;
events = events.concat(r.events);
}
expect(s.hand!.chosen.A).toEqual([S(1), S(8)]);
expect(s.hand!.phase).toBe('betting');
expect(s.hand!.street).toBe(1);
for (const p of ['A', 'B', 'C']) {
const r = actE(s, p, { type: 'check' });
s = r.state;
events = events.concat(r.events);
}
expect(s.hand!.showdown!.hands.find((x) => x.id === 'A')!.name).toBe('18광땡');
for (const p of ['A', 'B', 'C']) {
const discard = s.hand!.cards[p]!.find((c) => !s.hand!.chosen[p]!.includes(c))!;
const key = `"key":"h1r0c${discard}"`;
for (const viewer of ['A', 'B', 'C', null]) {
if (viewer === p) continue;
expect(JSON.stringify(seotda.view(s, viewer))).not.toContain(key);
}
const shown = events.filter((e) => e.type === 'showdown').flatMap((e) => (e.hands as { cards: number[] }[]).flatMap((x) => x.cards));
expect(shown).not.toContain(discard);
}
expect(events.filter((e) => e.type !== 'showdown').every((e) => !('cards' in e) && !('card' in e))).toBe(true);
});
});
describe('timeouts, away, leaving', () => {
test('timeout: check when nobody bet, otherwise die; two in a row → away with a 5 s limit', () => {
let s = make();
const t0 = s.deadlineAt!;
expect(t0).toBe(20_000);
s = act(s, 'A', { type: 'timeout' }, 21_000);
expect(s.hand!.lastAction.A).toEqual({ type: 'check', amount: 0 });
s = act(s, 'B', { type: 'ping' }, 22_000);
s = act(s, 'C', { type: 'timeout' }, 43_000);
expect(s.hand!.lastAction.C!.type).toBe('die');
s = act(s, 'A', { type: 'timeout' }, 64_000);
expect(s.hand!.lastAction.A!.type).toBe('die');
expect(s.seats[0]!.away).toBe(true);
// Next hand (result screen times out): A is away → 5 s limit on A's turn; a real action clears it.
for (const p of seotda.activePlayers(s)) s = act(s, p, { type: 'timeout' }, 70_000);
const h = s.hand!;
const first = seotda.activePlayers(s)[0]!;
expect(first).toBe(h.order[0]!);
// Walk to A's turn.
while (seotda.activePlayers(s)[0] !== 'A') s = act(s, seotda.activePlayers(s)[0]!, { type: 'check' }, 100_000);
expect(s.deadlineAt).toBe(100_000 + 5000);
s = act(s, 'A', { type: 'check' }, 101_000);
expect(s.seats[0]!.away).toBe(false);
});
test('disconnected dealer: timeout acts after the deadline, then normal play resumes', () => {
let s = make();
expect(seotda.timeoutPlayers!(s)).toEqual(['A']);
expect(seotda.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
expect(reject(s, 'B', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.');
s = act(s, 'A', { type: 'timeout' });
s = act(s, 'B', { type: 'check' });
s = act(s, 'C', { type: 'check' });
s = act(s, 'A', { type: 'ping' }); // reconnected
expect(s.seats[0]!.timeoutsInRow).toBe(0);
});
test('a player who leaves auto-dies; with one connected player left the game ends after the hand', () => {
let s = make({}, ['A', 'B', 'C']);
s = act(s, 'A', { type: 'ping' });
s = act(s, 'B', seotda.onLeave!(s, 'B')!);
expect(s.hand!.betting!.players.find((p) => p.id === 'B')!.status).toBe('folded');
expect(seotda.activePlayers(s)).toEqual(['C']);
s = act(s, 'C', { type: 'leave' });
expect(s.hand!.foldWin!.winner).toBe('A');
s = readyAll(s);
expect(s.phase).toBe('finished');
const r = seotda.result(s)!;
expect(r.ranking[0]).toEqual(['A']);
expect(seotda.onLeave!(s, 'A')).toBeNull();
});
});
describe('rebuy, elimination, scores', () => {
/** Ends the current hand by folds, then sets stacks before the hand-end processing. */
function toHandEnd(opts: Partial<SeotdaOptions>, players = ['A', 'B', 'C']): SeotdaState {
let s = make({ dealMode: 'oneRound', ...opts }, players);
while (s.hand!.phase !== 'handEnd') s = act(s, seotda.activePlayers(s)[0]!, { type: 'die' });
return s;
}
test('rebuy: busted player may take chips again; hands score = chips - start × rebuys', () => {
let s = toHandEnd({ rebuy: 'limited', rebuyLimit: 1, handLimit: 10 });
s.seats[0]!.stack += s.seats[1]!.stack;
s.seats[1]!.stack = 0;
s = readyAll(s);
expect(s.phase).toBe('rebuy');
expect(seotda.activePlayers(s)).toEqual(['B']);
expect(seotda.view(s, 'B').rebuy).toEqual({ pending: ['B'], mine: true, left: 1 });
s = act(s, 'B', { type: 'rebuy', accept: true });
expect([s.phase, stack(s, 'B'), s.seats[1]!.rebuys]).toEqual(['playing', 990, 1]); // refilled, then paid the next 학교
expect(chipsTotal(s)).toBe(total(s));
// Last hand: no rebuy question, the game ends.
while (s.hand!.phase !== 'handEnd') s = act(s, seotda.activePlayers(s)[0]!, { type: 'die' });
s.handsPlayed = 9;
s = readyAll(s);
expect(s.phase).toBe('finished');
const r = seotda.result(s)!;
for (const x of s.seats) expect(r.scores![x.id]).toBe(x.stack - 1000 * x.rebuys);
expect(r.ranking.flat().sort()).toEqual(['A', 'B', 'C']);
expect(r.summary).toMatch(/^10판 끝\. 1위/);
});
test('limited rebuys used up → eliminated; declining also eliminates', () => {
let s = toHandEnd({ rebuy: 'limited', rebuyLimit: 1 });
s.seats[1]!.rebuys = 1;
s.seats[0]!.stack += s.seats[1]!.stack;
s.seats[1]!.stack = 0;
s = readyAll(s);
expect(s.phase).toBe('playing');
expect(s.seats[1]!.status).toBe('eliminated');
expect(s.hand!.order).not.toContain('B');
let t = toHandEnd({});
t.seats[0]!.stack += t.seats[1]!.stack;
t.seats[1]!.stack = 0;
t = readyAll(t);
t = act(t, 'B', { type: 'timeout' });
expect(t.seats[1]!.status).toBe('eliminated');
});
test('lastStanding: same-hand eliminations ranked by chips at hand start', () => {
let s = toHandEnd({ endCondition: 'lastStanding', rebuy: 'off' });
s.seats[1]!.stackAtHandStart = 300;
s.seats[2]!.stackAtHandStart = 100;
s.seats[0]!.stack += s.seats[1]!.stack + s.seats[2]!.stack;
s.seats[1]!.stack = 0;
s.seats[2]!.stack = 0;
s = readyAll(s);
expect(s.phase).toBe('finished');
const r = seotda.result(s)!;
expect(r.ranking).toEqual([['A'], ['B'], ['C']]);
expect(r.scores).toEqual({ A: 3, B: 2, C: 1 });
});
test('학교 doubles every N hands (capped at startingChips / 5)', () => {
let s = make({ dealMode: 'oneRound', schoolUpEvery: 5, startingChips: 500, baseUnit: 20 });
for (let i = 0; i < 10; i++) {
while (s.hand!.phase !== 'handEnd') s = act(s, seotda.activePlayers(s)[0]!, { type: 'die' });
s = readyAll(s);
}
expect(s.baseUnit).toBe(80);
expect(seotda.view(s, null).baseUnit).toBe(80);
});
});
describe('options', () => {
test('schema defaults and validation', () => {
expect(seotda.defaultOptions).toMatchObject({ cardMode: 'two', dealMode: 'twoRounds', startingChips: 1000, baseUnit: 10, rebuy: 'unlimited' });
expect(seotda.optionsSchema.safeParse({ startingChips: 500, baseUnit: 50 }).success).toBe(false);
expect(seotda.playersFor!({ ...seotda.defaultOptions, cardMode: 'three' })).toEqual({ min: 2, max: 6 });
expect(seotda.playersFor!(seotda.defaultOptions)).toEqual({ min: 2, max: 10 });
expect(() => seotda.setup({ players: 'ABCDEFG'.split(''), options: { ...seotda.defaultOptions, cardMode: 'three' }, rng: rng(), now: 0 })).toThrow();
});
test('every option has Korean metadata', () => {
const shape = (seotda.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
});
test('client actions only (system actions are not in the schema)', () => {
expect(seotda.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
expect(seotda.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(seotda.actionSchema.safeParse({ type: 'ddadang' }).success).toBe(true);
});
});
// ---------------------------------------------------------------- properties
describe('random playouts', () => {
const conserve = (st: unknown) => {
const s = st as SeotdaState;
if (chipsTotal(s) !== total(s)) throw new Error(`chips not conserved: ${chipsTotal(s)} vs ${total(s)}`);
if (s.seats.some((x) => x.stack < 0)) throw new Error('negative stack');
};
const configs: [string, Partial<SeotdaOptions>, number, number][] = [
['기본', {}, 4, 4],
['2장 한 번에·나눔·풀', { dealMode: 'oneRound', tieRule: 'split', maxRaise: 'full' }, 5, 3],
['3장 섯다·재참가 절반', { cardMode: 'three', rejoinCost: 'half', ttaengjabi: 'loose' }, 6, 3],
['최후 1인·리바이 없음·회전', { endCondition: 'lastStanding', rebuy: 'off', dealerRule: 'rotate', baseUnit: 50, startingChips: 1000 }, 3, 4],
['10명·전액 재참가', { rejoinCost: 'full', mungGusa: 'upToJang', handLimit: 10, startingChips: 500, baseUnit: 2 }, 10, 2],
['2명·학교 상승·제한 리바이', { schoolUpEvery: 5, rebuy: 'limited', rebuyLimit: 2, handLimit: 30 }, 2, 4],
];
test.each(configs)('%s: finishes, chips conserved, no leaks, deterministic', (_n, opts, n, runs) => {
const options = seotda.optionsSchema.parse(opts);
const players = Array.from({ length: n }, (_, i) => `P${i}`);
for (let i = 1; i <= runs; i++) {
const a = randomPlayout(seotda, { players, options, seed: seedN(i), maxSteps: 20_000, invariant: conserve, maxApplyMs: 30 });
expect(a.finished).toBe(true);
expect(seotda.result(a.finalState as SeotdaState)!.ranking.flat().sort()).toEqual([...players].sort());
if (i === 1) {
const b = randomPlayout(seotda, { players, options, seed: seedN(i), maxSteps: 20_000, maxApplyMs: 30 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
}
}
}, 15_000);
test('leak property: 1,000 seeds × first hand, every viewer and every event', () => {
for (let seed = 1; seed <= 1000; seed++) {
const n = 2 + (seed % 5);
const players = Array.from({ length: n }, (_, i) => `P${i}`);
const options = seotda.optionsSchema.parse({ cardMode: seed % 3 === 0 ? 'three' : 'two', dealMode: seed % 4 === 1 ? 'oneRound' : 'twoRounds' });
const r = SeededRng.fromSeed(seedN(seed));
const chooser = SeededRng.fromSeed(seedN(seed + 77));
let s = seotda.setup({ players, options, rng: r, now: 0 });
for (let step = 0; step < 200 && s.handsPlayed === 0 && s.phase !== 'finished'; step++) {
// Same check as checkNoLeak, computing each view once.
const views = new Map([...players, null].map((v) => [v, JSON.stringify(seotda.view(s, v))]));
for (const owner of players)
for (const secret of seotda.secretsOf!(s, owner))
for (const [viewer, json] of views) if (viewer !== owner && json.includes(JSON.stringify(secret))) throw new Error(`leak ${secret} of ${owner} to ${viewer}`);
const actives = seotda.activePlayers(s);
const actor = actives[chooser.int(actives.length)]!;
const legal = seotda.legalActions!(s, actor);
const a = chooser.int(5) === 0 ? seotda.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
const out = seotda.apply(s, actor, a, { rng: r, now: step * 1000 });
const revealed = new Set(out.state.hand?.showdown?.hands.flatMap((x) => x.cards) ?? []);
for (const e of out.events) {
if (e.type === 'showdown') {
for (const c of (e.hands as { cards: number[] }[]).flatMap((x) => x.cards)) if (!revealed.has(c)) throw new Error('showdown event leaks');
} else if ('cards' in e || 'card' in e || 'hand' in e) throw new Error(`event leaks cards: ${e.type}`);
}
s = out.state;
}
// Deck order is never part of any view.
const deck = JSON.stringify(s.hand?.deck ?? []);
if (s.hand && s.hand.deck.length > 3) for (const p of [...players, null]) expect(JSON.stringify(seotda.view(s, p))).not.toContain(deck.slice(1, -1));
}
}, 20_000);
});