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

@@ -137,6 +137,8 @@ describe('M1 integration scenarios', () => {
// Simulate a crash: drop the listener and timers without the graceful bye/cleanup. // Simulate a crash: drop the listener and timers without the graceful bye/cleanup.
for (const r of s1.rooms.all()) r.clearTimer(); for (const r of s1.rooms.all()) r.clearTimer();
await Promise.race([s1.server.stop(true), sleep(300)]); await Promise.race([s1.server.stop(true), sleep(300)]);
// Closing the sockets re-arms grace timers; a crashed process has none, and they must not touch the closed DB later.
for (const r of s1.rooms.all()) r.clearTimer();
s1.db.close(); s1.db.close();
const s2 = start(s1.dbPath); const s2 = start(s1.dbPath);
const a2 = await client(s2, '', { cookie: a.cookie, id: a.id }); const a2 = await client(s2, '', { cookie: a.cookie, id: a.id });

View File

@@ -0,0 +1,990 @@
/** 포커 화면 (docs/games/poker.md §9). Chips are room-only play points. */
import { useEffect, useRef, useState } from 'react';
import type { BetActionType, LegalBetView, PokerMode, PokerOptions, PokerView, SeatView, Side, ViewCard } from '@bg/games/poker';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
import { TurnTimer } from '../../components/TurnTimer';
import { PlayingCard } from '../../components/cards/PlayingCard';
import { Chips, PlayChipNotice, formatChips } from '../../components/cards/Chips';
import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types';
const MODE_KO: Record<PokerMode, string> = {
holdem: '텍사스 홀덤',
omaha: '오마하',
sevenPoker: '7포커',
sevenHiLo: '7포커 하이로우',
badugi: '바둑이',
fiveDraw: '5카드 드로우',
indian: '인디언 포커',
};
const KOREAN_ORDER: BetActionType[] = ['k.die', 'k.check', 'k.ping', 'k.call', 'k.ddadang', 'k.quarter', 'k.half', 'k.full'];
const BET_KO: Record<BetActionType, string> = {
'k.check': '체크',
'k.ping': '삥',
'k.call': '콜',
'k.ddadang': '따당',
'k.quarter': '쿼터',
'k.half': '하프',
'k.full': '풀',
'k.die': '다이',
'nl.fold': '폴드',
'nl.check': '체크',
'nl.call': '콜',
'nl.raise': '레이즈',
'nl.allIn': '올인',
};
const BET_HELP: Partial<Record<BetActionType, string>> = {
'k.check': '칩을 걸지 않고 넘겨요',
'k.ping': '기본 칩만큼 걸어요',
'k.call': '앞사람만큼 맞춰요',
'k.ddadang': '앞사람이 건 만큼의 두 배',
'k.quarter': '모인 칩의 4분의 1만큼 더',
'k.half': '모인 칩의 절반만큼 더',
'k.full': '모인 칩만큼 더',
'k.die': '이번 판을 포기해요',
};
const SIDE_KO: Record<Side, string> = { high: '하이', low: '로우', swing: '스윙' };
const AWARD_KO = { high: '하이', low: '로우', scoop: '스윙 성공', carry: '넘어온 칩', penalty: '벌칙 칩', refund: '돌려받음' } as const;
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const isCommunity = (m: PokerMode) => m === 'holdem' || m === 'omaha';
const isSevenMode = (m: PokerMode) => m === 'sevenPoker' || m === 'sevenHiLo';
const isDrawMode = (m: PokerMode) => m === 'badugi' || m === 'fiveDraw';
/** Rank of a card id for the client-side "A를 남기면 4장" check. */
const isAce = (c: string) => c[0] === 'A';
const CSS = `
.pk-table { position: relative; width: 100%; aspect-ratio: 16 / 11; border-radius: 180px; border: 10px solid var(--wood);
background: radial-gradient(ellipse at center, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0) 60%), var(--felt); box-shadow: var(--shadow); }
.pk-seat { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: var(--seat-w, 172px); z-index: 1;
background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.25);
border: 3px solid transparent; display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; transition: border-color .2s; }
.pk-seat.turn { border-color: var(--focus); }
.pk-seat.me { border-color: var(--primary); }
.pk-seat.me.turn { border-color: var(--focus); }
.pk-seat.win { border-color: #f59e0b; }
.pk-seat.out { opacity: 0.6; }
.pk-center { position: absolute; left: 50%; top: 48%; transform: translate(-50%, -50%); text-align: center; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,.5); max-width: 52%; }
.pk-board { display: flex; gap: 6px; justify-content: center; margin: 6px 0; }
.pk-slot { width: 64px; height: 90px; border-radius: 8px; border: 2px dashed rgba(255,255,255,.35); flex: none; }
.pk-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.pk-actions .btn { min-height: 64px; flex-direction: column; gap: 0; padding: 0 8px; line-height: 1.15; font-size: 1.1rem; }
.pk-actions .btn small { font-weight: 600; font-size: .85rem; opacity: .9; }
.pk-mine { display: flex; gap: 4px; align-items: flex-end; flex-wrap: wrap; justify-content: center; }
.pk-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; }
.pk-x { position: relative; }
.pk-x::after { content: '✕'; position: absolute; inset: 0; display: grid; place-items: center; color: #dc2626; font-size: 3rem; font-weight: 900; pointer-events: none; }
.pk-raise { display: grid; gap: 8px; }
.pk-raise input[type=range] { width: 100%; min-height: 40px; }
@media (max-width: 719px) {
.pk-table { aspect-ratio: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 10px; border-radius: 20px; border-width: 6px; }
.pk-seat { position: static; transform: none; width: auto; }
.pk-center { position: static; transform: none; grid-column: 1 / -1; order: -1; max-width: none; padding: 8px 0; }
.pk-slot { width: 44px; height: 62px; }
.pk-actions { grid-template-columns: repeat(2, 1fr); }
}
`;
// ---------------------------------------------------------------- seats
function seatBadges(seat: SeatView, v: PokerView): [string, string][] {
const b: [string, string][] = [];
if (seat.status === 'left') b.push(['나감', 'var(--danger)']);
else if (seat.away) b.push(['자리 비움', 'var(--warning)']);
if (seat.status === 'eliminated') b.push(['탈락', 'var(--text-sub)']);
if (seat.status === 'busted' || seat.rebuyPending) b.push(['리바이 대기', 'var(--warning)']);
if (seat.folded) b.push([v.betStyle === 'korean' ? '다이' : '폴드', 'var(--danger)']);
if (seat.allIn) b.push(['올인', 'var(--primary)']);
const contesting = seat.inHand && !seat.folded;
if (contesting && v.handPhase === 'choice') b.push(seat.choiceDone ? ['고름', 'var(--success)'] : ['고르는 중', 'var(--text-sub)']);
if (contesting && v.handPhase === 'draw') b.push(seat.drawDone ? ['냄', 'var(--success)'] : ['바꿀 카드 고르는 중', 'var(--text-sub)']);
if (contesting && v.handPhase === 'declare') b.push(seat.declared ? ['선언함', 'var(--success)'] : ['선언 고르는 중', 'var(--text-sub)']);
if (seat.declare) b.push([`선언: ${SIDE_KO[seat.declare]}`, '#7c3aed']);
return b;
}
function SeatCards({ seat, v, me }: { seat: SeatView; v: PokerView; me: string | null }) {
const sd = v.showdown?.find((e) => e.id === seat.id);
const cards: { c: ViewCard; key: string; title?: string }[] = [];
if (seat.forehead !== undefined) cards.push({ c: seat.forehead, key: 'f', title: seat.forehead ? undefined : seat.id === me ? '내 이마 카드(나만 못 봐요)' : '이마 카드' });
// Hidden cards arrive as null; at showdown the view already reveals them.
seat.hole.forEach((c, i) => cards.push({ c, key: `h${i}` }));
for (let i = 0; i < seat.choiceCount; i++) cards.push({ c: null, key: `c${i}` });
seat.up.forEach((c, i) => cards.push({ c, key: `u${i}` }));
if (!cards.length) return null;
const best = new Set(sd?.best ?? []);
return (
<div style={{ display: 'flex', flexWrap: 'nowrap', justifyContent: 'center' }}>
{cards.map((x, i) => (
<PlayingCard
key={x.key}
card={x.c}
size="sm"
highlight={!!x.c && best.has(x.c)}
title={x.title}
style={i > 0 ? { marginLeft: cards.length > 5 ? -24 : -16 } : undefined}
/>
))}
</div>
);
}
function SeatBox({ seat, v, players, me, deadline, x, y }: { seat: SeatView; v: PokerView; players: Record<string, PublicUser>; me: string | null; deadline: number | null; x: number; y: number }) {
const u = userOf(players, seat.id);
const badges = seatBadges(seat, v);
const waiting = v.waiting.includes(seat.id) && v.handPhase !== 'handEnd';
const out = seat.status === 'eliminated' || seat.folded || (!seat.inHand && v.handPhase !== null);
const won = v.handPhase === 'handEnd' && v.winners.includes(seat.id);
const sd = v.showdown?.find((e) => e.id === seat.id);
const marks: [string, string, string][] = [];
if (seat.dealer) marks.push(['D', '#facc15', '딜러']);
if (seat.sb) marks.push(['SB', '#93c5fd', '스몰 블라인드']);
if (seat.bb) marks.push(['BB', '#60a5fa', '빅 블라인드']);
if (seat.boss) marks.push(['보스', '#fb923c', '보스(먼저 베팅)']);
return (
<div
className={`pk-seat${waiting ? ' turn' : ''}${seat.id === me ? ' me' : ''}${won ? ' win' : ''}${out ? ' out' : ''}`}
style={{ '--x': `${x}%`, '--y': `${y}%` } as React.CSSProperties}
aria-label={`${u.nickname} ${formatChips(seat.stack)}${marks.length ? `, ${marks.map((m) => m[2]).join(', ')}` : ''}${badges.length ? `, ${badges.map((b) => b[0]).join(', ')}` : ''}`}
>
<div className="row" style={{ gap: 5, flexWrap: 'nowrap' }}>
<Avatar user={u} size={22} />
<strong style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{u.nickname}
{seat.id === me && <span className="muted"> (나)</span>}
</strong>
{marks.map(([t, c, title]) => (
<span key={t} className="pk-badge" style={{ background: c, color: '#1f2937' }} title={title}>
{t}
</span>
))}
{waiting && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={seat.id === me} size={28} />}
</div>
<div className="row" style={{ gap: 6, justifyContent: 'space-between' }}>
<Chips n={seat.stack} strong />
{seat.roundBet > 0 && <span className="small" title="이번 라운드에 건 칩">+{seat.roundBet.toLocaleString('ko-KR')}</span>}
</div>
{seat.lastAction && !seat.folded && <span className="small muted">{seat.lastAction}</span>}
{seat.drawCounts.length > 0 && <span className="small muted">바꾼 장수: {seat.drawCounts.join(' · ')}</span>}
{badges.length > 0 && (
<div className="row" style={{ gap: 3 }}>
{badges.map(([t, c]) => (
<span key={t} className="pk-badge" style={{ background: c, color: '#fff' }}>
{t}
</span>
))}
</div>
)}
<SeatCards seat={seat} v={v} me={me} />
{sd && (
<strong style={{ color: 'var(--primary)', textAlign: 'center' }}>
{sd.handName}
{sd.lowName !== undefined && <span className="small"> / 로우 {sd.lowName ?? '없음'}</span>}
</strong>
)}
</div>
);
}
function CenterInfo({ v, players }: { v: PokerView; players: Record<string, PublicUser> }) {
const limit = v.options.endCondition === 'hands' ? `/${v.options.handLimit}` : '';
const penalty = v.penalties.reduce((a, p) => a + p.amount, 0);
return (
<div className="pk-center">
{v.streetName && <div className="pk-badge" style={{ background: '#fde047', color: '#422006', fontSize: '0.95rem', padding: '2px 12px' }}>{v.streetName}</div>}
{isCommunity(v.mode) && (
<div className="pk-board" aria-label="공용 카드">
{Array.from({ length: 5 }, (_, i) => (v.board[i] ? <PlayingCard key={v.board[i]} card={v.board[i]!} size="md" /> : <span key={`e${i}`} className="pk-slot" aria-hidden />))}
</div>
)}
<div style={{ fontSize: '0.95rem', opacity: 0.9 }}>모인 칩</div>
<div style={{ fontSize: '2.2rem', fontWeight: 900, fontVariantNumeric: 'tabular-nums' }} aria-live="polite">
{formatChips(v.pot)}
</div>
{v.pots.length > 1 && (
<div className="small" style={{ opacity: 0.9 }}>
{v.pots.map((p, i) => (
<div key={i}>
{i === 0 ? '메인' : `사이드 ${i}`}: {formatChips(p.amount)} ({p.eligible.map((id) => nick(players, id)).join('·')})
</div>
))}
</div>
)}
{(v.carryOver > 0 || penalty > 0) && (
<div className="small" style={{ opacity: 0.9 }}>
{v.carryOver > 0 && `지난 판에서 넘어온 칩 ${formatChips(v.carryOver)}`}
{v.carryOver > 0 && penalty > 0 && ' · '}
{penalty > 0 && `벌칙 칩 ${formatChips(penalty)}`}
</div>
)}
<div className="small" style={{ opacity: 0.85, marginTop: 4 }}>
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}${limit}판`} · 기본 {formatChips(v.baseUnit)}
{v.level > 1 ? ` (레벨 ${v.level})` : ''}
</div>
</div>
);
}
// ---------------------------------------------------------------- board
function PokerBoard({ view: v, me, players, act, pending, deadline }: BoardProps<PokerView>) {
const n = v.seats.length;
const myIdx = Math.max(0, v.seats.findIndex((s) => s.id === me));
const [announce, setAnnounce] = useState('');
const prevWaiting = useRef(false);
const mine = isMyTurn(v, me);
const showKey = `${v.handNo}-${v.handPhase}`;
useEffect(() => {
if (mine && !prevWaiting.current) {
haptic(40);
sounds.myTurn();
setAnnounce('내 차례예요.');
} else if (v.toAct && v.toAct !== me) setAnnounce(`${nick(players, v.toAct)}님 차례예요.`);
prevWaiting.current = mine;
}, [mine, v.toAct]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (v.handPhase !== 'handEnd') return;
setAnnounce(resultLine(v, players));
if (me && v.winners.includes(me)) sounds.win();
}, [showKey]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="board-wrap stack" style={{ gap: 12 }}>
<style>{CSS}</style>
<div className="pk-table" role="region" aria-label="포커 테이블" style={{ '--seat-w': n > 6 ? '150px' : '176px' } as React.CSSProperties}>
{v.seats.map((s, i) => {
const rel = (i - myIdx + n) % n;
const ang = Math.PI / 2 + (rel * 2 * Math.PI) / n;
return <SeatBox key={s.id} seat={s} v={v} players={players} me={me} deadline={deadline} x={50 + 40 * Math.cos(ang)} y={50 + 38 * Math.sin(ang)} />;
})}
<CenterInfo v={v} players={players} />
</div>
<MyArea v={v} me={me} players={players} act={act} pending={pending} />
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
function resultLine(v: PokerView, players: Record<string, PublicUser>): string {
if (!v.awards) return '';
const won = new Map<string, number>();
for (const a of v.awards) if (a.side !== 'refund') won.set(a.to, (won.get(a.to) ?? 0) + a.amount);
if (!won.size) return v.carryOver > 0 ? `동점이라 ${formatChips(v.carryOver)}이 다음 판으로 넘어가요` : '이번 판은 칩을 모두 돌려받았어요';
const parts = [...won].map(([id, amt]) => {
const name = v.showdown?.find((e) => e.id === id)?.handName;
return `${nick(players, id)}님이 ${name ? `${name}(으)로 ` : ''}${formatChips(amt)}을 가져갔어요`;
});
return parts.join(' / ') + (v.foldWin ? ' (모두 포기)' : '');
}
function ResultPanel({ v, players }: { v: PokerView; players: Record<string, PublicUser> }) {
if (!v.awards) return null;
return (
<div className="card stack" style={{ gap: 6, padding: 12 }} aria-live="polite">
<strong>이번 판 결과</strong>
{v.foldWin && <span className="small muted">한 명만 남아서 패는 공개하지 않아요.</span>}
{v.awards.map((a, i) => {
const sd = v.showdown?.find((e) => e.id === a.to);
const hand = !sd ? '' : a.side === 'low' ? (sd.lowName ? `로우 ${sd.lowName}` : '') : !a.side || a.side === 'high' || a.side === 'scoop' ? sd.handName : '';
return (
<span key={i}>
<strong>{nick(players, a.to)}</strong> {formatChips(a.amount)}
{hand && ` · ${hand}`}
<span className="muted">
{v.awards!.some((x) => x.potIndex > 0) ? ` · ${a.potIndex === 0 ? '메인' : `사이드 ${a.potIndex}`}` : ''}
{a.side ? ` · ${AWARD_KO[a.side]}` : ''}
</span>
</span>
);
})}
{v.awards.every((a) => a.side === 'refund') && v.carryOver > 0 && <span>동점이라 {formatChips(v.carryOver)}이 다음 판으로 넘어가요.</span>}
</div>
);
}
// ---------------------------------------------------------------- my area
interface AreaProps {
v: PokerView;
me: string | null;
players: Record<string, PublicUser>;
act: (a: unknown) => void;
pending: boolean;
}
function MyArea({ v, me, players, act, pending }: AreaProps) {
const seat = v.seats.find((s) => s.id === me);
if (!me || !seat) return v.handPhase === 'handEnd' ? <ResultPanel v={v} players={players} /> : null;
const m = v.my;
return (
<div className="stack" style={{ gap: 10 }}>
{seat.inHand && (
<div className="card stack" style={{ gap: 10, padding: 12 }}>
{m.canChoose && m.choice ? (
<ChoicePicker key={v.handNo} cards={m.choice} act={act} pending={pending} />
) : m.canDraw ? (
<DrawPicker key={`${v.handNo}-${seat.drawCounts.length}`} v={v} hole={seat.hole.filter((c): c is string => !!c)} act={act} pending={pending} />
) : (
<MyCards v={v} seat={seat} />
)}
{(m.handName || m.lowName) && (
<div style={{ textAlign: 'center', fontSize: '1.4rem', fontWeight: 900 }}>
지금: {m.handName}
{m.lowName && <span className="small" style={{ marginLeft: 8 }}>로우 {m.lowName}</span>}
</div>
)}
{seat.folded && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>이번 판은 포기했어요. 내 카드는 나만 볼 수 있어요.</p>}
{v.handPhase === 'choice' && seat.choiceDone && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>골랐어요. 다른 사람을 기다리는 중이에요.</p>}
{v.handPhase === 'draw' && seat.drawDone && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>바꿀 카드를 냈어요. 다른 사람을 기다리는 중이에요.</p>}
{m.canDeclare && <DeclarePicker v={v} act={act} pending={pending} />}
{v.handPhase === 'declare' && seat.declared && !m.canDeclare && (
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
{m.declare ? `${SIDE_KO[m.declare]}(으)로 선언했어요. ` : ''}다른 사람을 기다리는 중이에요.
</p>
)}
</div>
)}
{seat.inHand && v.handPhase === 'betting' && !seat.folded && !seat.allIn && <BetBar v={v} me={me} players={players} act={act} pending={pending} />}
{v.handPhase === 'handEnd' && <ResultPanel v={v} players={players} />}
{m.canReady && (
<button type="button" className="btn btn-primary btn-block" disabled={pending} onClick={() => act({ type: 'ready' })}>
다음 판으로
</button>
)}
{v.handPhase === 'handEnd' && seat.inHand && seat.ready && seat.status !== 'left' && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>다른 사람을 기다리는 중…</p>}
</div>
);
}
function MyCards({ v, seat }: { v: PokerView; seat: SeatView }) {
const sd = v.showdown?.find((e) => e.id === seat.id);
const best = new Set(sd?.best ?? []);
return (
<div className="pk-mine" aria-label="내 카드">
{seat.forehead !== undefined && (
<div className="stack" style={{ gap: 2, alignItems: 'center' }}>
<PlayingCard card={seat.forehead} size="lg" title={seat.forehead ? undefined : '내 이마 카드(나만 못 봐요)'} />
<span className="small muted">이마</span>
</div>
)}
{seat.hole.map((c, i) => (
<div key={`h${i}`} className="stack" style={{ gap: 2, alignItems: 'center' }}>
<PlayingCard card={c} size="lg" highlight={!!c && best.has(c)} />
{(isSevenMode(v.mode) || v.mode === 'indian') && <span className="small muted">{v.mode === 'indian' ? '손' : '나만 봄'}</span>}
</div>
))}
{seat.up.map((c, i) => (
<div key={`u${i}`} className="stack" style={{ gap: 2, alignItems: 'center' }}>
<PlayingCard card={c} size="lg" highlight={!!c && best.has(c)} />
<span className="small muted">공개</span>
</div>
))}
</div>
);
}
/** 7포커 초이스: 1) 버릴 카드 2) 보여 줄 카드 → 확인 (§9). */
function ChoicePicker({ cards, act, pending }: { cards: string[]; act: (a: unknown) => void; pending: boolean }) {
const [discard, setDiscard] = useState<string | null>(null);
const [open, setOpen] = useState<string | null>(null);
const tap = (c: string) => {
if (!discard) setDiscard(c);
else if (c === discard) {
setDiscard(null);
setOpen(null);
} else setOpen(open === c ? null : c);
};
return (
<div className="stack" style={{ gap: 8 }}>
<p style={{ margin: 0, textAlign: 'center' }}>
<strong>{!discard ? '1단계: 버릴 카드를 누르세요' : !open ? '2단계: 모두에게 보여 줄 카드를 누르세요' : '확인을 누르면 정해져요'}</strong>
<br />
<span className="small muted">버린 카드는 아무에게도 보이지 않아요. 남는 2장은 나만 봐요.</span>
</p>
<div className="pk-mine">
{cards.map((c) => (
<span key={c} className={c === discard ? 'pk-x' : undefined}>
<PlayingCard card={c} size="lg" selected={c === open} dimmed={c === discard} onClick={() => tap(c)} title={c === discard ? '버릴 카드(다시 누르면 취소)' : c === open ? '보여 줄 카드' : undefined} />
</span>
))}
</div>
<div className="row" style={{ justifyContent: 'center' }}>
<button
type="button"
className="btn btn-secondary"
disabled={!discard}
onClick={() => {
setDiscard(null);
setOpen(null);
}}
>
다시 고르기
</button>
<button type="button" className="btn btn-primary" disabled={!discard || !open || pending} onClick={() => act({ type: 'choose', discardId: discard, openId: open })}>
확인
</button>
</div>
</div>
);
}
/** 교환(바둑이 / 5카드 드로우): tap to lift the cards to change. */
function DrawPicker({ v, hole, act, pending }: { v: PokerView; hole: string[]; act: (a: unknown) => void; pending: boolean }) {
const [sel, setSel] = useState<string[]>([]);
const { drawMax, drawAceBonus } = v.my;
const keepsAce = hole.some((c) => isAce(c) && !sel.includes(c));
const limit = drawAceBonus && keepsAce ? drawMax + 1 : drawMax;
const tooMany = sel.length > limit;
const round = v.seats.find((s) => s.id === v.me)?.drawCounts.length ?? 0;
const label = v.mode === 'badugi' ? (['아침', '점심', '저녁'][round] ?? '교환') : '교환';
return (
<div className="stack" style={{ gap: 8 }}>
<p style={{ margin: 0, textAlign: 'center' }}>
<strong>{label}: 바꿀 카드를 누르세요</strong>
<br />
<span className="small muted">
{drawAceBonus ? '3장까지 바꿀 수 있어요(A를 남기면 4장).' : `${drawMax}장까지 바꿀 수 있어요.`} 모두 동시에 내고, 버린 카드는 공개되지 않아요.
</span>
</p>
<div className="pk-mine">
{hole.map((c) => (
<PlayingCard key={c} card={c} size="lg" selected={sel.includes(c)} onClick={() => setSel((s) => (s.includes(c) ? s.filter((x) => x !== c) : [...s, c]))} />
))}
</div>
{tooMany && <p className="field-error" style={{ margin: 0, textAlign: 'center' }}>{limit}장까지만 바꿀 수 있어요.</p>}
<button type="button" className="btn btn-primary btn-block" disabled={pending || tooMany} onClick={() => act({ type: 'draw', cardIds: sel })}>
{sel.length === 0 ? '패스(안 바꾸기)' : `${sel.length}장 바꾸기`}
</button>
</div>
);
}
function DeclarePicker({ v, act, pending }: { v: PokerView; act: (a: unknown) => void; pending: boolean }) {
const sub: Record<Side, string> = {
high: v.my.handName ?? '',
low: v.my.lowName ?? '로우 없음',
swing: '하이·로우 둘 다 이겨야 해요',
};
return (
<div className="stack" style={{ gap: 8 }}>
<p style={{ margin: 0, textAlign: 'center' }}>
<strong>하이·로우·스윙 중 하나를 선언하세요.</strong> <span className="small muted">모두 고른 뒤 한꺼번에 공개돼요.</span>
</p>
<div className="pk-actions" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
{(['high', 'low', 'swing'] as Side[]).map((side) => (
<button key={side} type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'declare', side })}>
<span>{SIDE_KO[side]}</span>
<small>{sub[side]}</small>
</button>
))}
</div>
</div>
);
}
function BetBar({ v, me, players, act, pending }: AreaProps & { me: string }) {
const myTurn = v.toAct === me;
const legal = new Map<BetActionType, LegalBetView>(v.my.legal.map((l) => [l.type, l]));
const [hover, setHover] = useState<BetActionType | null>(null);
const [confirmFold, setConfirmFold] = useState<BetActionType | null>(null);
const [raising, setRaising] = useState(false);
const seat = v.seats.find((s) => s.id === me)!;
useEffect(() => setRaising(false), [v.handNo, v.street, myTurn]);
const press = (t: BetActionType) => {
if ((t === 'k.die' || t === 'nl.fold') && v.my.toCall === 0) return setConfirmFold(t);
if (t === 'nl.raise') return setRaising(true);
act({ type: t });
};
const korean = v.betStyle === 'korean';
const types: BetActionType[] = korean
? KOREAN_ORDER.filter((t) => t !== 'k.full' || v.options.koreanMaxRaise === 'full')
: ['nl.fold', legal.has('nl.call') ? 'nl.call' : 'nl.check', 'nl.raise', 'nl.allIn'];
const hoverBet = hover ? legal.get(hover) : undefined;
const raise = legal.get('nl.raise');
return (
<div className="stack" style={{ gap: 6 }}>
{raising && raise ? (
<RaisePanel v={v} seat={seat} raise={raise} act={act} pending={pending} onCancel={() => setRaising(false)} />
) : (
<div className="pk-actions" role="group" aria-label="베팅">
{types.map((t) => {
const l = legal.get(t);
const enabled = myTurn && !!l && !pending;
const cls = t === 'k.die' || t === 'nl.fold' ? 'btn-danger' : t.endsWith('check') || t.endsWith('call') ? 'btn-primary' : 'btn-soft';
const amount = t === 'nl.raise' && l ? `${formatChips(l.minTo!)}부터` : l && l.amount > 0 ? `+${formatChips(l.amount)}${l.allIn ? ' 올인' : ''}` : '';
return (
<button
key={t}
type="button"
className={`btn ${cls}`}
disabled={!enabled}
title={BET_HELP[t]}
onMouseEnter={() => setHover(t)}
onMouseLeave={() => setHover(null)}
onFocus={() => setHover(t)}
onBlur={() => setHover(null)}
onClick={() => press(t)}
>
<span>{BET_KO[t]}</span>
<small>{amount || (t === 'k.die' || t === 'nl.fold' ? '포기' : l ? '0 칩' : '-')}</small>
</button>
);
})}
</div>
)}
<p className="small muted" style={{ margin: 0, minHeight: '1.4em', textAlign: 'center' }}>
{myTurn
? hoverBet && BET_HELP[hover!]
? `${BET_KO[hover!]}: ${BET_HELP[hover!]}. 내고 나면 내 칩 ${formatChips(seat.stack - hoverBet.amount)}, 모인 칩 ${formatChips(v.pot + hoverBet.amount)}`
: v.my.toCall > 0
? `내 차례예요. 맞추려면 ${formatChips(v.my.toCall)}이 필요해요.`
: '내 차례예요. 버튼에 적힌 칩이 바로 나가요.'
: v.toAct
? `${nick(players, v.toAct)}님 차례예요.`
: ''}
</p>
{confirmFold && (
<ConfirmSheet
title="체크할 수 있는데 포기할까요?"
body="지금은 칩을 더 내지 않고 넘어갈 수 있어요."
confirm="그래도 포기"
danger
onConfirm={() => {
act({ type: confirmFold });
setConfirmFold(null);
}}
onClose={() => setConfirmFold(null)}
/>
)}
</div>
);
}
/** NL/PL raise: slider + quick buttons (최소, 1/2팟, 팟, 최대) + ∓ 기본 단위, then 확인. Amounts are "총 몇 칩까지". */
function RaisePanel({ v, seat, raise, act, pending, onCancel }: { v: PokerView; seat: SeatView; raise: LegalBetView; act: (a: unknown) => void; pending: boolean; onCancel: () => void }) {
const min = raise.minTo!;
const max = raise.maxTo!;
const [to, setTo] = useState(min);
const clamp = (x: number) => Math.max(min, Math.min(max, Math.round(x)));
const potAfterCall = v.pot + v.my.toCall;
const quick: [string, number][] = [
['최소', min],
['1/2 팟', v.currentBet + Math.floor(potAfterCall / 2)],
['팟', v.currentBet + potAfterCall],
[v.betStyle === 'potLimit' ? '최대' : '올인', max],
];
const add = to - seat.roundBet;
const allIn = to >= seat.roundBet + seat.stack;
return (
<div className="card pk-raise" style={{ padding: 12 }}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong>
{v.currentBet === 0 ? '벳' : '레이즈'}: 이번 라운드 총 {formatChips(to)}
</strong>
<span className="small muted">
지금 낼 칩 +{formatChips(add)}
{allIn ? ' (올인)' : ''}
</span>
</div>
<div className="row" style={{ gap: 6, flexWrap: 'nowrap' }}>
<button type="button" className="btn btn-secondary" aria-label="기본 단위만큼 줄이기" onClick={() => setTo(clamp(to - v.baseUnit))}>
−
</button>
<input type="range" min={min} max={max} step={1} value={to} onChange={(e) => setTo(clamp(Number(e.target.value)))} aria-label="올릴 칩" style={{ flex: 1 }} />
<button type="button" className="btn btn-secondary" aria-label="기본 단위만큼 늘리기" onClick={() => setTo(clamp(to + v.baseUnit))}>
+
</button>
</div>
<div className="row" style={{ gap: 6 }}>
{quick.map(([label, x]) => (
<button key={label} type="button" className="btn btn-soft" style={{ flex: 1 }} onClick={() => setTo(clamp(x))}>
{label}
</button>
))}
</div>
<div className="row" style={{ gap: 6 }}>
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} onClick={onCancel}>
취소
</button>
<button type="button" className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => act(allIn && v.betStyle === 'noLimit' ? { type: 'nl.allIn' } : { type: 'nl.raise', to })}>
확인: {formatChips(to)}까지
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------- side
const RANKS_INTL = ['스트레이트 플러시(로열 포함)', '포카드', '풀하우스', '플러시', '스트레이트', '트리플', '투페어', '원페어', '하이카드'];
const RANKS_KOREAN = ['로열 스트레이트 플러시', '백 스트레이트 플러시', '스트레이트 플러시', '포카드', '풀하우스', '플러시', '마운틴(A-K-Q-J-10)', '백 스트레이트(A-2-3-4-5)', '스트레이트', '트리플', '투페어', '원페어', '탑(노페어)'];
function HandRanks({ mode, o }: { mode: PokerMode; o: Pick<PokerOptions, 'indianCards' | 'indianDeck' | 'hiLoLow' | 'hiLoQualifier'> }) {
if (mode === 'badugi') {
return (
<div className="small stack" style={{ gap: 4 }}>
<span>낮을수록 좋은 로우 게임이에요. A가 가장 낮아요.</span>
<span>
4장 중 <strong>숫자도 무늬도 겹치지 않는 카드</strong>가 많을수록 셉니다: 메이드(4장) &gt; 베이스(3장) &gt; 투베이스(2장) &gt; 노베이스.
</span>
<span>같은 등급이면 가장 높은 카드부터 비교해 낮은 쪽이 이겨요. 골프(A-2-3-4)가 최강, 세컨드(A-2-3-5), 써드(A-2-4-5).</span>
</div>
);
}
if (mode === 'indian') {
return (
<div className="small stack" style={{ gap: 4 }}>
<span>내 이마 카드는 나만 못 보고, 다른 사람 카드는 다 보여요.</span>
<span>{o.indianDeck === 'd20' ? '10이 가장 높고 A(1)가 가장 낮아요.' : 'A가 가장 높고 2가 가장 낮아요.'} 무늬는 보지 않아요.</span>
{o.indianCards === 2 && <span>2장일 때: 페어 &gt; 노페어. 노페어끼리는 높은 카드 → 낮은 카드 순으로 비교해요.</span>}
</div>
);
}
const korean = isSevenMode(mode);
return (
<div className="stack" style={{ gap: 4 }}>
<ol className="small" style={{ margin: 0, paddingLeft: 20 }}>
{(korean ? RANKS_KOREAN : RANKS_INTL).map((r) => (
<li key={r}>{r}</li>
))}
</ol>
{korean && <span className="small muted">같은 족보면 숫자, 그다음 무늬(♠ &gt; ◆ &gt; ♥ &gt; ♣)로 정해요.</span>}
{mode === 'sevenHiLo' && (
<span className="small muted">
로우: 서로 다른 숫자 5장, 낮을수록 좋음({o.hiLoLow === 'korean' ? '스트레이트·플러시 불인정, 최고 A-2-3-4-6' : '최고 A-2-3-4-5'}
{o.hiLoQualifier === 'eight' ? ', 8 이하만 인정' : ''}).
</span>
)}
{!korean && <span className="small muted">5장이 같은 서열이면 나눠 가져요(무늬는 보지 않아요).</span>}
</div>
);
}
function PokerSide({ view: v, me, players, act, pending, deadline }: BoardProps<PokerView>) {
const [confirmQuit, setConfirmQuit] = useState(false);
const ranked = [...v.seats].sort((a, b) => b.stack - a.stack);
const scoreBy = v.options.endCondition === 'hands' || v.handsPlayed >= 300;
return (
<>
<style>{CSS}</style>
<PlayChipNotice />
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong>
{MODE_KO[v.mode]} · {v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}판${v.options.endCondition === 'hands' ? ` / ${v.options.handLimit}판` : ''}`}
</strong>
{v.phase !== 'finished' && v.waiting.length > 0 && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={isMyTurn(v, me)} size={36} />}
</div>
<span className="small muted">
{v.betStyle === 'korean' ? `한국식 베팅(${v.options.koreanMaxRaise === 'full' ? '풀까지' : '하프까지'})` : v.betStyle === 'potLimit' ? '팟리밋' : '노리밋'} · 기본 {formatChips(v.baseUnit)}
</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{ranked.map((s) => (
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'eliminated' ? 0.55 : 1 }}>
<Avatar user={userOf(players, s.id)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{nick(players, s.id)}
{s.rebuys > 0 ? ` · 리바이 ${s.rebuys}` : ''}
{s.status === 'eliminated' ? ' · 탈락' : s.status === 'left' ? ' · 나감' : s.away ? ' · 자리 비움' : ''}
</span>
<Chips n={s.stack} />
</li>
))}
</ol>
</div>
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{v.result.ranking.map((g, i) => {
const sc = v.result!.scores[g[0]!] ?? 0;
return (
<span key={i}>
{v.result!.ranking.slice(0, i).reduce((a, x) => a + x.length, 1)}위 {g.map((id) => nick(players, id)).join(', ')}
{scoreBy ? ` (${sc < 0 ? '-' : ''}${Math.abs(sc).toLocaleString('ko-KR')} 칩)` : ''}
</span>
);
})}
{scoreBy && <span className="small muted">점수 = 마지막 칩 − 시작 칩 × 리바이 횟수</span>}
{v.stats.bestHand && (
<span className="small">
최고 족보: {nick(players, v.stats.bestHand.playerId)} · {v.stats.bestHand.handName} ({v.stats.bestHand.handNo}판)
</span>
)}
{v.stats.biggestPot && <span className="small">가장 큰 판: {formatChips(v.stats.biggestPot.amount)} ({v.stats.biggestPot.handNo}판)</span>}
</div>
)}
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>족보</summary>
<HandRanks mode={v.mode} o={v.options} />
</details>
{v.mode === 'indian' && v.indianUsed.length > 0 && (
<details className="card" style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>이미 나온 카드 ({v.indianUsed.length}장)</summary>
<div className="row" style={{ gap: 2, marginTop: 6 }}>
{v.indianUsed.map((c, i) => (
<PlayingCard key={`${c}${i}`} card={c} size="sm" />
))}
</div>
</details>
)}
{v.log.length > 0 && (
<details className="card" style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
<ol style={{ listStyle: 'none', margin: '8px 0 0', padding: 0, display: 'grid', gap: 2, maxHeight: 200, overflow: 'auto' }}>
{[...v.log].reverse().map((e, i) => (
<li key={i} className="small">
<span className="muted">{e.hand}판</span> {e.by ? `${nick(players, e.by)}: ` : ''}
{e.msg}
</li>
))}
</ol>
</details>
)}
{v.my.canRebuy && !confirmQuit && (
<Sheet onClose={() => {}} label="칩 다시 받기">
<h2>칩을 모두 잃었어요</h2>
<p className="muted">
놀이용 칩 {formatChips(v.options.startingChips)}을 다시 받고 계속할 수 있어요{v.my.rebuysLeft !== null ? ` (남은 횟수 ${v.my.rebuysLeft}번)` : ''}. 점수에서는 다시 받은 만큼 빠져요.
</p>
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'rebuy', accept: true })}>
칩 다시 받기
</button>
<button className="btn btn-danger" style={{ flex: 1 }} disabled={pending} onClick={() => setConfirmQuit(true)}>
그만하기
</button>
</div>
</Sheet>
)}
{confirmQuit && v.my.canRebuy && (
<ConfirmSheet
title="여기서 그만할까요?"
body="그만하면 이번 게임에서 탈락하고 구경만 할 수 있어요."
confirm="그만하기"
danger
onConfirm={() => act({ type: 'rebuy', accept: false })}
onClose={() => setConfirmQuit(false)}
/>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: PokerView, me: string | null): boolean {
if (!me || v.phase === 'finished' || v.handPhase === 'handEnd') return false;
return v.waiting.includes(me);
}
function statusText(v: PokerView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (v.phase === 'rebuy') return v.my.canRebuy ? '칩을 다시 받을지 골라 주세요' : '칩을 다시 받을지 기다리는 중이에요';
const mine = isMyTurn(v, me);
switch (v.handPhase) {
case 'betting':
if (v.toAct === me) return v.my.handName && v.options.hints ? `내 차례예요 · 지금: ${v.my.handName}` : '내 차례예요. 베팅해 주세요';
return v.toAct ? `${nick(players, v.toAct)}님 차례예요` : '카드를 나눠 주는 중이에요';
case 'choice':
return mine ? '내 차례예요. 버릴 카드와 보여 줄 카드를 골라 주세요' : '다른 사람이 카드를 고르는 중이에요';
case 'draw':
return mine ? '내 차례예요. 바꿀 카드를 골라 주세요' : '다른 사람이 바꿀 카드를 고르는 중이에요';
case 'declare':
return mine ? '내 차례예요. 하이·로우·스윙을 선언해 주세요' : '다른 사람이 선언하는 중이에요';
case 'handEnd':
return resultLine(v, players) || '판이 끝났어요';
default:
return '잠시만 기다려 주세요';
}
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
return (
<div className="segmented">
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
</div>
);
}
function Options({ value: o, onChange }: OptionsProps<PokerOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<PokerOptions>) => onChange({ ...o, ...patch });
const tooBig = o.baseUnit * 20 > o.startingChips;
const max = o.mode === 'holdem' || o.mode === 'omaha' || (o.mode === 'indian' && o.indianCards === 1 && o.indianDeck === 'd52') ? 10 : 6;
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="게임 모드">
<Seg value={o.mode} options={(Object.keys(MODE_KO) as PokerMode[]).map((m) => [m, MODE_KO[m]])} onChange={(mode) => set({ mode })} />
<span className="small muted">이 모드는 2~{max}명이 할 수 있어요.</span>
</Field>
<Field label="베팅 방식">
<Seg
value={o.betting}
options={[
['auto', '모드 기본'],
['korean', '한국식(삥·따당·하프)'],
['noLimit', '노리밋'],
['potLimit', '팟리밋'],
]}
onChange={(betting) => set({ betting })}
/>
</Field>
<Field label="시작 칩">
<Seg value={o.startingChips} options={[[500, '500'], [1000, '1,000'], [2000, '2,000'], [5000, '5,000']]} onChange={(startingChips) => set({ startingChips })} />
</Field>
<Field label={o.mode === 'holdem' || o.mode === 'omaha' ? '빅 블라인드' : '기본 칩(앤티)'}>
<Seg value={o.baseUnit} options={[[2, '2칩'], [10, '10칩'], [20, '20칩'], [50, '50칩']]} onChange={(baseUnit) => set({ baseUnit })} />
{tooBig && <span className="field-error">기본 칩은 시작 칩의 20분의 1 이하로 골라 주세요.</span>}
</Field>
<Field label="끝내는 방식">
<Seg value={o.endCondition} options={[['hands', '정해진 판 수'], ['lastStanding', '최후의 1인']]} onChange={(endCondition) => set({ endCondition })} />
{o.endCondition === 'hands' && <Seg value={o.handLimit} options={[[10, '10판'], [20, '20판'], [30, '30판'], [50, '50판']]} onChange={(handLimit) => set({ handLimit })} />}
</Field>
<Field label="행동 제한 시간">
<Seg value={o.turnSeconds} options={[[15, '15초'], [20, '20초'], [30, '30초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
{o.mode === 'indian' && (
<Field label="인디언 포커">
<Seg value={o.indianCards} options={[[1, '1장(이마)'], [2, '2장(이마 + 손)']]} onChange={(indianCards) => set({ indianCards })} />
<Seg value={o.indianDeck} options={[['d20', '20장(1~10 두 벌)'], ['d52', '52장']]} onChange={(indianDeck) => set({ indianDeck })} />
</Field>
)}
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="한국식 최대 레이즈">
<Seg value={o.koreanMaxRaise} options={[['half', '하프까지'], ['full', '풀까지']]} onChange={(koreanMaxRaise) => set({ koreanMaxRaise })} />
</Field>
<Field label="칩 다시 받기(리바이)">
<Seg value={o.rebuy} options={[['off', '안 함(탈락)'], ['limited', '횟수 제한'], ['unlimited', '무제한']]} onChange={(rebuy) => set({ rebuy })} />
{o.rebuy === 'limited' && <Seg value={o.rebuyLimit} options={[[1, '1번'], [2, '2번'], [3, '3번']]} onChange={(rebuyLimit) => set({ rebuyLimit })} />}
</Field>
<Field label="기본 칩 2배로 올리기">
<Seg value={o.blindUpEvery} options={[[0, '안 올림'], [5, '5판마다'], [10, '10판마다'], [15, '15판마다']]} onChange={(blindUpEvery) => set({ blindUpEvery })} />
</Field>
<Field label="다음 판 딜러">
<Seg value={o.dealerRule} options={[['rotate', '시계 방향으로'], ['winner', '직전 판 승자']]} onChange={(dealerRule) => set({ dealerRule })} />
</Field>
<Field label="도움 표시">
<Seg value={o.hints ? 'on' : 'off'} options={[['on', '지금 족보 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ hints: k === 'on' })} />
</Field>
{o.mode === 'sevenHiLo' && (
<Field label="하이로우 로우 판정">
<Seg value={o.hiLoLow} options={[['korean', '스트레이트·플러시 불인정'], ['aceToFive', 'A-2-3-4-5 최고']]} onChange={(hiLoLow) => set({ hiLoLow })} />
<Seg value={o.hiLoQualifier} options={[['none', '제한 없음'], ['eight', '8 이하만 로우']]} onChange={(hiLoQualifier) => set({ hiLoQualifier })} />
</Field>
)}
{o.mode === 'fiveDraw' && (
<Field label="교환 장수">
<Seg value={o.drawLimit} options={[['any', '0~5장'], ['three', '3장까지(A를 남기면 4장)']]} onChange={(drawLimit) => set({ drawLimit })} />
</Field>
)}
{o.mode === 'indian' && (
<>
<Field label="가장 높은 카드를 단 채 다이하면 벌칙">
<Seg value={o.indianFoldPenalty ? 'on' : 'off'} options={[['on', '벌칙 있음'], ['off', '없음']]} onChange={(k) => set({ indianFoldPenalty: k === 'on' })} />
{o.indianFoldPenalty && <Seg value={o.indianPenaltyUnits} options={[[5, '기본 칩 5배'], [10, '10배'], [20, '20배']]} onChange={(indianPenaltyUnits) => set({ indianPenaltyUnits })} />}
</Field>
<Field label="동점일 때">
<Seg value={o.indianTie} options={[['carry', '다음 판으로 넘김'], ['split', '나눠 가짐']]} onChange={(indianTie) => set({ indianTie })} />
</Field>
<Field label="덱 섞기">
<Seg value={o.indianReshuffle} options={[['whenEmpty', '덱을 다 쓰면(카드 기억)'], ['everyHand', '판마다']]} onChange={(indianReshuffle) => set({ indianReshuffle })} />
</Field>
</>
)}
<Field label="판 결과 보여 주는 시간">
<Seg value={o.handEndSeconds} options={[[3, '3초'], [4, '4초'], [6, '6초'], [10, '10초']]} onChange={(handEndSeconds) => set({ handEndSeconds })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: PokerOptions): string {
const bet = o.betting === 'auto' ? '' : o.betting === 'korean' ? '한국식' : o.betting === 'noLimit' ? '노리밋' : '팟리밋';
return [MODE_KO[o.mode], bet, `시작 ${o.startingChips.toLocaleString('ko-KR')}칩`, `기본 ${o.baseUnit}칩`, o.endCondition === 'hands' ? `${o.handLimit}판` : '최후의 1인', `${o.turnSeconds}초`]
.filter(Boolean)
.join(' · ');
}
const MODE_RULES: Record<PokerMode, string> = {
holdem: '각자 2장을 받고, 가운데 공용 카드 5장(플랍 3 → 턴 1 → 리버 1)과 합쳐 가장 좋은 5장을 만들어요. 베팅은 프리플랍·플랍·턴·리버 네 번이에요.',
omaha: '각자 4장을 받는데, 반드시 내 카드 2장 + 공용 카드 3장으로만 5장을 만들어요. 나머지는 홀덤과 같아요.',
sevenPoker: '4장을 받아 1장은 버리고 1장은 모두에게 보여 줘요(초이스). 그다음 공개 카드 3장과 마지막 히든 카드 1장을 받으며 네 번 베팅해요. 7장 중 가장 좋은 5장으로 겨뤄요. 공개 카드가 가장 좋은 사람(보스)이 먼저 베팅해요.',
sevenHiLo: '7포커와 같이 진행한 뒤, 마지막에 하이·로우·스윙 중 하나를 동시에 선언해요. 하이 1등과 로우 1등이 칩을 반씩 나눠 가져요. 스윙은 두 쪽 다 이겨야 모두 가져가고, 하나라도 지면 아무것도 못 받아요.',
badugi: '4장을 받고 아침·점심·저녁 세 번 카드를 바꿀 수 있어요(교환 사이마다 베팅). 숫자와 무늬가 겹치지 않으면서 낮은 카드일수록 좋아요.',
fiveDraw: '5장을 받고 한 번 베팅한 뒤, 원하는 만큼 카드를 바꾸고 다시 베팅해요. 가장 좋은 5장 족보가 이겨요.',
indian: '카드를 이마에 붙이듯 나만 못 보고 다른 사람은 모두 봐요. 상대 카드와 베팅만 보고 내 카드를 짐작해야 해요.',
};
function Rules({ options }: { options?: PokerOptions }) {
const o = options;
const mode = o?.mode ?? 'sevenPoker';
const style = o ? (o.betting !== 'auto' ? o.betting : mode === 'holdem' || mode === 'indian' ? 'noLimit' : mode === 'omaha' ? 'potLimit' : 'korean') : 'korean';
return (
<div className="stack">
<p>
<strong>{MODE_KO[mode]}</strong>: {MODE_RULES[mode]} 칩은 이 방에서만 쓰는 놀이용 점수예요.
</p>
<p>
<strong>베팅</strong>:{' '}
{style === 'korean'
? '체크(안 걸고 넘김), 삥(기본 칩만큼), 콜(앞사람만큼), 따당(앞사람이 건 만큼의 두 배), 쿼터·하프·풀(모인 칩의 4분의 1·절반·전부만큼 더), 다이(포기). 한 라운드에 올리기는 한 번뿐이고, 체크나 콜을 한 뒤에는 같은 라운드에서 올릴 수 없어요.'
: `폴드(포기), 체크, 콜, 레이즈, 올인. 레이즈는 직전 레이즈만큼 이상 올려야 해요.${style === 'potLimit' ? ' 팟리밋은 모인 칩만큼까지만 올릴 수 있어요.' : ''}`}{' '}
칩이 모자라면 가진 만큼 내고 올인하며, 그 이상 걸린 칩은 따로(사이드 팟) 겨뤄요.
</p>
<p>
<strong>족보</strong>
</p>
<HandRanks mode={mode} o={o ?? { indianCards: 1, indianDeck: 'd20', hiLoLow: 'korean', hiLoQualifier: 'none' }} />
{mode === 'indian' && o?.indianFoldPenalty !== false && <p>가장 높은 카드를 이마에 단 채 다이하면 벌칙 칩을 내요. 벌칙 칩은 그 판 승자가 가져가요.</p>}
<p>
<strong>칩이 떨어지면</strong>:{' '}
{o?.rebuy === 'off' ? '탈락하고 구경해요.' : '칩을 다시 받을지 고를 수 있어요. 다시 받은 만큼 최종 점수에서 빠져요.'}{' '}
{o?.endCondition === 'lastStanding' ? '칩을 가진 사람이 한 명 남으면 끝나요.' : '정해진 판 수가 끝나면 (마지막 칩 − 시작 칩 × 다시 받은 횟수)로 순위를 정해요.'}
</p>
<p>시간 안에 고르지 않으면 걸린 칩이 없을 때는 체크, 있을 때는 포기로 처리돼요. 두 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 짧아져요.</p>
</div>
);
}
const ui: GameUI<PokerView, PokerOptions> = {
Board: PokerBoard,
Side: PokerSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -7,6 +7,8 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
chess: () => import('./chess'), chess: () => import('./chess'),
janggi: () => import('./janggi'), janggi: () => import('./janggi'),
baduk: () => import('./baduk'), baduk: () => import('./baduk'),
seotda: () => import('./seotda'),
poker: () => import('./poker'),
}; };
const cache = new Map<string, GameUI>(); const cache = new Map<string, GameUI>();

View File

@@ -0,0 +1,831 @@
/** 섯다 화면 (docs/games/seotda.md §9). Chips are room-only play points. */
import { useEffect, useRef, useState } from 'react';
import type {
BetType,
CardSlot,
PotOutcome,
SeotdaCardView,
SeotdaOptions,
SeotdaSeatView,
SeotdaView,
SpecialKind,
} from '@bg/games/seotda';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
import { TurnTimer } from '../../components/TurnTimer';
import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard';
import { Chips, PlayChipNotice, formatChips } from '../../components/cards/Chips';
import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types';
const BET_ORDER: BetType[] = ['die', 'check', 'ping', 'call', 'ddadang', 'quarter', 'half', 'full'];
const BET_KO: Record<BetType, string> = { check: '체크', ping: '삥', call: '콜', ddadang: '따당', quarter: '쿼터', half: '하프', full: '풀', die: '다이' };
const BET_HELP: Record<BetType, string> = {
check: '칩을 걸지 않고 넘겨요',
ping: '기본 칩만큼 걸어요',
call: '앞사람만큼 맞춰요',
ddadang: '앞사람이 건 만큼의 두 배',
quarter: '모인 칩의 4분의 1만큼 더',
half: '모인 칩의 절반만큼 더',
full: '모인 칩만큼 더',
die: '이번 판을 포기해요',
};
const STRENGTH_COLOR = { high: 'var(--success)', mid: 'var(--warning)', low: 'var(--text-sub)' } as const;
const STRENGTH_KO = { high: '센 패', mid: '중간 패', low: '약한 패' } as const;
const REMATCH_KO = { gusa: '구사', mungGusa: '멍텅구리구사', tie: '동점' } as const;
const REASON_KO: Record<PotOutcome['reason'], string> = {
normal: '',
ttaengjabi: '땡잡이',
amhaeng: '암행어사',
gusa: '구사 재경기',
mungGusa: '멍텅구리구사 재경기',
tie: '동점',
limit: '재경기 횟수 초과로 나눔',
fold: '모두 다이',
};
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const isCard = (c: CardSlot): c is SeotdaCardView => !('hidden' in c);
/** Illustration card for the rules / 족보표 (no rule code needed on the client). */
function illus(month: number, special: boolean): HwatuInfo {
const gwang = [1, 3, 8].includes(month);
const kind: HwatuInfo['kind'] = special ? (gwang ? 'gwang' : 'yeol') : month === 8 ? 'pi' : 'tti';
return {
id: (month - 1) * 4 + (special ? 0 : 1),
month,
kind,
...(kind === 'tti' ? { tti: 'plain' as const } : {}),
name: `${month}월 ${special ? (gwang ? '광' : '열끗') : month === 8 ? '기러기' : '띠'}`,
};
}
function totalMsOf(v: SeotdaView): number {
const o = v.options;
if (v.phase === 'rebuy' || v.handPhase === 'rejoin') return o.decisionSeconds * 1000;
if (v.handPhase === 'choose') return (o.turnSeconds + 10) * 1000;
if (v.handPhase === 'handEnd') return 4000;
const turn = v.seats.find((s) => s.id === v.toAct);
return turn?.away ? 5000 : o.turnSeconds * 1000;
}
function specialFlash(v: SeotdaView): string | null {
const pots = v.showdown?.pots ?? [];
if (pots.some((p) => p.reason === 'ttaengjabi')) return '땡잡이!';
if (pots.some((p) => p.reason === 'amhaeng')) return '암행어사 출두!';
if (pots.some((p) => p.type === 'rematch' && p.reason === 'gusa')) return '구사 재경기';
if (pots.some((p) => p.type === 'rematch' && p.reason === 'mungGusa')) return '멍텅구리구사 재경기';
if (pots.some((p) => p.type === 'rematch')) return '동점 재경기';
return null;
}
const CSS = `
.sd-table { position: relative; width: 100%; aspect-ratio: 16 / 11; border-radius: 180px; border: 10px solid var(--wood);
background: radial-gradient(ellipse at center, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0) 60%), var(--felt); box-shadow: var(--shadow); }
.sd-seat { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: var(--seat-w, 168px); z-index: 1;
background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.25);
border: 3px solid transparent; display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; transition: border-color .2s; }
.sd-seat.turn { border-color: var(--focus); }
.sd-seat.me { border-color: var(--primary); }
.sd-seat.me.turn { border-color: var(--focus); }
.sd-seat.out { opacity: 0.6; }
.sd-center { position: absolute; left: 50%; top: 47%; transform: translate(-50%, -50%); text-align: center; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,.5); max-width: 46%; }
.sd-flash { position: absolute; left: 50%; top: 30%; transform: translate(-50%, -50%); z-index: 3; font-size: 2.6rem; font-weight: 900; color: #fde047;
text-shadow: 0 3px 8px rgba(0,0,0,.6); animation: sd-pop .35s ease-out; pointer-events: none; white-space: nowrap; }
@keyframes sd-pop { from { transform: translate(-50%, -50%) scale(.5); opacity: 0 } to { transform: translate(-50%, -50%) scale(1); opacity: 1 } }
.sd-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.sd-actions .btn { min-height: 64px; flex-direction: column; gap: 0; padding: 0 8px; line-height: 1.15; }
.sd-actions .btn small { font-weight: 600; font-size: .85rem; opacity: .9; }
.sd-mine { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; justify-content: center; }
.sd-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; }
@media (max-width: 719px) {
.sd-table { aspect-ratio: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 10px; border-radius: 20px; border-width: 6px; }
.sd-seat { position: static; transform: none; width: auto; }
.sd-center { position: static; transform: none; grid-column: 1 / -1; order: -1; max-width: none; padding: 8px 0; }
.sd-flash { position: fixed; top: 40%; }
.sd-actions { grid-template-columns: repeat(2, 1fr); }
}
`;
// ---------------------------------------------------------------- board
function SeatBox({ seat, view: v, players, me, deadline, x, y }: { seat: SeotdaSeatView; view: SeotdaView; players: Record<string, PublicUser>; me: string | null; deadline: number | null; x: number; y: number }) {
const u = userOf(players, seat.id);
const out = seat.status === 'eliminated' || seat.folded || (!seat.inHand && v.handPhase !== null);
const badges: [string, string][] = [];
if (seat.left) badges.push(['나감', 'var(--danger)']);
else if (seat.away) badges.push(['자리 비움', 'var(--warning)']);
if (seat.status === 'eliminated') badges.push(['탈락', 'var(--text-sub)']);
if (seat.status === 'busted') badges.push(['리바이 대기', 'var(--warning)']);
if (seat.folded) badges.push(['다이', 'var(--danger)']);
if (seat.allIn) badges.push(['올인', 'var(--primary)']);
if (seat.chosen === true) badges.push(['고름', 'var(--success)']);
if (seat.chosen === false) badges.push(['고르는 중', 'var(--text-sub)']);
const timed = seat.isTurn || seat.chosen === false;
return (
<div
className={`sd-seat${seat.isTurn ? ' turn' : ''}${seat.id === me ? ' me' : ''}${out ? ' out' : ''}`}
style={{ '--x': `${x}%`, '--y': `${y}%` } as React.CSSProperties}
aria-label={`${u.nickname} ${formatChips(seat.stack)}${seat.isDealer ? ', 선' : ''}${badges.length ? `, ${badges.map((b) => b[0]).join(', ')}` : ''}`}
>
<div className="row" style={{ gap: 5, flexWrap: 'nowrap' }}>
<Avatar user={u} size={22} />
<strong style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{u.nickname}
{seat.id === me && <span className="muted"> (나)</span>}
</strong>
{seat.isDealer && (
<span className="sd-badge" style={{ background: '#facc15', color: '#422006' }} title="선(먼저 하는 사람)">
선
</span>
)}
{timed && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={seat.id === me} size={28} />}
</div>
<div className="row" style={{ gap: 6, flexWrap: 'nowrap', alignItems: 'flex-start' }}>
<div className="stack" style={{ gap: 2, flex: 1, minWidth: 0 }}>
<Chips n={seat.stack} strong />
{((seat.lastAction && !seat.folded) || seat.roundBet > 0) && (
<span className="small" style={{ whiteSpace: 'nowrap' }} title="마지막 행동 · 이번 라운드에 건 칩">
{seat.lastAction && !seat.folded && <span className="muted">{BET_KO[seat.lastAction.type]} </span>}
{seat.roundBet > 0 && `+${seat.roundBet.toLocaleString('ko-KR')}`}
</span>
)}
<div className="row" style={{ gap: 3 }}>
{badges.map(([t, c]) => (
<span key={t} className="sd-badge" style={{ background: c, color: '#fff' }}>
{t}
</span>
))}
</div>
</div>
{seat.cards.length > 0 && (
<div style={{ display: 'flex', flex: 'none' }}>
{seat.cards.map((c, i) => (
<HwatuCard key={isCard(c) ? c.key : `h${i}`} card={isCard(c) ? c : null} size="sm" style={i > 0 ? { marginLeft: -14 } : undefined} />
))}
</div>
)}
</div>
{seat.handName && <strong style={{ color: 'var(--primary)', textAlign: 'center' }}>{seat.handName}</strong>}
</div>
);
}
function CenterInfo({ view: v, players }: { view: SeotdaView; players: Record<string, PublicUser> }) {
const limit = v.options.endCondition === 'hands' ? `/${v.options.handLimit}` : '';
// On the result screen the paid-out pots are no longer in v.pot (only a carried rematch pot is).
const paid = v.handPhase === 'handEnd' ? (v.foldWin?.amount ?? (v.showdown?.pots ?? []).filter((p) => p.type !== 'rematch').reduce((a, p) => a + p.amount, 0)) : 0;
return (
<div className="sd-center">
{v.rematchNo > 0 && v.rematchReason && (
<div className="sd-badge" style={{ background: '#fde047', color: '#422006', fontSize: '1rem', padding: '2px 12px', marginBottom: 6 }}>
재경기 {v.rematchNo}회차 · {REMATCH_KO[v.rematchReason]}
</div>
)}
<div style={{ fontSize: '0.95rem', opacity: 0.9 }}>모인 칩</div>
<div style={{ fontSize: '2.2rem', fontWeight: 900, fontVariantNumeric: 'tabular-nums' }} aria-live="polite">
{formatChips(v.pot + paid)}
</div>
{v.pots.length > 1 && (
<div className="small" style={{ opacity: 0.9 }}>
{v.pots.map((p, i) => (
<div key={i}>
{i === v.pots.length - 1 ? '메인' : `사이드 ${i + 1}`}: {formatChips(p.amount)} ({p.eligible.map((id) => nick(players, id)).join('·')})
</div>
))}
</div>
)}
<div className="small" style={{ opacity: 0.85, marginTop: 4 }}>
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}${limit}판`} · 학교 {formatChips(v.baseUnit)}
{v.currentBet > 0 && v.handPhase === 'betting' ? ` · 이번 라운드 ${formatChips(v.currentBet)}` : ''}
</div>
</div>
);
}
function SeotdaBoard({ view: v, me, players, act, pending, deadline }: BoardProps<SeotdaView>) {
const n = v.seats.length;
const myIdx = Math.max(0, v.seats.findIndex((s) => s.id === me));
const [flash, setFlash] = useState<string | null>(null);
const [announce, setAnnounce] = useState('');
const prevToAct = useRef(v.toAct);
const showKey = `${v.handNo}-${v.rematchNo}-${v.handPhase}`;
useEffect(() => {
const f = v.handPhase === 'handEnd' ? specialFlash(v) : null;
if (f) {
setFlash(f);
const t = setTimeout(() => setFlash(null), 1400);
return () => clearTimeout(t);
}
return undefined;
}, [showKey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (v.toAct && v.toAct !== prevToAct.current) {
if (v.toAct === me) {
haptic(40);
sounds.myTurn();
setAnnounce('내 차례예요.');
} else setAnnounce(`${nick(players, v.toAct)}님 차례예요.`);
}
prevToAct.current = v.toAct;
}, [v.toAct]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (v.handPhase !== 'handEnd') return;
if (v.foldWin) setAnnounce(`${nick(players, v.foldWin.winner)}님이 ${formatChips(v.foldWin.amount)}을 가져갔어요.`);
else if (v.showdown) setAnnounce(resultLine(v, players));
const won = v.foldWin?.winner === me || v.showdown?.pots.some((p) => p.type !== 'rematch' && p.winners.includes(me ?? ''));
if (me && won) sounds.win();
}, [showKey]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="board-wrap stack" style={{ gap: 12 }}>
<style>{CSS}</style>
<div className="sd-table" role="region" aria-label="섯다 테이블" style={{ '--seat-w': v.options.cardMode === 'three' ? '196px' : n > 6 ? '150px' : '172px' } as React.CSSProperties}>
{v.seats.map((s, i) => {
const rel = (i - myIdx + n) % n;
const ang = Math.PI / 2 + (rel * 2 * Math.PI) / n;
return <SeatBox key={s.id} seat={s} view={v} players={players} me={me} deadline={deadline} x={50 + 40 * Math.cos(ang)} y={50 + 38 * Math.sin(ang)} />;
})}
<CenterInfo view={v} players={players} />
{flash && <div className="sd-flash">{flash}</div>}
</div>
<MyArea view={v} me={me} players={players} act={act} pending={pending} />
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
function resultLine(v: SeotdaView, players: Record<string, PublicUser>): string {
if (v.foldWin) return `${nick(players, v.foldWin.winner)}님이 ${formatChips(v.foldWin.amount)}을 가져갔어요(모두 다이).`;
if (!v.showdown) return '';
return v.showdown.pots
.map((p) => {
const who = p.winners.map((id) => nick(players, id)).join('·');
const name = v.showdown!.hands.find((h) => h.id === p.winners[0])?.name ?? '';
if (p.type === 'rematch') return `${REASON_KO[p.reason]}: ${who}님이 다시 겨뤄요(${formatChips(p.amount)})`;
if (p.type === 'split') return `${who}님이 ${formatChips(p.amount)}을 나눠 가졌어요${p.reason === 'limit' ? '(재경기 횟수 초과)' : ''}`;
return `${who}님이 ${name}${p.reason === 'normal' ? '' : ' 발동'}(으)로 ${formatChips(p.amount)}을 가져갔어요`;
})
.join(' / ');
}
/** My cards, hand name, 3장 choice and the action buttons. */
function MyArea({ view: v, me, players, act, pending }: { view: SeotdaView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean }) {
const seat = v.seats.find((s) => s.id === me);
const [pick, setPick] = useState<number[]>([]);
const [hover, setHover] = useState<BetType | null>(null);
useEffect(() => setPick([]), [v.handNo, v.rematchNo, v.handPhase]);
if (!me || !seat) return v.handPhase === 'handEnd' ? <ResultPanel view={v} players={players} /> : null;
const myCards = seat.cards.filter(isCard);
const choosing = v.handPhase === 'choose' && seat.chosen === false;
const preview = choosing && pick.length === 2 ? v.myCombos?.find((c) => c.cardIds.includes(pick[0]!) && c.cardIds.includes(pick[1]!)) : null;
const myTurn = v.toAct === me && v.handPhase === 'betting';
const legal = new Map(v.legal.map((l) => [l.type, l]));
const hoverBet = hover ? legal.get(hover) : undefined;
const toggle = (id: number) => setPick((p) => (p.includes(id) ? p.filter((x) => x !== id) : p.length >= 2 ? [p[1]!, id] : [...p, id]));
const showHand = myCards.length > 0 && seat.inHand;
return (
<div className="stack" style={{ gap: 10 }}>
{showHand && (
<div className="card stack" style={{ gap: 10, padding: 12 }}>
<div className="sd-mine">
{myCards.map((c) => {
const chosen = v.myChosen?.includes(c.id);
return (
<HwatuCard
key={c.key}
card={c}
size="lg"
selected={choosing && pick.includes(c.id)}
highlight={!!chosen}
dimmed={!!v.myChosen && !chosen}
onClick={choosing ? () => toggle(c.id) : undefined}
/>
);
})}
{v.myHand && (
<div className="stack" style={{ gap: 2, minWidth: 160, maxWidth: 320 }}>
<span style={{ fontSize: '1.5rem', fontWeight: 900, color: v.myHand.strength ? STRENGTH_COLOR[v.myHand.strength] : undefined }}>
지금: {v.myHand.name}
{v.myHand.strength && <span className="small" style={{ marginLeft: 6 }}>({STRENGTH_KO[v.myHand.strength]})</span>}
</span>
<span className="small muted">{v.myHand.hint}</span>
</div>
)}
</div>
{seat.folded && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>이번 판은 다이했어요. 내 패는 나만 볼 수 있어요.</p>}
{choosing && (
<div className="stack" style={{ gap: 8 }}>
<p style={{ margin: 0, textAlign: 'center' }}>
<strong>쓸 카드 2장을 골라 주세요.</strong> 남는 1장은 아무에게도 보이지 않아요.
{preview && (
<>
{' '}
고른 조합: <strong>{preview.name}</strong>
</>
)}
</p>
<div className="row" style={{ justifyContent: 'center' }}>
{v.options.hints && v.myCombos && (
<button type="button" className="btn btn-secondary" onClick={() => setPick([...v.myCombos!.find((c) => c.best)!.cardIds])}>
추천 조합
</button>
)}
<button
type="button"
className="btn btn-primary"
disabled={pick.length !== 2 || pending}
onClick={() => act({ type: 'choose', cardIds: [pick[0], pick[1]] })}
>
{preview ? `${preview.name}(으)로 정하기` : '두 장을 고르세요'}
</button>
</div>
</div>
)}
{v.handPhase === 'choose' && seat.chosen === true && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>골랐어요. 다른 사람을 기다리는 중이에요.</p>}
</div>
)}
{seat.inHand && v.handPhase === 'betting' && !seat.folded && (
<div className="stack" style={{ gap: 6 }}>
<div className="sd-actions" role="group" aria-label="베팅">
{BET_ORDER.filter((t) => t !== 'full' || v.options.maxRaise === 'full').map((t) => {
const l = legal.get(t);
const enabled = myTurn && !!l && !pending;
const cls = t === 'die' ? 'btn-danger' : t === 'check' || t === 'call' ? 'btn-primary' : 'btn-soft';
return (
<button
key={t}
type="button"
className={`btn ${cls}`}
disabled={!enabled}
title={BET_HELP[t]}
onMouseEnter={() => setHover(t)}
onMouseLeave={() => setHover(null)}
onFocus={() => setHover(t)}
onBlur={() => setHover(null)}
onClick={() => act({ type: t })}
>
<span>{BET_KO[t]}</span>
<small>{l && l.amount > 0 ? `${formatChips(l.amount)}${l.allIn ? ' 올인' : ''}` : t === 'die' ? '포기' : l ? '0 칩' : '-'}</small>
</button>
);
})}
</div>
<p className="small muted" style={{ margin: 0, minHeight: '1.4em', textAlign: 'center' }}>
{myTurn
? hoverBet
? `${BET_KO[hover!]}: ${BET_HELP[hover!]}. 내고 나면 내 칩 ${formatChips(seat.stack - hoverBet.amount)}, 모인 칩 ${formatChips(v.pot + hoverBet.amount)}`
: '내 차례예요. 버튼에 적힌 칩이 바로 나가요.'
: v.toAct
? `${nick(players, v.toAct)}님 차례예요.`
: ''}
</p>
</div>
)}
{v.handPhase === 'handEnd' && <ResultPanel view={v} players={players} />}
{v.handPhase === 'handEnd' && seat.status === 'playing' && !seat.left && (
<button type="button" className="btn btn-primary btn-block" disabled={seat.ready || pending} onClick={() => act({ type: 'ready' })}>
{seat.ready ? '다른 사람을 기다리는 중…' : v.next === 'rematch' ? '재경기 시작' : '다음 판으로'}
</button>
)}
</div>
);
}
function ResultPanel({ view: v, players }: { view: SeotdaView; players: Record<string, PublicUser> }) {
return (
<div className="card stack" style={{ gap: 6, padding: 12 }} aria-live="polite">
<strong>{v.next === 'rematch' ? '재경기가 결정됐어요' : '이번 판 결과'}</strong>
{v.foldWin && (
<span>
{nick(players, v.foldWin.winner)}님이 {formatChips(v.foldWin.amount)}을 가져갔어요. 모두 다이해서 패는 공개하지 않아요.
</span>
)}
{v.showdown?.pots.map((p, i) => {
const who = p.winners.map((id) => nick(players, id)).join(' · ');
const name = v.showdown!.hands.find((h) => h.id === p.winners[0])?.name;
return (
<span key={i}>
{v.showdown!.pots.length > 1 ? `${i === v.showdown!.pots.length - 1 ? '메인' : '사이드'} ${formatChips(p.amount)}: ` : `${formatChips(p.amount)}: `}
{p.type === 'win' && (
<>
<strong>{who}</strong> 승리 ({name}
{p.reason === 'ttaengjabi' || p.reason === 'amhaeng' ? ' 발동' : ''})
</>
)}
{p.type === 'split' && (
<>
<strong>{who}</strong> 나눠 가짐{p.reason === 'limit' ? ' (재경기 횟수 초과)' : ' (같은 족보)'}
</>
)}
{p.type === 'rematch' && (
<>
<strong>{REASON_KO[p.reason]}</strong> — {who}님이 다시 겨뤄요. 칩은 그대로 남아요.
</>
)}
</span>
);
})}
</div>
);
}
// ---------------------------------------------------------------- side
interface JokboRow {
name: string;
cards: [number, boolean][];
note: string;
match: (value: number | null, special: SpecialKind | null) => boolean;
}
function jokboRows(o: SeotdaOptions): JokboRow[] {
const v = (lo: number, hi = lo) => (x: number | null, sp: SpecialKind | null) => !sp && x !== null && x >= lo && x <= hi;
const rows: JokboRow[] = [
{ name: '38광땡', cards: [[3, true], [8, true]], note: '가장 센 패', match: v(1000) },
...(o.gwangTtaeng === 'all'
? [
{ name: '18광땡', cards: [[1, true], [8, true]], note: '', match: v(990) } as JokboRow,
{ name: '13광땡', cards: [[1, true], [3, true]], note: '', match: v(980) } as JokboRow,
]
: []),
{ name: '장땡(10땡)', cards: [[10, true], [10, false]], note: '같은 월 두 장 중 최고', match: v(910) },
{ name: '9땡 ~ 삥땡(1땡)', cards: [[9, true], [9, false]], note: '숫자가 클수록 셈', match: v(901, 909) },
{ name: '알리', cards: [[1, true], [2, false]], note: '1 + 2', match: v(806) },
{ name: '독사', cards: [[1, true], [4, false]], note: '1 + 4', match: v(805) },
{ name: '구삥', cards: [[1, true], [9, false]], note: '1 + 9', match: v(804) },
{ name: '장삥', cards: [[1, true], [10, false]], note: '1 + 10', match: v(803) },
{ name: '장사', cards: [[4, false], [10, false]], note: '4 + 10', match: v(802) },
{ name: '세륙', cards: [[4, false], [6, false]], note: '4 + 6', match: v(801) },
{ name: '갑오(9끗)', cards: [[2, false], [7, false]], note: '두 월을 더한 일의 자리 9', match: v(9) },
{ name: '8끗 ~ 1끗', cards: [[3, false], [5, false]], note: '일의 자리가 클수록 셈', match: v(1, 8) },
{ name: '망통(0끗)', cards: [[2, false], [8, false]], note: '가장 약한 패', match: v(0) },
];
const sp = (k: SpecialKind) => (_x: number | null, s: SpecialKind | null) => s === k;
if (o.ttaengjabi !== 'off')
rows.push({
name: '땡잡이',
cards: [[3, true], [7, true]],
note: `${o.ttaengjabi === 'strict' ? '3월 광 + 7월 열끗' : '3월 + 7월 아무 패'}. 상대 최고가 1~9땡이면 이김, 아니면 망통`,
match: sp('ttaengjabi'),
});
if (o.amhaeng) rows.push({ name: '암행어사', cards: [[4, true], [7, true]], note: '4·7월 열끗. 13·18광땡을 잡음, 아니면 1끗', match: sp('amhaeng') });
if (o.gusa) {
rows.push({ name: '구사', cards: [[4, false], [9, true]], note: '4 + 9. 상대가 알리 이하면 재경기, 아니면 3끗', match: sp('gusa') });
if (o.mungGusa !== 'off') {
const upTo = o.mungGusa === 'upTo9' ? '9땡' : o.mungGusa === 'upToJang' ? '장땡' : '8땡';
rows.push({ name: '멍텅구리구사', cards: [[4, true], [9, true]], note: `4·9월 열끗. 상대가 ${upTo} 이하면 재경기`, match: sp('mungGusa') });
}
}
return rows;
}
function JokboTable({ options, mine }: { options: SeotdaOptions; mine?: SeotdaView['myHand'] }) {
const rows = jokboRows(options);
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
<tbody>
{rows.map((r) => {
const hit = !!mine && options.hints && r.match(mine.value, mine.special);
return (
<tr key={r.name} style={{ background: hit ? 'var(--warning-soft)' : undefined, borderBottom: '1px solid var(--border)' }} aria-current={hit ? 'true' : undefined}>
<td style={{ padding: '4px 2px', whiteSpace: 'nowrap' }}>
{r.cards.map(([m, sp], i) => (
<HwatuCard key={i} card={illus(m, sp)} size="sm" style={{ marginRight: 2 }} />
))}
</td>
<td style={{ padding: '4px 6px' }}>
<strong>{r.name}</strong>
{hit && <span className="sd-badge" style={{ background: 'var(--warning)', color: '#fff', marginLeft: 4 }}>내 패</span>}
{r.note && <div className="small muted">{r.note}</div>}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
function SeotdaSide({ view: v, me, players, act, pending, deadline }: BoardProps<SeotdaView>) {
const [confirmQuit, setConfirmQuit] = useState(false);
const ranked = [...v.seats].sort((a, b) => b.stack - a.stack);
return (
<>
<style>{CSS}</style>
<PlayChipNotice />
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong>
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}판${v.options.endCondition === 'hands' ? ` / ${v.options.handLimit}판` : ''}`}
{v.rematchNo > 0 ? ` · 재경기 ${v.rematchNo}회차` : ''}
</strong>
{v.phase !== 'finished' && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={isMyTurn(v, me)} size={36} />}
</div>
<span className="small muted">
학교 {formatChips(v.baseUnit)} · {v.options.cardMode === 'three' ? '3장 섯다' : '2장 섯다'} · {v.options.maxRaise === 'full' ? '풀까지' : '하프까지'}
</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{ranked.map((s) => (
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'eliminated' ? 0.55 : 1 }}>
<Avatar user={userOf(players, s.id)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{nick(players, s.id)}
{s.isDealer ? ' · 선' : ''}
{s.rebuys > 0 ? ` · 리바이 ${s.rebuys}` : ''}
{s.status === 'eliminated' ? ' · 탈락' : s.left ? ' · 나감' : s.away ? ' · 자리 비움' : ''}
</span>
<Chips n={s.stack} />
</li>
))}
</ol>
</div>
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{v.result.ranking.map((g, i) => (
<span key={i}>
{v.result!.ranking.slice(0, i).reduce((a, x) => a + x.length, 1)}위 {g.map((id) => nick(players, id)).join(', ')}
{v.options.endCondition === 'hands' || v.handsPlayed >= 300
? ` (${(v.result!.scores[g[0]!] ?? 0) >= 0 ? '+' : ''}${(v.result!.scores[g[0]!] ?? 0).toLocaleString('ko-KR')} 칩)`
: ''}
</span>
))}
<span className="small muted">점수 = 마지막 칩 − 시작 칩 × 리바이 횟수</span>
</div>
)}
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>족보표</summary>
<JokboTable options={v.options} mine={v.myHand} />
</details>
{v.history.length > 0 && (
<details className="card" style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>판 기록</summary>
<ol style={{ listStyle: 'none', margin: '8px 0 0', padding: 0, display: 'grid', gap: 2, maxHeight: 180, overflow: 'auto' }}>
{[...v.history].reverse().map((r, i) => (
<li key={i} className="small">
<span className="muted">
{r.handNo}판{r.rematchNo ? `(재${r.rematchNo})` : ''}
</span>{' '}
{r.how === 'rematch'
? `재경기로 넘어감 ${formatChips(r.amount)}`
: `${r.winners.map((id) => nick(players, id)).join('·')} ${formatChips(r.amount)}${r.name ? ` · ${r.name}` : ''}${r.how !== 'normal' ? ` · ${REASON_KO[r.how]}` : ''}`}
</li>
))}
</ol>
</details>
)}
{v.rejoin?.mine && (
<Sheet onClose={() => {}} label="재경기 참가">
<h2>구사 재경기에 다시 들어갈까요?</h2>
<p className="muted">
{formatChips(v.rejoin.cost)}을 모인 칩에 더 내면 재경기에 참가할 수 있어요. 시간이 지나면 참가하지 않아요.
</p>
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending || !v.rejoin.canAfford} onClick={() => act({ type: 'rejoin', accept: true })}>
{v.rejoin.canAfford ? `${formatChips(v.rejoin.cost)} 내고 참가` : '칩이 모자라요'}
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'rejoin', accept: false })}>
참가 안 함
</button>
</div>
</Sheet>
)}
{v.rebuy?.mine && !confirmQuit && (
<Sheet onClose={() => {}} label="칩 다시 받기">
<h2>칩을 모두 잃었어요</h2>
<p className="muted">
놀이용 칩 {formatChips(v.options.startingChips)}을 다시 받고 계속할 수 있어요{v.rebuy.left !== null ? ` (남은 횟수 ${v.rebuy.left}번)` : ''}. 점수에서는 다시 받은 만큼 빠져요.
</p>
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'rebuy', accept: true })}>
칩 다시 받기
</button>
<button className="btn btn-danger" style={{ flex: 1 }} disabled={pending} onClick={() => setConfirmQuit(true)}>
그만하기
</button>
</div>
</Sheet>
)}
{confirmQuit && v.rebuy?.mine && (
<ConfirmSheet
title="여기서 그만할까요?"
body="그만하면 이번 게임에서 탈락하고 구경만 할 수 있어요."
confirm="그만하기"
danger
onConfirm={() => act({ type: 'rebuy', accept: false })}
onClose={() => setConfirmQuit(false)}
/>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: SeotdaView, me: string | null): boolean {
if (!me || v.phase === 'finished') return false;
if (v.phase === 'rebuy') return !!v.rebuy?.mine;
if (v.handPhase === 'betting') return v.toAct === me;
if (v.handPhase === 'choose') return v.seats.find((s) => s.id === me)?.chosen === false;
if (v.handPhase === 'rejoin') return !!v.rejoin?.mine;
return false;
}
function statusText(v: SeotdaView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (v.phase === 'rebuy') return v.rebuy?.mine ? '칩을 다시 받을지 골라 주세요' : '칩을 다시 받을지 기다리는 중이에요';
switch (v.handPhase) {
case 'betting':
if (v.toAct === me) return v.myHand && v.options.hints ? `내 차례예요 · 지금: ${v.myHand.name}` : '내 차례예요. 베팅해 주세요';
return v.toAct ? `${nick(players, v.toAct)}님 차례예요` : '카드를 나눠 주는 중이에요';
case 'choose':
return isMyTurn(v, me) ? '쓸 카드 2장을 골라 주세요' : '다른 사람이 카드를 고르는 중이에요';
case 'rejoin':
return v.rejoin?.mine ? '구사 재경기에 참가할지 골라 주세요' : '다이한 사람이 재경기 참가를 고르는 중이에요';
case 'handEnd':
return resultLine(v, players) || '판이 끝났어요';
default:
return '잠시만 기다려 주세요';
}
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
return (
<div className="segmented">
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
</div>
);
}
function Options({ value: o, onChange }: OptionsProps<SeotdaOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<SeotdaOptions>) => onChange({ ...o, ...patch });
const tooBig = o.baseUnit * 20 > o.startingChips;
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="카드 수">
<Seg value={o.cardMode} options={[['two', '2장 섯다'], ['three', '3장 섯다(최대 6명)']]} onChange={(cardMode) => set({ cardMode })} />
</Field>
{o.cardMode === 'two' && (
<Field label="카드 나눠 주기">
<Seg value={o.dealMode} options={[['twoRounds', '1장씩 두 번(베팅 2번)'], ['oneRound', '2장 한 번에(베팅 1번)']]} onChange={(dealMode) => set({ dealMode })} />
</Field>
)}
<Field label="시작 칩">
<Seg value={o.startingChips} options={[[500, '500'], [1000, '1,000'], [2000, '2,000'], [5000, '5,000']]} onChange={(startingChips) => set({ startingChips })} />
</Field>
<Field label="학교(기본 칩) = 삥">
<Seg value={o.baseUnit} options={[[2, '2칩'], [10, '10칩'], [20, '20칩'], [50, '50칩']]} onChange={(baseUnit) => set({ baseUnit })} />
{tooBig && <span className="field-error">학교는 시작 칩의 20분의 1 이하로 골라 주세요.</span>}
</Field>
<Field label="끝내는 방식">
<Seg value={o.endCondition} options={[['hands', '정해진 판 수'], ['lastStanding', '최후의 1인']]} onChange={(endCondition) => set({ endCondition })} />
{o.endCondition === 'hands' && (
<Seg value={o.handLimit} options={[[10, '10판'], [20, '20판'], [30, '30판'], [50, '50판']]} onChange={(handLimit) => set({ handLimit })} />
)}
</Field>
<Field label="행동 제한 시간">
<Seg value={o.turnSeconds} options={[[15, '15초'], [20, '20초'], [30, '30초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="최대 레이즈">
<Seg value={o.maxRaise} options={[['half', '하프까지'], ['full', '풀까지']]} onChange={(maxRaise) => set({ maxRaise })} />
</Field>
<Field label="칩 다시 받기(리바이)">
<Seg value={o.rebuy} options={[['off', '안 함(탈락)'], ['limited', '횟수 제한'], ['unlimited', '무제한']]} onChange={(rebuy) => set({ rebuy })} />
{o.rebuy === 'limited' && <Seg value={o.rebuyLimit} options={[[1, '1번'], [2, '2번'], [3, '3번']]} onChange={(rebuyLimit) => set({ rebuyLimit })} />}
</Field>
<Field label="학교 올리기">
<Seg value={o.schoolUpEvery} options={[[0, '안 올림'], [5, '5판마다 2배'], [10, '10판마다 2배'], [15, '15판마다 2배']]} onChange={(schoolUpEvery) => set({ schoolUpEvery })} />
</Field>
<Field label="다음 판의 선">
<Seg value={o.dealerRule} options={[['winner', '이긴 사람'], ['rotate', '돌아가며']]} onChange={(dealerRule) => set({ dealerRule })} />
</Field>
<Field label="광땡 종류">
<Seg value={o.gwangTtaeng} options={[['all', '38·18·13광땡'], ['only38', '38광땡만']]} onChange={(gwangTtaeng) => set({ gwangTtaeng })} />
</Field>
<Field label="땡잡이(3·7)">
<Seg value={o.ttaengjabi} options={[['strict', '3월 광 + 7월 열끗'], ['loose', '3월 + 7월 아무 패'], ['off', '없음']]} onChange={(ttaengjabi) => set({ ttaengjabi })} />
</Field>
<Field label="특수패">
<Seg value={o.amhaeng ? 'on' : 'off'} options={[['on', '암행어사 켜기'], ['off', '암행어사 끄기']]} onChange={(k) => set({ amhaeng: k === 'on' })} />
<Seg value={o.gusa ? 'on' : 'off'} options={[['on', '구사 재경기 켜기'], ['off', '구사 재경기 끄기']]} onChange={(k) => set({ gusa: k === 'on' })} />
</Field>
{o.gusa && (
<>
<Field label="멍텅구리구사 재경기 범위">
<Seg value={o.mungGusa} options={[['upTo8', '8땡까지'], ['upTo9', '9땡까지'], ['upToJang', '장땡까지'], ['off', '일반 구사로']]} onChange={(mungGusa) => set({ mungGusa })} />
</Field>
<Field label="구사 재경기 때 다이한 사람">
<Seg value={o.rejoinCost} options={[['none', '참가 불가'], ['half', '모인 칩 절반 내고 참가'], ['full', '모인 칩만큼 내고 참가']]} onChange={(rejoinCost) => set({ rejoinCost })} />
</Field>
</>
)}
<Field label="같은 족보일 때">
<Seg value={o.tieRule} options={[['rematch', '재경기'], ['split', '나눠 가짐']]} onChange={(tieRule) => set({ tieRule })} />
</Field>
<Field label="재경기 최대 횟수(넘으면 나눠 가짐)">
<Seg value={o.maxRematches} options={[[1, '1번'], [2, '2번'], [3, '3번'], [5, '5번']]} onChange={(maxRematches) => set({ maxRematches })} />
</Field>
<Field label="재참가·리바이 답 시간">
<Seg value={o.decisionSeconds} options={[[10, '10초'], [15, '15초'], [30, '30초']]} onChange={(decisionSeconds) => set({ decisionSeconds })} />
</Field>
<Field label="도움 표시">
<Seg value={o.hints ? 'on' : 'off'} options={[['on', '내 족보 이름 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ hints: k === 'on' })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: SeotdaOptions): string {
return [
o.cardMode === 'three' ? '3장 섯다' : o.dealMode === 'twoRounds' ? '2장 섯다(1장씩 두 번)' : '2장 섯다(한 번에)',
`시작 ${o.startingChips.toLocaleString('ko-KR')}칩`,
`학교 ${o.baseUnit}칩`,
o.endCondition === 'hands' ? `${o.handLimit}판` : '최후의 1인',
`${o.turnSeconds}초`,
].join(' · ');
}
function Rules({ options }: { options?: SeotdaOptions }) {
const o = options;
return (
<div className="stack">
<p>
화투 20장(1~10월 각 2장)으로 하는 족보 대결이에요. 각자 받은 <strong>2장의 조합(족보)</strong>이 가장 센 사람이 모인 칩을 가져가요. 칩은 이 방에서만 쓰는 놀이용 점수예요.
</p>
<p>
<strong>진행</strong>: 판마다 모두 <strong>학교(기본 칩)</strong>를 내요. 선부터 카드를 받고 차례로 베팅해요.
{o?.cardMode === 'three'
? ' 3장 섯다는 2장을 받고 베팅 → 1장을 더 받아 3장 중 쓸 2장을 동시에 몰래 고르고 → 마지막 베팅 후 공개해요. 버린 1장은 끝까지 아무도 못 봐요.'
: o?.dealMode === 'oneRound'
? ' 2장을 한 번에 받고 한 번 베팅한 뒤 공개해요.'
: ' 1장 받고 베팅, 1장 더 받고 베팅한 뒤 남은 사람끼리 패를 공개해요.'}{' '}
모두 다이하고 한 명만 남으면 그 사람이 패를 보여 주지 않고 가져가요.
</p>
<p>
<strong>베팅</strong>: 체크(안 걸고 넘김), 삥(기본 칩만큼), 콜(앞사람만큼), 따당(앞사람이 건 만큼의 두 배), 쿼터·하프{o?.maxRaise === 'full' ? '·풀' : ''}(모인 칩의 4분의 1·절반
{o?.maxRaise === 'full' ? '·전부' : ''}만큼 더), 다이(포기). 한 라운드에 올리기는 한 번뿐이고, 체크나 콜을 한 뒤에는 같은 라운드에서 올릴 수 없어요. 칩이 모자라면 가진 만큼 내고 올인해요.
</p>
<p>
<strong>끗</strong>: 두 장의 월을 더한 값의 일의 자리예요. 7 + 8 = 15 → 5끗, 9 + 10 = 19 → 9끗(갑오), 2 + 8 = 10 → 0끗(망통).
</p>
<JokboTable options={o ?? ({ gwangTtaeng: 'all', ttaengjabi: 'strict', amhaeng: true, gusa: true, mungGusa: 'upTo8', hints: false } as SeotdaOptions)} />
<p>
<strong>재경기</strong>: 구사나 같은 족보{o?.tieRule === 'split' ? '(이 방은 동점이면 나눠 가져요)' : ''}로 재경기가 되면 모인 칩은 그대로 두고 남은 사람끼리 새로 카드를 받아요. 학교는 다시 내지 않아요.
{o && o.rejoinCost !== 'none' ? ` 구사 재경기 때 다이한 사람은 모인 칩의 ${o.rejoinCost === 'half' ? '절반' : '전부'}만큼 내고 다시 들어올 수 있어요.` : ''} 한 판에서 재경기가{' '}
{o?.maxRematches ?? 3}번을 넘으면 나눠 가져요.
</p>
<p>
<strong>선</strong>: 선은 카드를 먼저 받고 먼저 베팅해요. {o?.dealerRule === 'rotate' ? '판마다 다음 사람에게 넘어가요.' : '이긴 사람이 다음 판의 선이 돼요.'}
</p>
<p>
<strong>칩이 떨어지면</strong>:{' '}
{o?.rebuy === 'off' ? '탈락하고 구경해요.' : '칩을 다시 받을지 고를 수 있어요. 다시 받은 만큼 최종 점수에서 빠져요.'}{' '}
{o?.endCondition === 'lastStanding' ? '칩을 가진 사람이 한 명 남으면 끝나요.' : `정해진 판 수가 끝나면 (마지막 칩 − 시작 칩 × 다시 받은 횟수)로 순위를 정해요.`}
</p>
<p>시간 안에 고르지 않으면 걸린 칩이 없을 때는 체크, 있을 때는 다이로 처리돼요. 두 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 5초로 줄어요.</p>
</div>
);
}
const ui: GameUI<SeotdaView, SeotdaOptions> = {
Board: SeotdaBoard,
Side: SeotdaSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -11,4 +11,4 @@ export const GAME_ICON: Record<string, string> = {
}; };
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['섯다', '포커', '윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리']; export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];

View File

@@ -32,6 +32,9 @@
- 공용 카드 컴포넌트(트럼프·화투 SVG 직접 제작), 베팅 엔진(한국식/노리밋), 사이드 팟. - 공용 카드 컴포넌트(트럼프·화투 SVG 직접 제작), 베팅 엔진(한국식/노리밋), 사이드 팟.
- 섯다 전 모드, 포커 전 모드. - 섯다 전 모드, 포커 전 모드.
- 공정성 확인(시드 해시 공개) 기능. - 공정성 확인(시드 해시 공개) 기능.
- **진행 결과(2026-10-05)**: 섯다(2장·3장, 땡잡이·암행어사·구사 재경기, 사이드 팟)와 포커 7개 모드(홀덤·오마하·7포커·하이로우·바둑이·5카드 드로우·인디언)
규칙 + 화면 완료. 규칙 테스트 섯다 54·포커 52·족보 판정 24, 무작위 플레이 1,000판 이상(칩 총량 보존·비공개 카드 유출 없음),
카드 게임 E2E(PC 방장 + 휴대폰 2명, 섯다 1판·7포커 초이스부터 1판), 결과 화면 공정성 확인(SHA-256 씨앗 대조).
## M4. 가족 게임 ## M4. 가족 게임
- 윷놀이, 원카드(+색깔 카드 모드), 숫자 타일(루미큐브 방식), 다빈치 코드, 요트 다이스. - 윷놀이, 원카드(+색깔 카드 모드), 숫자 타일(루미큐브 방식), 다빈치 코드, 요트 다이스.

View File

@@ -17,12 +17,66 @@ mkdirSync(SHOTS, { recursive: true });
/** Per game: key sequences on the grid, in turn order (first mover, second mover, ...). */ /** Per game: key sequences on the grid, in turn order (first mover, second mover, ...). */
interface Plan { interface Plan {
/** Total players including the host (default 2). Extra players join on phones. */
players?: number;
/** Steps run before normal moves (e.g. janggi formation choice). */ /** Steps run before normal moves (e.g. janggi formation choice). */
setup?: (host: Page, guest: Page) => Promise<void>; setup?: (host: Page, guest: Page) => Promise<void>;
moves: string[][]; /** Board games: key sequences on the grid, in turn order. */
moves?: string[][];
/** Card games: custom driver over all pages (host first). */
drive?: (pages: Page[]) => Promise<void>;
}
/** Card games: whoever has the turn presses the first available safe action (check/call/…) until `rounds` actions were taken. */
async function pressActions(pages: Page[], labels: RegExp, rounds: number) {
for (let n = 0; n < rounds; n++) {
let done = false;
for (let t = 0; t < 150 && !done; t++) {
for (const p of pages) {
const btn = p.getByRole('button', { name: labels }).first();
if ((await btn.count()) && (await btn.isEnabled({ timeout: 200 }).catch(() => false))) {
if (await btn.click({ timeout: 1000 }).then(() => true, () => false)) {
done = true;
break;
}
}
}
if (!done) await pages[0]!.waitForTimeout(100);
}
if (!done) throw new Error(`no action button matching ${labels} became available (step ${n + 1})`);
await pages[0]!.waitForTimeout(150);
}
}
/** Waits until every page shows the hand result, then presses "다음 판으로" where it is offered. */
async function finishHand(pages: Page[]) {
for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 });
} }
const PLANS: Record<string, Plan> = { const PLANS: Record<string, Plan> = {
seotda: {
// 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown.
players: 3,
drive: async (pages) => {
await pressActions(pages, /^(체크|콜)/, 6);
await finishHand(pages);
},
},
poker: {
// 3 players, default 7포커: 초이스 (discard 1st card, open 2nd), then everyone checks 4 streets → showdown.
players: 3,
drive: async (pages) => {
for (const p of pages) {
const cards = p.locator('.pk-mine button');
await cards.first().waitFor();
await cards.nth(0).click();
await cards.nth(1).click();
await p.getByRole('button', { name: '확인', exact: true }).click();
}
await pressActions(pages, /^체크/, 12);
await finishHand(pages);
},
},
othello: { othello: {
// cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left // cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left
moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']], moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']],
@@ -87,8 +141,12 @@ for (const id of games) {
} }
const errors: string[] = []; const errors: string[] = [];
const host = await (await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' })).newPage(); const host = await (await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' })).newPage();
const guest = await (await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' })).newPage(); const guests: Page[] = [];
for (const p of [host, guest]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`)); for (let g = 1; g < (plan.players ?? 2); g++) {
guests.push(await (await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' })).newPage());
}
const guest = guests[0]!;
for (const p of [host, ...guests]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`));
try { try {
await host.goto(`${ORIGIN}/g/${id}`); await host.goto(`${ORIGIN}/g/${id}`);
await host.getByRole('button', { name: /친구랑 하기/ }).click(); await host.getByRole('button', { name: /친구랑 하기/ }).click();
@@ -96,20 +154,28 @@ for (const id of games) {
await host.getByRole('button', { name: '이 이름으로 시작하기' }).click(); await host.getByRole('button', { name: '이 이름으로 시작하기' }).click();
await host.waitForURL(/\/r\/\d{6}$/); await host.waitForURL(/\/r\/\d{6}$/);
const code = host.url().slice(-6); const code = host.url().slice(-6);
await guest.goto(`${ORIGIN}/r/${code}`); for (const [gi, g] of guests.entries()) {
await guest.getByLabel('내 이름(닉네임)').fill('친구'); const nick = gi === 0 ? '친구' : `친구${gi + 1}`;
await guest.getByRole('button', { name: '이 이름으로 입장하기' }).click(); await g.goto(`${ORIGIN}/r/${code}`);
await host.getByLabel('자리').getByText('친구').waitFor(); await g.getByLabel('내 이름(닉네임)').fill(nick);
await g.getByRole('button', { name: '이 이름으로 입장하기' }).click();
await host.getByLabel('자리').getByText(nick, { exact: true }).waitFor();
}
await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) }); await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) });
await host.getByRole('button', { name: '▶ 게임 시작' }).click(); await host.getByRole('button', { name: '▶ 게임 시작' }).click();
await host.getByRole('button', { name: '시작하기' }).click(); await host.getByRole('button', { name: '시작하기' }).click();
if (plan.drive) {
await host.locator('.turn-status').waitFor();
await plan.drive([host, ...guests]);
} else {
await host.locator('[role="grid"]').first().waitFor(); await host.locator('[role="grid"]').first().waitFor();
await guest.locator('[role="grid"]').first().waitFor(); await guest.locator('[role="grid"]').first().waitFor();
}
if (plan.setup) await plan.setup(host, guest); if (plan.setup) await plan.setup(host, guest);
const status = async (p: Page) => (await p.locator('.turn-status').innerText()).trim(); const status = async (p: Page) => (await p.locator('.turn-status').innerText()).trim();
// Sides may be random: whoever shows "내 차례예요" plays the next sequence. // Sides may be random: whoever shows "내 차례예요" plays the next sequence.
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
for (const seq of plan.moves) { for (const seq of plan.moves ?? []) {
let mover: Page | null = null; let mover: Page | null = null;
for (let t = 0; t < 100 && !mover; t++) { for (let t = 0; t < 100 && !mover; t++) {
if (await myTurn(host)) mover = host; if (await myTurn(host)) mover = host;

View File

@@ -425,7 +425,8 @@ export function buildPots(players: Pick<Better, 'id' | 'total' | 'status'>[]): P
if (level <= prev) continue; if (level <= prev) continue;
let amount = 0; let amount = 0;
for (const p of players) amount += Math.max(0, Math.min(p.total, level) - prev); for (const p of players) amount += Math.max(0, Math.min(p.total, level) - prev);
const eligible = live.filter((p) => p.total >= level).map((p) => p.id); // Active players can still call up to any level, so they stay eligible mid-round.
const eligible = live.filter((p) => p.status === 'active' || p.total >= level).map((p) => p.id);
if (amount > 0) pots.push({ amount, eligible }); if (amount > 0) pots.push({ amount, eligible });
prev = level; prev = level;
} }

View File

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

View File

@@ -16,6 +16,8 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'chess', nameKo: '체스', minPlayers: 2, maxPlayers: 2, minutes: '10~30', level: '보통', blurb: '상대 킹을 체크메이트하면 승리', available: true }, { id: 'chess', nameKo: '체스', minPlayers: 2, maxPlayers: 2, minutes: '10~30', level: '보통', blurb: '상대 킹을 체크메이트하면 승리', available: true },
{ id: 'janggi', nameKo: '장기', minPlayers: 2, maxPlayers: 2, minutes: '20~40', level: '보통', blurb: '초와 한의 한판 승부, 장군!', available: true }, { id: 'janggi', nameKo: '장기', minPlayers: 2, maxPlayers: 2, minutes: '20~40', level: '보통', blurb: '초와 한의 한판 승부, 장군!', available: true },
{ id: 'baduk', nameKo: '바둑', minPlayers: 2, maxPlayers: 2, minutes: '15~60', level: '어려움', blurb: '집을 더 많이 지은 쪽이 승리', available: true }, { id: 'baduk', nameKo: '바둑', minPlayers: 2, maxPlayers: 2, minutes: '15~60', level: '어려움', blurb: '집을 더 많이 지은 쪽이 승리', available: true },
{ id: 'seotda', nameKo: '섯다', minPlayers: 2, maxPlayers: 10, minutes: '20~40', level: '보통', blurb: '화투 두 장으로 겨루는 족보 승부 · 놀이용 칩', available: true },
{ id: 'poker', nameKo: '포커', minPlayers: 2, maxPlayers: 10, minutes: '20~60', level: '보통', blurb: '홀덤·7포커·인디언 등 7가지 모드 · 놀이용 칩', available: true },
]; ];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id); export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);