diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c9e5970..8a16b97 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -137,6 +137,8 @@ describe('M1 integration scenarios', () => { // Simulate a crash: drop the listener and timers without the graceful bye/cleanup. for (const r of s1.rooms.all()) r.clearTimer(); 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(); const s2 = start(s1.dbPath); const a2 = await client(s2, '', { cookie: a.cookie, id: a.id }); diff --git a/apps/web/src/games/poker/index.tsx b/apps/web/src/games/poker/index.tsx new file mode 100644 index 0000000..1a945dc --- /dev/null +++ b/apps/web/src/games/poker/index.tsx @@ -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 = { + 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 = { + '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> = { + 'k.check': '칩을 걸지 않고 넘겨요', + 'k.ping': '기본 칩만큼 걸어요', + 'k.call': '앞사람만큼 맞춰요', + 'k.ddadang': '앞사람이 건 만큼의 두 배', + 'k.quarter': '모인 칩의 4분의 1만큼 더', + 'k.half': '모인 칩의 절반만큼 더', + 'k.full': '모인 칩만큼 더', + 'k.die': '이번 판을 포기해요', +}; +const SIDE_KO: Record = { high: '하이', low: '로우', swing: '스윙' }; +const AWARD_KO = { high: '하이', low: '로우', scoop: '스윙 성공', carry: '넘어온 칩', penalty: '벌칙 칩', refund: '돌려받음' } as const; + +const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; +const userOf = (players: Record, 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 ( +
+ {cards.map((x, i) => ( + 0 ? { marginLeft: cards.length > 5 ? -24 : -16 } : undefined} + /> + ))} +
+ ); +} + +function SeatBox({ seat, v, players, me, deadline, x, y }: { seat: SeatView; v: PokerView; players: Record; 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 ( +
m[2]).join(', ')}` : ''}${badges.length ? `, ${badges.map((b) => b[0]).join(', ')}` : ''}`} + > +
+ + + {u.nickname} + {seat.id === me && (나)} + + {marks.map(([t, c, title]) => ( + + {t} + + ))} + {waiting && } +
+
+ + {seat.roundBet > 0 && +{seat.roundBet.toLocaleString('ko-KR')}} +
+ {seat.lastAction && !seat.folded && {seat.lastAction}} + {seat.drawCounts.length > 0 && 바꾼 장수: {seat.drawCounts.join(' · ')}} + {badges.length > 0 && ( +
+ {badges.map(([t, c]) => ( + + {t} + + ))} +
+ )} + + {sd && ( + + {sd.handName} + {sd.lowName !== undefined && / 로우 {sd.lowName ?? '없음'}} + + )} +
+ ); +} + +function CenterInfo({ v, players }: { v: PokerView; players: Record }) { + const limit = v.options.endCondition === 'hands' ? `/${v.options.handLimit}` : ''; + const penalty = v.penalties.reduce((a, p) => a + p.amount, 0); + return ( +
+ {v.streetName &&
{v.streetName}
} + {isCommunity(v.mode) && ( +
+ {Array.from({ length: 5 }, (_, i) => (v.board[i] ? : ))} +
+ )} +
모인 칩
+
+ {formatChips(v.pot)} +
+ {v.pots.length > 1 && ( +
+ {v.pots.map((p, i) => ( +
+ {i === 0 ? '메인' : `사이드 ${i}`}: {formatChips(p.amount)} ({p.eligible.map((id) => nick(players, id)).join('·')}) +
+ ))} +
+ )} + {(v.carryOver > 0 || penalty > 0) && ( +
+ {v.carryOver > 0 && `지난 판에서 넘어온 칩 ${formatChips(v.carryOver)}`} + {v.carryOver > 0 && penalty > 0 && ' · '} + {penalty > 0 && `벌칙 칩 ${formatChips(penalty)}`} +
+ )} +
+ {v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}${limit}판`} · 기본 {formatChips(v.baseUnit)} + {v.level > 1 ? ` (레벨 ${v.level})` : ''} +
+
+ ); +} + +// ---------------------------------------------------------------- board + +function PokerBoard({ view: v, me, players, act, pending, deadline }: BoardProps) { + 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 ( +
+ +
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 ; + })} + +
+ +

+ {announce} +

+
+ ); +} + +function resultLine(v: PokerView, players: Record): string { + if (!v.awards) return ''; + const won = new Map(); + 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 }) { + if (!v.awards) return null; + return ( +
+ 이번 판 결과 + {v.foldWin && 한 명만 남아서 패는 공개하지 않아요.} + {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 ( + + {nick(players, a.to)} {formatChips(a.amount)} + {hand && ` · ${hand}`} + + {v.awards!.some((x) => x.potIndex > 0) ? ` · ${a.potIndex === 0 ? '메인' : `사이드 ${a.potIndex}`}` : ''} + {a.side ? ` · ${AWARD_KO[a.side]}` : ''} + + + ); + })} + {v.awards.every((a) => a.side === 'refund') && v.carryOver > 0 && 동점이라 {formatChips(v.carryOver)}이 다음 판으로 넘어가요.} +
+ ); +} + +// ---------------------------------------------------------------- my area + +interface AreaProps { + v: PokerView; + me: string | null; + players: Record; + 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' ? : null; + const m = v.my; + return ( +
+ {seat.inHand && ( +
+ {m.canChoose && m.choice ? ( + + ) : m.canDraw ? ( + !!c)} act={act} pending={pending} /> + ) : ( + + )} + {(m.handName || m.lowName) && ( +
+ 지금: {m.handName} + {m.lowName && 로우 {m.lowName}} +
+ )} + {seat.folded &&

이번 판은 포기했어요. 내 카드는 나만 볼 수 있어요.

} + {v.handPhase === 'choice' && seat.choiceDone &&

골랐어요. 다른 사람을 기다리는 중이에요.

} + {v.handPhase === 'draw' && seat.drawDone &&

바꿀 카드를 냈어요. 다른 사람을 기다리는 중이에요.

} + {m.canDeclare && } + {v.handPhase === 'declare' && seat.declared && !m.canDeclare && ( +

+ {m.declare ? `${SIDE_KO[m.declare]}(으)로 선언했어요. ` : ''}다른 사람을 기다리는 중이에요. +

+ )} +
+ )} + {seat.inHand && v.handPhase === 'betting' && !seat.folded && !seat.allIn && } + {v.handPhase === 'handEnd' && } + {m.canReady && ( + + )} + {v.handPhase === 'handEnd' && seat.inHand && seat.ready && seat.status !== 'left' &&

다른 사람을 기다리는 중…

} +
+ ); +} + +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 ( +
+ {seat.forehead !== undefined && ( +
+ + 이마 +
+ )} + {seat.hole.map((c, i) => ( +
+ + {(isSevenMode(v.mode) || v.mode === 'indian') && {v.mode === 'indian' ? '손' : '나만 봄'}} +
+ ))} + {seat.up.map((c, i) => ( +
+ + 공개 +
+ ))} +
+ ); +} + +/** 7포커 초이스: 1) 버릴 카드 2) 보여 줄 카드 → 확인 (§9). */ +function ChoicePicker({ cards, act, pending }: { cards: string[]; act: (a: unknown) => void; pending: boolean }) { + const [discard, setDiscard] = useState(null); + const [open, setOpen] = useState(null); + const tap = (c: string) => { + if (!discard) setDiscard(c); + else if (c === discard) { + setDiscard(null); + setOpen(null); + } else setOpen(open === c ? null : c); + }; + return ( +
+

+ {!discard ? '1단계: 버릴 카드를 누르세요' : !open ? '2단계: 모두에게 보여 줄 카드를 누르세요' : '확인을 누르면 정해져요'} +
+ 버린 카드는 아무에게도 보이지 않아요. 남는 2장은 나만 봐요. +

+
+ {cards.map((c) => ( + + tap(c)} title={c === discard ? '버릴 카드(다시 누르면 취소)' : c === open ? '보여 줄 카드' : undefined} /> + + ))} +
+
+ + +
+
+ ); +} + +/** 교환(바둑이 / 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([]); + 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 ( +
+

+ {label}: 바꿀 카드를 누르세요 +
+ + {drawAceBonus ? '3장까지 바꿀 수 있어요(A를 남기면 4장).' : `${drawMax}장까지 바꿀 수 있어요.`} 모두 동시에 내고, 버린 카드는 공개되지 않아요. + +

+
+ {hole.map((c) => ( + setSel((s) => (s.includes(c) ? s.filter((x) => x !== c) : [...s, c]))} /> + ))} +
+ {tooMany &&

{limit}장까지만 바꿀 수 있어요.

} + +
+ ); +} + +function DeclarePicker({ v, act, pending }: { v: PokerView; act: (a: unknown) => void; pending: boolean }) { + const sub: Record = { + high: v.my.handName ?? '', + low: v.my.lowName ?? '로우 없음', + swing: '하이·로우 둘 다 이겨야 해요', + }; + return ( +
+

+ 하이·로우·스윙 중 하나를 선언하세요. 모두 고른 뒤 한꺼번에 공개돼요. +

+
+ {(['high', 'low', 'swing'] as Side[]).map((side) => ( + + ))} +
+
+ ); +} + +function BetBar({ v, me, players, act, pending }: AreaProps & { me: string }) { + const myTurn = v.toAct === me; + const legal = new Map(v.my.legal.map((l) => [l.type, l])); + const [hover, setHover] = useState(null); + const [confirmFold, setConfirmFold] = useState(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 ( +
+ {raising && raise ? ( + setRaising(false)} /> + ) : ( +
+ {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 ( + + ); + })} +
+ )} +

+ {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)}님 차례예요.` + : ''} +

+ {confirmFold && ( + { + act({ type: confirmFold }); + setConfirmFold(null); + }} + onClose={() => setConfirmFold(null)} + /> + )} +
+ ); +} + +/** 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 ( +
+
+ + {v.currentBet === 0 ? '벳' : '레이즈'}: 이번 라운드 총 {formatChips(to)} + + + 지금 낼 칩 +{formatChips(add)} + {allIn ? ' (올인)' : ''} + +
+
+ + setTo(clamp(Number(e.target.value)))} aria-label="올릴 칩" style={{ flex: 1 }} /> + +
+
+ {quick.map(([label, x]) => ( + + ))} +
+
+ + +
+
+ ); +} + +// ---------------------------------------------------------------- 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 }) { + if (mode === 'badugi') { + return ( +
+ 낮을수록 좋은 로우 게임이에요. A가 가장 낮아요. + + 4장 중 숫자도 무늬도 겹치지 않는 카드가 많을수록 셉니다: 메이드(4장) > 베이스(3장) > 투베이스(2장) > 노베이스. + + 같은 등급이면 가장 높은 카드부터 비교해 낮은 쪽이 이겨요. 골프(A-2-3-4)가 최강, 세컨드(A-2-3-5), 써드(A-2-4-5). +
+ ); + } + if (mode === 'indian') { + return ( +
+ 내 이마 카드는 나만 못 보고, 다른 사람 카드는 다 보여요. + {o.indianDeck === 'd20' ? '10이 가장 높고 A(1)가 가장 낮아요.' : 'A가 가장 높고 2가 가장 낮아요.'} 무늬는 보지 않아요. + {o.indianCards === 2 && 2장일 때: 페어 > 노페어. 노페어끼리는 높은 카드 → 낮은 카드 순으로 비교해요.} +
+ ); + } + const korean = isSevenMode(mode); + return ( +
+
    + {(korean ? RANKS_KOREAN : RANKS_INTL).map((r) => ( +
  1. {r}
  2. + ))} +
+ {korean && 같은 족보면 숫자, 그다음 무늬(♠ > ◆ > ♥ > ♣)로 정해요.} + {mode === 'sevenHiLo' && ( + + 로우: 서로 다른 숫자 5장, 낮을수록 좋음({o.hiLoLow === 'korean' ? '스트레이트·플러시 불인정, 최고 A-2-3-4-6' : '최고 A-2-3-4-5'} + {o.hiLoQualifier === 'eight' ? ', 8 이하만 인정' : ''}). + + )} + {!korean && 5장이 같은 서열이면 나눠 가져요(무늬는 보지 않아요).} +
+ ); +} + +function PokerSide({ view: v, me, players, act, pending, deadline }: BoardProps) { + 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 ( + <> + + +
+
+ + {MODE_KO[v.mode]} · {v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}판${v.options.endCondition === 'hands' ? ` / ${v.options.handLimit}판` : ''}`} + + {v.phase !== 'finished' && v.waiting.length > 0 && } +
+ + {v.betStyle === 'korean' ? `한국식 베팅(${v.options.koreanMaxRaise === 'full' ? '풀까지' : '하프까지'})` : v.betStyle === 'potLimit' ? '팟리밋' : '노리밋'} · 기본 {formatChips(v.baseUnit)} + +
    + {ranked.map((s) => ( +
  1. + + + {nick(players, s.id)} + {s.rebuys > 0 ? ` · 리바이 ${s.rebuys}` : ''} + {s.status === 'eliminated' ? ' · 탈락' : s.status === 'left' ? ' · 나감' : s.away ? ' · 자리 비움' : ''} + + +
  2. + ))} +
+
+ {v.result && ( +
+ 최종 순위 + {v.result.ranking.map((g, i) => { + const sc = v.result!.scores[g[0]!] ?? 0; + return ( + + {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')} 칩)` : ''} + + ); + })} + {scoreBy && 점수 = 마지막 칩 − 시작 칩 × 리바이 횟수} + {v.stats.bestHand && ( + + 최고 족보: {nick(players, v.stats.bestHand.playerId)} · {v.stats.bestHand.handName} ({v.stats.bestHand.handNo}판) + + )} + {v.stats.biggestPot && 가장 큰 판: {formatChips(v.stats.biggestPot.amount)} ({v.stats.biggestPot.handNo}판)} +
+ )} +
+ 족보 + +
+ {v.mode === 'indian' && v.indianUsed.length > 0 && ( +
+ 이미 나온 카드 ({v.indianUsed.length}장) +
+ {v.indianUsed.map((c, i) => ( + + ))} +
+
+ )} + {v.log.length > 0 && ( +
+ 기록 +
    + {[...v.log].reverse().map((e, i) => ( +
  1. + {e.hand}판 {e.by ? `${nick(players, e.by)}: ` : ''} + {e.msg} +
  2. + ))} +
+
+ )} + {v.my.canRebuy && !confirmQuit && ( + {}} label="칩 다시 받기"> +

칩을 모두 잃었어요

+

+ 놀이용 칩 {formatChips(v.options.startingChips)}을 다시 받고 계속할 수 있어요{v.my.rebuysLeft !== null ? ` (남은 횟수 ${v.my.rebuysLeft}번)` : ''}. 점수에서는 다시 받은 만큼 빠져요. +

+
+ + +
+
+ )} + {confirmQuit && v.my.canRebuy && ( + 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 { + 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({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => 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 ( +
+ + [m, MODE_KO[m]])} onChange={(mode) => set({ mode })} /> + 이 모드는 2~{max}명이 할 수 있어요. + + + set({ betting })} + /> + + + set({ startingChips })} /> + + + set({ baseUnit })} /> + {tooBig && 기본 칩은 시작 칩의 20분의 1 이하로 골라 주세요.} + + + set({ endCondition })} /> + {o.endCondition === 'hands' && set({ handLimit })} />} + + + set({ turnSeconds })} /> + + {o.mode === 'indian' && ( + + set({ indianCards })} /> + set({ indianDeck })} /> + + )} + + {more && ( + <> + + set({ koreanMaxRaise })} /> + + + set({ rebuy })} /> + {o.rebuy === 'limited' && set({ rebuyLimit })} />} + + + set({ blindUpEvery })} /> + + + set({ dealerRule })} /> + + + set({ hints: k === 'on' })} /> + + {o.mode === 'sevenHiLo' && ( + + set({ hiLoLow })} /> + set({ hiLoQualifier })} /> + + )} + {o.mode === 'fiveDraw' && ( + + set({ drawLimit })} /> + + )} + {o.mode === 'indian' && ( + <> + + set({ indianFoldPenalty: k === 'on' })} /> + {o.indianFoldPenalty && set({ indianPenaltyUnits })} />} + + + set({ indianTie })} /> + + + set({ indianReshuffle })} /> + + + )} + + set({ handEndSeconds })} /> + + + )} +
+ ); +} + +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 = { + 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 ( +
+

+ {MODE_KO[mode]}: {MODE_RULES[mode]} 칩은 이 방에서만 쓰는 놀이용 점수예요. +

+

+ 베팅:{' '} + {style === 'korean' + ? '체크(안 걸고 넘김), 삥(기본 칩만큼), 콜(앞사람만큼), 따당(앞사람이 건 만큼의 두 배), 쿼터·하프·풀(모인 칩의 4분의 1·절반·전부만큼 더), 다이(포기). 한 라운드에 올리기는 한 번뿐이고, 체크나 콜을 한 뒤에는 같은 라운드에서 올릴 수 없어요.' + : `폴드(포기), 체크, 콜, 레이즈, 올인. 레이즈는 직전 레이즈만큼 이상 올려야 해요.${style === 'potLimit' ? ' 팟리밋은 모인 칩만큼까지만 올릴 수 있어요.' : ''}`}{' '} + 칩이 모자라면 가진 만큼 내고 올인하며, 그 이상 걸린 칩은 따로(사이드 팟) 겨뤄요. +

+

+ 족보 +

+ + {mode === 'indian' && o?.indianFoldPenalty !== false &&

가장 높은 카드를 이마에 단 채 다이하면 벌칙 칩을 내요. 벌칙 칩은 그 판 승자가 가져가요.

} +

+ 칩이 떨어지면:{' '} + {o?.rebuy === 'off' ? '탈락하고 구경해요.' : '칩을 다시 받을지 고를 수 있어요. 다시 받은 만큼 최종 점수에서 빠져요.'}{' '} + {o?.endCondition === 'lastStanding' ? '칩을 가진 사람이 한 명 남으면 끝나요.' : '정해진 판 수가 끝나면 (마지막 칩 − 시작 칩 × 다시 받은 횟수)로 순위를 정해요.'} +

+

시간 안에 고르지 않으면 걸린 칩이 없을 때는 체크, 있을 때는 포기로 처리돼요. 두 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 짧아져요.

+
+ ); +} + +const ui: GameUI = { + Board: PokerBoard, + Side: PokerSide, + statusText, + isMyTurn, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index ebae902..67eb707 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -7,6 +7,8 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { chess: () => import('./chess'), janggi: () => import('./janggi'), baduk: () => import('./baduk'), + seotda: () => import('./seotda'), + poker: () => import('./poker'), }; const cache = new Map(); diff --git a/apps/web/src/games/seotda/index.tsx b/apps/web/src/games/seotda/index.tsx new file mode 100644 index 0000000..e5566de --- /dev/null +++ b/apps/web/src/games/seotda/index.tsx @@ -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 = { check: '체크', ping: '삥', call: '콜', ddadang: '따당', quarter: '쿼터', half: '하프', full: '풀', die: '다이' }; +const BET_HELP: Record = { + 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 = { + normal: '', + ttaengjabi: '땡잡이', + amhaeng: '암행어사', + gusa: '구사 재경기', + mungGusa: '멍텅구리구사 재경기', + tie: '동점', + limit: '재경기 횟수 초과로 나눔', + fold: '모두 다이', +}; + +const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; +const userOf = (players: Record, 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; 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 ( +
b[0]).join(', ')}` : ''}`} + > +
+ + + {u.nickname} + {seat.id === me && (나)} + + {seat.isDealer && ( + + 선 + + )} + {timed && } +
+
+
+ + {((seat.lastAction && !seat.folded) || seat.roundBet > 0) && ( + + {seat.lastAction && !seat.folded && {BET_KO[seat.lastAction.type]} } + {seat.roundBet > 0 && `+${seat.roundBet.toLocaleString('ko-KR')}`} + + )} +
+ {badges.map(([t, c]) => ( + + {t} + + ))} +
+
+ {seat.cards.length > 0 && ( +
+ {seat.cards.map((c, i) => ( + 0 ? { marginLeft: -14 } : undefined} /> + ))} +
+ )} +
+ {seat.handName && {seat.handName}} +
+ ); +} + +function CenterInfo({ view: v, players }: { view: SeotdaView; players: Record }) { + 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 ( +
+ {v.rematchNo > 0 && v.rematchReason && ( +
+ 재경기 {v.rematchNo}회차 · {REMATCH_KO[v.rematchReason]} +
+ )} +
모인 칩
+
+ {formatChips(v.pot + paid)} +
+ {v.pots.length > 1 && ( +
+ {v.pots.map((p, i) => ( +
+ {i === v.pots.length - 1 ? '메인' : `사이드 ${i + 1}`}: {formatChips(p.amount)} ({p.eligible.map((id) => nick(players, id)).join('·')}) +
+ ))} +
+ )} +
+ {v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}${limit}판`} · 학교 {formatChips(v.baseUnit)} + {v.currentBet > 0 && v.handPhase === 'betting' ? ` · 이번 라운드 ${formatChips(v.currentBet)}` : ''} +
+
+ ); +} + +function SeotdaBoard({ view: v, me, players, act, pending, deadline }: BoardProps) { + const n = v.seats.length; + const myIdx = Math.max(0, v.seats.findIndex((s) => s.id === me)); + const [flash, setFlash] = useState(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 ( +
+ +
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 ; + })} + + {flash &&
{flash}
} +
+ +

+ {announce} +

+
+ ); +} + +function resultLine(v: SeotdaView, players: Record): 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; act: (a: unknown) => void; pending: boolean }) { + const seat = v.seats.find((s) => s.id === me); + const [pick, setPick] = useState([]); + const [hover, setHover] = useState(null); + useEffect(() => setPick([]), [v.handNo, v.rematchNo, v.handPhase]); + if (!me || !seat) return v.handPhase === 'handEnd' ? : 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 ( +
+ {showHand && ( +
+
+ {myCards.map((c) => { + const chosen = v.myChosen?.includes(c.id); + return ( + toggle(c.id) : undefined} + /> + ); + })} + {v.myHand && ( +
+ + 지금: {v.myHand.name} + {v.myHand.strength && ({STRENGTH_KO[v.myHand.strength]})} + + {v.myHand.hint} +
+ )} +
+ {seat.folded &&

이번 판은 다이했어요. 내 패는 나만 볼 수 있어요.

} + {choosing && ( +
+

+ 쓸 카드 2장을 골라 주세요. 남는 1장은 아무에게도 보이지 않아요. + {preview && ( + <> + {' '} + 고른 조합: {preview.name} + + )} +

+
+ {v.options.hints && v.myCombos && ( + + )} + +
+
+ )} + {v.handPhase === 'choose' && seat.chosen === true &&

골랐어요. 다른 사람을 기다리는 중이에요.

} +
+ )} + {seat.inHand && v.handPhase === 'betting' && !seat.folded && ( +
+
+ {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 ( + + ); + })} +
+

+ {myTurn + ? hoverBet + ? `${BET_KO[hover!]}: ${BET_HELP[hover!]}. 내고 나면 내 칩 ${formatChips(seat.stack - hoverBet.amount)}, 모인 칩 ${formatChips(v.pot + hoverBet.amount)}` + : '내 차례예요. 버튼에 적힌 칩이 바로 나가요.' + : v.toAct + ? `${nick(players, v.toAct)}님 차례예요.` + : ''} +

+
+ )} + {v.handPhase === 'handEnd' && } + {v.handPhase === 'handEnd' && seat.status === 'playing' && !seat.left && ( + + )} +
+ ); +} + +function ResultPanel({ view: v, players }: { view: SeotdaView; players: Record }) { + return ( +
+ {v.next === 'rematch' ? '재경기가 결정됐어요' : '이번 판 결과'} + {v.foldWin && ( + + {nick(players, v.foldWin.winner)}님이 {formatChips(v.foldWin.amount)}을 가져갔어요. 모두 다이해서 패는 공개하지 않아요. + + )} + {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 ( + + {v.showdown!.pots.length > 1 ? `${i === v.showdown!.pots.length - 1 ? '메인' : '사이드'} ${formatChips(p.amount)}: ` : `${formatChips(p.amount)}: `} + {p.type === 'win' && ( + <> + {who} 승리 ({name} + {p.reason === 'ttaengjabi' || p.reason === 'amhaeng' ? ' 발동' : ''}) + + )} + {p.type === 'split' && ( + <> + {who} 나눠 가짐{p.reason === 'limit' ? ' (재경기 횟수 초과)' : ' (같은 족보)'} + + )} + {p.type === 'rematch' && ( + <> + {REASON_KO[p.reason]} — {who}님이 다시 겨뤄요. 칩은 그대로 남아요. + + )} + + ); + })} +
+ ); +} + +// ---------------------------------------------------------------- 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 ( + + + {rows.map((r) => { + const hit = !!mine && options.hints && r.match(mine.value, mine.special); + return ( + + + + + ); + })} + +
+ {r.cards.map(([m, sp], i) => ( + + ))} + + {r.name} + {hit && 내 패} + {r.note &&
{r.note}
} +
+ ); +} + +function SeotdaSide({ view: v, me, players, act, pending, deadline }: BoardProps) { + const [confirmQuit, setConfirmQuit] = useState(false); + const ranked = [...v.seats].sort((a, b) => b.stack - a.stack); + return ( + <> + + +
+
+ + {v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}판${v.options.endCondition === 'hands' ? ` / ${v.options.handLimit}판` : ''}`} + {v.rematchNo > 0 ? ` · 재경기 ${v.rematchNo}회차` : ''} + + {v.phase !== 'finished' && } +
+ + 학교 {formatChips(v.baseUnit)} · {v.options.cardMode === 'three' ? '3장 섯다' : '2장 섯다'} · {v.options.maxRaise === 'full' ? '풀까지' : '하프까지'} + +
    + {ranked.map((s) => ( +
  1. + + + {nick(players, s.id)} + {s.isDealer ? ' · 선' : ''} + {s.rebuys > 0 ? ` · 리바이 ${s.rebuys}` : ''} + {s.status === 'eliminated' ? ' · 탈락' : s.left ? ' · 나감' : s.away ? ' · 자리 비움' : ''} + + +
  2. + ))} +
+
+ {v.result && ( +
+ 최종 순위 + {v.result.ranking.map((g, 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')} 칩)` + : ''} + + ))} + 점수 = 마지막 칩 − 시작 칩 × 리바이 횟수 +
+ )} +
+ 족보표 + +
+ {v.history.length > 0 && ( +
+ 판 기록 +
    + {[...v.history].reverse().map((r, i) => ( +
  1. + + {r.handNo}판{r.rematchNo ? `(재${r.rematchNo})` : ''} + {' '} + {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]}` : ''}`} +
  2. + ))} +
+
+ )} + {v.rejoin?.mine && ( + {}} label="재경기 참가"> +

구사 재경기에 다시 들어갈까요?

+

+ {formatChips(v.rejoin.cost)}을 모인 칩에 더 내면 재경기에 참가할 수 있어요. 시간이 지나면 참가하지 않아요. +

+
+ + +
+
+ )} + {v.rebuy?.mine && !confirmQuit && ( + {}} label="칩 다시 받기"> +

칩을 모두 잃었어요

+

+ 놀이용 칩 {formatChips(v.options.startingChips)}을 다시 받고 계속할 수 있어요{v.rebuy.left !== null ? ` (남은 횟수 ${v.rebuy.left}번)` : ''}. 점수에서는 다시 받은 만큼 빠져요. +

+
+ + +
+
+ )} + {confirmQuit && v.rebuy?.mine && ( + 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 { + 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({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + const tooBig = o.baseUnit * 20 > o.startingChips; + return ( +
+ + set({ cardMode })} /> + + {o.cardMode === 'two' && ( + + set({ dealMode })} /> + + )} + + set({ startingChips })} /> + + + set({ baseUnit })} /> + {tooBig && 학교는 시작 칩의 20분의 1 이하로 골라 주세요.} + + + set({ endCondition })} /> + {o.endCondition === 'hands' && ( + set({ handLimit })} /> + )} + + + set({ turnSeconds })} /> + + + {more && ( + <> + + set({ maxRaise })} /> + + + set({ rebuy })} /> + {o.rebuy === 'limited' && set({ rebuyLimit })} />} + + + set({ schoolUpEvery })} /> + + + set({ dealerRule })} /> + + + set({ gwangTtaeng })} /> + + + set({ ttaengjabi })} /> + + + set({ amhaeng: k === 'on' })} /> + set({ gusa: k === 'on' })} /> + + {o.gusa && ( + <> + + set({ mungGusa })} /> + + + set({ rejoinCost })} /> + + + )} + + set({ tieRule })} /> + + + set({ maxRematches })} /> + + + set({ decisionSeconds })} /> + + + set({ hints: k === 'on' })} /> + + + )} +
+ ); +} + +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 ( +
+

+ 화투 20장(1~10월 각 2장)으로 하는 족보 대결이에요. 각자 받은 2장의 조합(족보)이 가장 센 사람이 모인 칩을 가져가요. 칩은 이 방에서만 쓰는 놀이용 점수예요. +

+

+ 진행: 판마다 모두 학교(기본 칩)를 내요. 선부터 카드를 받고 차례로 베팅해요. + {o?.cardMode === 'three' + ? ' 3장 섯다는 2장을 받고 베팅 → 1장을 더 받아 3장 중 쓸 2장을 동시에 몰래 고르고 → 마지막 베팅 후 공개해요. 버린 1장은 끝까지 아무도 못 봐요.' + : o?.dealMode === 'oneRound' + ? ' 2장을 한 번에 받고 한 번 베팅한 뒤 공개해요.' + : ' 1장 받고 베팅, 1장 더 받고 베팅한 뒤 남은 사람끼리 패를 공개해요.'}{' '} + 모두 다이하고 한 명만 남으면 그 사람이 패를 보여 주지 않고 가져가요. +

+

+ 베팅: 체크(안 걸고 넘김), 삥(기본 칩만큼), 콜(앞사람만큼), 따당(앞사람이 건 만큼의 두 배), 쿼터·하프{o?.maxRaise === 'full' ? '·풀' : ''}(모인 칩의 4분의 1·절반 + {o?.maxRaise === 'full' ? '·전부' : ''}만큼 더), 다이(포기). 한 라운드에 올리기는 한 번뿐이고, 체크나 콜을 한 뒤에는 같은 라운드에서 올릴 수 없어요. 칩이 모자라면 가진 만큼 내고 올인해요. +

+

+ 끗: 두 장의 월을 더한 값의 일의 자리예요. 7 + 8 = 15 → 5끗, 9 + 10 = 19 → 9끗(갑오), 2 + 8 = 10 → 0끗(망통). +

+ +

+ 재경기: 구사나 같은 족보{o?.tieRule === 'split' ? '(이 방은 동점이면 나눠 가져요)' : ''}로 재경기가 되면 모인 칩은 그대로 두고 남은 사람끼리 새로 카드를 받아요. 학교는 다시 내지 않아요. + {o && o.rejoinCost !== 'none' ? ` 구사 재경기 때 다이한 사람은 모인 칩의 ${o.rejoinCost === 'half' ? '절반' : '전부'}만큼 내고 다시 들어올 수 있어요.` : ''} 한 판에서 재경기가{' '} + {o?.maxRematches ?? 3}번을 넘으면 나눠 가져요. +

+

+ 선: 선은 카드를 먼저 받고 먼저 베팅해요. {o?.dealerRule === 'rotate' ? '판마다 다음 사람에게 넘어가요.' : '이긴 사람이 다음 판의 선이 돼요.'} +

+

+ 칩이 떨어지면:{' '} + {o?.rebuy === 'off' ? '탈락하고 구경해요.' : '칩을 다시 받을지 고를 수 있어요. 다시 받은 만큼 최종 점수에서 빠져요.'}{' '} + {o?.endCondition === 'lastStanding' ? '칩을 가진 사람이 한 명 남으면 끝나요.' : `정해진 판 수가 끝나면 (마지막 칩 − 시작 칩 × 다시 받은 횟수)로 순위를 정해요.`} +

+

시간 안에 고르지 않으면 걸린 칩이 없을 때는 체크, 있을 때는 다이로 처리돼요. 두 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 5초로 줄어요.

+
+ ); +} + +const ui: GameUI = { + Board: SeotdaBoard, + Side: SeotdaSide, + statusText, + isMyTurn, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 71648ac..6d4e91a 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -11,4 +11,4 @@ export const GAME_ICON: Record = { }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['섯다', '포커', '윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리']; +export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리']; diff --git a/docs/12-roadmap.md b/docs/12-roadmap.md index d0f819c..a3d40ae 100644 --- a/docs/12-roadmap.md +++ b/docs/12-roadmap.md @@ -32,6 +32,9 @@ - 공용 카드 컴포넌트(트럼프·화투 SVG 직접 제작), 베팅 엔진(한국식/노리밋), 사이드 팟. - 섯다 전 모드, 포커 전 모드. - 공정성 확인(시드 해시 공개) 기능. +- **진행 결과(2026-10-05)**: 섯다(2장·3장, 땡잡이·암행어사·구사 재경기, 사이드 팟)와 포커 7개 모드(홀덤·오마하·7포커·하이로우·바둑이·5카드 드로우·인디언) + 규칙 + 화면 완료. 규칙 테스트 섯다 54·포커 52·족보 판정 24, 무작위 플레이 1,000판 이상(칩 총량 보존·비공개 카드 유출 없음), + 카드 게임 E2E(PC 방장 + 휴대폰 2명, 섯다 1판·7포커 초이스부터 1판), 결과 화면 공정성 확인(SHA-256 씨앗 대조). ## M4. 가족 게임 - 윷놀이, 원카드(+색깔 카드 모드), 숫자 타일(루미큐브 방식), 다빈치 코드, 요트 다이스. diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 085c1aa..8d6700d 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -17,12 +17,66 @@ mkdirSync(SHOTS, { recursive: true }); /** Per game: key sequences on the grid, in turn order (first mover, second mover, ...). */ 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). */ setup?: (host: Page, guest: Page) => Promise; - 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; +} + +/** 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 = { + 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: { // cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']], @@ -87,8 +141,12 @@ for (const id of games) { } const errors: string[] = []; 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(); - for (const p of [host, guest]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`)); + const guests: Page[] = []; + 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 { await host.goto(`${ORIGIN}/g/${id}`); await host.getByRole('button', { name: /친구랑 하기/ }).click(); @@ -96,20 +154,28 @@ for (const id of games) { await host.getByRole('button', { name: '이 이름으로 시작하기' }).click(); await host.waitForURL(/\/r\/\d{6}$/); const code = host.url().slice(-6); - await guest.goto(`${ORIGIN}/r/${code}`); - await guest.getByLabel('내 이름(닉네임)').fill('친구'); - await guest.getByRole('button', { name: '이 이름으로 입장하기' }).click(); - await host.getByLabel('자리').getByText('친구').waitFor(); + for (const [gi, g] of guests.entries()) { + const nick = gi === 0 ? '친구' : `친구${gi + 1}`; + await g.goto(`${ORIGIN}/r/${code}`); + 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.getByRole('button', { name: '▶ 게임 시작' }).click(); await host.getByRole('button', { name: '시작하기' }).click(); - await host.locator('[role="grid"]').first().waitFor(); - await guest.locator('[role="grid"]').first().waitFor(); + if (plan.drive) { + await host.locator('.turn-status').waitFor(); + await plan.drive([host, ...guests]); + } else { + await host.locator('[role="grid"]').first().waitFor(); + await guest.locator('[role="grid"]').first().waitFor(); + } if (plan.setup) await plan.setup(host, guest); const status = async (p: Page) => (await p.locator('.turn-status').innerText()).trim(); // Sides may be random: whoever shows "내 차례예요" plays the next sequence. 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; for (let t = 0; t < 100 && !mover; t++) { if (await myTurn(host)) mover = host; diff --git a/packages/engine/src/betting.ts b/packages/engine/src/betting.ts index c0daa47..279a64a 100644 --- a/packages/engine/src/betting.ts +++ b/packages/engine/src/betting.ts @@ -425,7 +425,8 @@ export function buildPots(players: Pick[]): P if (level <= prev) continue; let amount = 0; 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 }); prev = level; } diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index eb4fa14..6afc773 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -4,6 +4,8 @@ import { othello } from './othello'; import { chess } from './chess'; import { janggi } from './janggi'; import { baduk } from './baduk'; +import { seotda } from './seotda'; +import { poker } from './poker'; export const GAMES: Record = { [omok.id]: omok, @@ -11,4 +13,6 @@ export const GAMES: Record = { [chess.id]: chess, [janggi.id]: janggi, [baduk.id]: baduk, + [seotda.id]: seotda, + [poker.id]: poker, }; diff --git a/packages/games/src/poker/cards.ts b/packages/games/src/poker/cards.ts new file mode 100644 index 0000000..b30dab4 --- /dev/null +++ b/packages/games/src/poker/cards.ts @@ -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 = { 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(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(); + 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]); +} diff --git a/packages/games/src/poker/eval/badugi.ts b/packages/games/src/poker/eval/badugi.ts new file mode 100644 index 0000000..90da760 --- /dev/null +++ b/packages/games/src/poker/eval/badugi.ts @@ -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 = { '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 }; +} diff --git a/packages/games/src/poker/eval/eval.test.ts b/packages/games/src/poker/eval/eval.test.ts new file mode 100644 index 0000000..0cd2ec5 --- /dev/null +++ b/packages/games/src/poker/eval/eval.test.ts @@ -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); + }); +}); diff --git a/packages/games/src/poker/eval/indian.ts b/packages/games/src/poker/eval/indian.ts new file mode 100644 index 0000000..4e1638f --- /dev/null +++ b/packages/games/src/poker/eval/indian.ts @@ -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 }; +} diff --git a/packages/games/src/poker/eval/international.ts b/packages/games/src/poker/eval/international.ts new file mode 100644 index 0000000..f90e1f2 --- /dev/null +++ b/packages/games/src/poker/eval/international.ts @@ -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); +} diff --git a/packages/games/src/poker/eval/korean.ts b/packages/games/src/poker/eval/korean.ts new file mode 100644 index 0000000..102a1f2 --- /dev/null +++ b/packages/games/src/poker/eval/korean.ts @@ -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; +} diff --git a/packages/games/src/poker/eval/low.ts b/packages/games/src/poker/eval/low.ts new file mode 100644 index 0000000..7f9c6b9 --- /dev/null +++ b/packages/games/src/poker/eval/low.ts @@ -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)); +} diff --git a/packages/games/src/poker/hand.ts b/packages/games/src/poker/hand.ts new file mode 100644 index 0000000..2efe0a9 --- /dev/null +++ b/packages/games/src/poker/hand.ts @@ -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(); + 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 = {}; + 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 = {}; + 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 = {}; + 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 = { high: '하이', low: '로우', swing: '스윙' }; + +function deal(s: PokerState, ctx: Ctx, st: Extract): 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 = { + 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 = {}; + 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 = {}; + 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 = {}; + 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 = (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 = {}; + 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; +} diff --git a/packages/games/src/poker/index.ts b/packages/games/src/poker/index.ts new file mode 100644 index 0000000..867688b --- /dev/null +++ b/packages/games/src/poker/index.ts @@ -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 = { + '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 = { + id: 'poker', + nameKo: '포커', + minPlayers: 2, + maxPlayers: 10, + stateVersion: 1, + defaultOptions: pokerOptionsSchema.parse({}), + optionsSchema: pokerOptionsSchema, + actionSchema: pokerActionSchema as z.ZodType, + 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; diff --git a/packages/games/src/poker/modes.ts b/packages/games/src/poker/modes.ts new file mode 100644 index 0000000..39bdb04 --- /dev/null +++ b/packages/games/src/poker/modes.ts @@ -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): 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 = { + 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'; diff --git a/packages/games/src/poker/options.ts b/packages/games/src/poker/options.ts new file mode 100644 index 0000000..93ed9a8 --- /dev/null +++ b/packages/games/src/poker/options.ts @@ -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 = { + 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; + +/** §2.1 player range per mode. */ +export function playerRange(o: Pick): { 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): 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'; diff --git a/packages/games/src/poker/poker.test.ts b/packages/games/src/poker/poker.test.ts new file mode 100644 index 0000000..a3221d8 --- /dev/null +++ b/packages/games/src/poker/poker.test.ts @@ -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, 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) => 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 }).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 = {}): 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 = { 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 => 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, 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; + } + } + }); +}); diff --git a/packages/games/src/poker/showdown.ts b/packages/games/src/poker/showdown.ts new file mode 100644 index 0000000..c39dca5 --- /dev/null +++ b/packages/games/src/poker/showdown.ts @@ -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): { 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, + declares: Record, +): 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'); +} diff --git a/packages/games/src/poker/types.ts b/packages/games/src/poker/types.ts new file mode 100644 index 0000000..bf5c83c --- /dev/null +++ b/packages/games/src/poker/types.ts @@ -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; +/** 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; + +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; + 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; 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']; +} diff --git a/packages/games/src/poker/view.ts b/packages/games/src/poker/view.ts new file mode 100644 index 0000000..725b676 --- /dev/null +++ b/packages/games/src/poker/view.ts @@ -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> = { + 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> = { + 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, + }; +} diff --git a/packages/games/src/seotda/hands.ts b/packages/games/src/seotda/hands.ts new file mode 100644 index 0000000..fbdeb6c --- /dev/null +++ b/packages/games/src/seotda/hands.ts @@ -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 = { + 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}`; + } +} diff --git a/packages/games/src/seotda/index.ts b/packages/games/src/seotda/index.ts new file mode 100644 index 0000000..79e34a0 --- /dev/null +++ b/packages/games/src/seotda/index.ts @@ -0,0 +1,1259 @@ +/** 섯다 (docs/games/seotda.md). Chips are room-only play points (§12). */ +import { z } from 'zod'; +import { + HWATU, + SEOTDA_DECK, + applyBet, + buildPots, + fail, + handDecided, + legalBets, + newBetting, + ok, + postAnte, + returnUncalled, + splitAmount, + startRound, + timeoutBet, + validateBet, + type BetAction, + type BettingState, + type GameDefinition, + type GameEvent, + type GameResult, + type KoreanKind, + type PlayerId, + type Rng, +} from '@bg/engine'; +import { bestChoice, combos, evalSeotda, hintText, resolve, strengthOf, type HandEval, type HandRules, type SpecialKind } from './hands'; + +export * from './hands'; + +const lit = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +export const seotdaOptionsSchema = z + .object({ + cardMode: z + .enum(['two', 'three']) + .default('two') + .meta({ title: '카드 수', labels: { two: '2장 섯다', three: '3장 섯다(3장 받아 2장 고르기)' } }), + dealMode: z + .enum(['twoRounds', 'oneRound']) + .default('twoRounds') + .meta({ title: '2장 섯다 나눠 주기', labels: { twoRounds: '1장씩 두 번(베팅 2번)', oneRound: '2장 한 번에(베팅 1번)' } }), + startingChips: lit(500, 1000, 2000, 5000) + .default(1000) + .meta({ title: '시작 칩', labels: { 500: '500칩', 1000: '1,000칩', 2000: '2,000칩', 5000: '5,000칩' } }), + baseUnit: lit(2, 10, 20, 50) + .default(10) + .meta({ title: '학교(기본 칩) = 삥', labels: { 2: '2칩', 10: '10칩', 20: '20칩', 50: '50칩' } }), + maxRaise: 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: lit(10, 20, 30, 50) + .default(20) + .meta({ title: '총 판 수(재경기 제외)', labels: { 10: '10판', 20: '20판', 30: '30판', 50: '50판' } }), + schoolUpEvery: lit(0, 5, 10, 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: lit(1, 2, 3).default(1).meta({ title: '리바이 횟수(1인당)', labels: { 1: '1번', 2: '2번', 3: '3번' } }), + turnSeconds: lit(15, 20, 30, 60) + .default(20) + .meta({ title: '행동 제한 시간', labels: { 15: '15초', 20: '20초', 30: '30초', 60: '60초' } }), + dealerRule: z.enum(['winner', 'rotate']).default('winner').meta({ title: '다음 판의 선', labels: { winner: '이긴 사람', rotate: '돌아가며' } }), + gwangTtaeng: z + .enum(['all', 'only38']) + .default('all') + .meta({ title: '광땡 종류', labels: { all: '38·18·13광땡', only38: '38광땡만' } }), + ttaengjabi: z + .enum(['off', 'strict', 'loose']) + .default('strict') + .meta({ title: '땡잡이(3·7)', labels: { off: '없음', strict: '3월 광 + 7월 열끗', loose: '3월 + 7월 아무 패' } }), + amhaeng: z.boolean().default(true).meta({ title: '암행어사(4·7 열끗)' }), + gusa: z.boolean().default(true).meta({ title: '구사 재경기(4·9)' }), + mungGusa: z + .enum(['off', 'upTo8', 'upTo9', 'upToJang']) + .default('upTo8') + .meta({ title: '멍텅구리구사 재경기 범위', labels: { off: '일반 구사로', upTo8: '8땡까지', upTo9: '9땡까지', upToJang: '장땡까지' } }), + tieRule: z.enum(['rematch', 'split']).default('rematch').meta({ title: '같은 족보일 때', labels: { rematch: '재경기', split: '나눠 가짐' } }), + rejoinCost: z + .enum(['none', 'half', 'full']) + .default('none') + .meta({ title: '구사 재경기 때 다이한 사람', labels: { none: '참가 불가', half: '모인 칩 절반 내고 참가', full: '모인 칩만큼 내고 참가' } }), + hints: z.boolean().default(true).meta({ title: '내 족보 이름·족보표 강조' }), + maxRematches: lit(1, 2, 3, 5) + .default(3) + .meta({ title: '한 판의 재경기 최대 횟수(넘으면 나눠 가짐)', labels: { 1: '1번', 2: '2번', 3: '3번', 5: '5번' } }), + decisionSeconds: lit(10, 15, 30) + .default(15) + .meta({ title: '재참가·리바이 답 시간', labels: { 10: '10초', 15: '15초', 30: '30초' } }), + }) + .superRefine((o, ctx) => { + if (o.baseUnit * 20 > o.startingChips) { + ctx.addIssue({ code: 'custom', path: ['baseUnit'], message: '학교는 시작 칩의 20분의 1 이하여야 해요.' }); + } + }); +export type SeotdaOptions = z.infer; + +export const seotdaActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('check') }), + z.object({ type: z.literal('ping') }), + z.object({ type: z.literal('call') }), + z.object({ type: z.literal('ddadang') }), + z.object({ type: z.literal('quarter') }), + z.object({ type: z.literal('half') }), + z.object({ type: z.literal('full') }), + z.object({ type: z.literal('die') }), + z.object({ type: z.literal('choose'), cardIds: z.tuple([z.number().int(), z.number().int()]) }), + z.object({ type: z.literal('rejoin'), accept: z.boolean() }), + z.object({ type: z.literal('ready') }), + z.object({ type: z.literal('rebuy'), accept: z.boolean() }), +]); +/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */ +export type SeotdaAction = z.infer | { type: 'timeout' } | { type: 'leave' }; + +export type BetType = 'check' | 'ping' | 'call' | 'ddadang' | 'quarter' | 'half' | 'full' | 'die'; +const BET_TYPES: BetType[] = ['check', 'ping', 'call', 'ddadang', 'quarter', 'half', 'full', 'die']; +const toKind = (t: BetType): KoreanKind => (t === 'ddadang' ? 'double' : t); +const toType = (k: string): BetType => (k === 'double' ? 'ddadang' : (k as BetType)); +export const BET_KO: Record = { check: '체크', ping: '삥', call: '콜', ddadang: '따당', quarter: '쿼터', half: '하프', full: '풀', die: '다이' }; + +// ---------------------------------------------------------------- state + +export interface SeatState { + id: PlayerId; + stack: number; + rebuys: number; + /** busted = 0 chips, waiting for the rebuy answer. */ + status: 'playing' | 'busted' | 'eliminated'; + left: boolean; + away: boolean; + timeoutsInRow: number; + eliminatedAtHand: number | null; + stackAtHandStart: number; +} + +export interface PotState { + amount: number; + eligible: PlayerId[]; +} + +export interface ShowdownHand { + id: PlayerId; + cards: [number, number]; + name: string; + baseName: string; + value: number; + special: SpecialKind | null; +} + +export interface PotOutcome { + amount: number; + eligible: PlayerId[]; + type: 'win' | 'split' | 'rematch' | 'fold'; + winners: PlayerId[]; + reason: 'normal' | 'ttaengjabi' | 'amhaeng' | 'gusa' | 'mungGusa' | 'tie' | 'limit' | 'fold'; +} + +export interface SeotdaHand { + handNo: number; + /** 0 = 본 판, 1.. = 재경기 차수. */ + rematchNo: number; + phase: 'betting' | 'choose' | 'rejoin' | 'handEnd'; + dealer: PlayerId; + /** Participants of this (re)match, from the dealer clockwise. */ + order: PlayerId[]; + /** Remaining deck (secret). */ + deck: number[]; + cards: Record; + /** 3장 섯다: chosen pair (secret until showdown). */ + chosen: Record; + betting: BettingState | null; + street: 0 | 1; + /** Bet counter (deadline key). */ + betSeq: number; + /** Pots carried from earlier matches of the same hand (eligibility fixed). */ + carried: PotState[]; + rematchReason: 'gusa' | 'mungGusa' | 'tie' | null; + /** Upcoming rematch participants (handEnd → rejoin → rematch). */ + rematchSet: PlayerId[]; + rejoinPending: PlayerId[]; + rejoinCost: number; + rejoined: PlayerId[]; + showdown: { hands: ShowdownHand[]; pots: PotOutcome[] } | null; + foldWin: { winner: PlayerId; amount: number } | null; + next: 'rematch' | 'nextHand' | null; + ready: PlayerId[]; + lastAction: Record; +} + +export interface HandRecord { + handNo: number; + rematchNo: number; + winners: PlayerId[]; + amount: number; + name: string | null; + how: PotOutcome['reason'] | 'rematch'; +} + +export interface SeotdaFinal { + ranking: PlayerId[][]; + scores: Record; + summary: string; +} + +export interface SeotdaState { + options: SeotdaOptions; + seats: SeatState[]; + baseUnit: number; + hand: SeotdaHand | null; + handsPlayed: number; + lastWinner: PlayerId | null; + lastDealer: PlayerId | null; + phase: 'playing' | 'rebuy' | 'finished'; + rebuyPending: PlayerId[]; + deadlineAt: number | null; + waitKey: string; + history: HandRecord[]; + stats: { + rematches: number; + bestHand: { playerId: PlayerId; name: string; value: number; handNo: number } | null; + biggestPot: { handNo: number; amount: number; winners: PlayerId[] } | null; + }; + final: SeotdaFinal | null; +} + +// ---------------------------------------------------------------- view + +export interface SeotdaCardView { + /** Unique per (hand, match, card) so leak tests can spot it. */ + key: string; + id: number; + month: number; + kind: 'gwang' | 'yeol' | 'tti' | 'pi' | 'ssangpi' | 'bonus'; + tti?: 'hong' | 'cheong' | 'cho' | 'plain'; + name: string; +} +export type CardSlot = SeotdaCardView | { hidden: true }; + +export interface SeotdaSeatView { + id: PlayerId; + stack: number; + rebuys: number; + status: SeatState['status']; + left: boolean; + away: boolean; + inHand: boolean; + folded: boolean; + allIn: boolean; + roundBet: number; + matchBet: number; + cards: CardSlot[]; + /** 3장 섯다 choose phase: has chosen (null outside that phase). */ + chosen: boolean | null; + isDealer: boolean; + isTurn: boolean; + ready: boolean; + lastAction: { type: BetType; amount: number } | null; + /** Shown after showdown only. */ + handName: string | null; +} + +export interface SeotdaView { + options: SeotdaOptions; + me: PlayerId | null; + phase: SeotdaState['phase']; + handPhase: SeotdaHand['phase'] | null; + handNo: number; + handsPlayed: number; + rematchNo: number; + rematchReason: SeotdaHand['rematchReason']; + baseUnit: number; + seats: SeotdaSeatView[]; + dealer: PlayerId | null; + toAct: PlayerId | null; + street: number; + pot: number; + pots: PotState[]; + currentBet: number; + legal: { type: BetType; amount: number; allIn: boolean }[]; + myHand: { name: string; value: number | null; special: SpecialKind | null; hint: string; strength: 'high' | 'mid' | 'low' | null } | null; + myCombos: { cardIds: [number, number]; name: string; value: number; best: boolean }[] | null; + myChosen: [number, number] | null; + myDiscard: SeotdaCardView | null; + rejoin: { pending: PlayerId[]; cost: number; mine: boolean; canAfford: boolean } | null; + rebuy: { pending: PlayerId[]; mine: boolean; left: number | null } | null; + showdown: { hands: (Omit & { cards: SeotdaCardView[] })[]; pots: PotOutcome[] } | null; + foldWin: SeotdaHand['foldWin']; + next: SeotdaHand['next']; + deadlineAt: number | null; + history: HandRecord[]; + stats: SeotdaState['stats']; + result: SeotdaFinal | null; +} + +// ---------------------------------------------------------------- helpers + +/** Placeholder better that carries earlier pots so 쿼터/하프/풀 see the whole 모인 칩 (§5.5-2). */ +const CARRY = '#carried'; +const HAND_END_MS = 4000; +const AWAY_MS = 5000; +const STANDING_CAP = 300; + +const rulesOf = (o: SeotdaOptions): HandRules => ({ + gwangTtaeng: o.gwangTtaeng, + ttaengjabi: o.ttaengjabi, + amhaeng: o.amhaeng, + gusa: o.gusa, + mungGusa: o.gusa ? o.mungGusa : 'off', +}); +const seatOf = (s: SeotdaState, id: PlayerId): SeatState | undefined => s.seats.find((x) => x.id === id); +const seatIdx = (s: SeotdaState, id: PlayerId): number => s.seats.findIndex((x) => x.id === id); +const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0); +const carriedTotal = (h: SeotdaHand): number => sum(h.carried.map((p) => p.amount)); +const realPlayers = (b: BettingState) => b.players.filter((p) => p.id !== CARRY); +/** Chips bet in the current match (carried pots excluded). */ +const livePot = (h: SeotdaHand): number => (h.betting ? sum(realPlayers(h.betting).map((p) => p.total)) : 0); +const contendersOf = (h: SeotdaHand): PlayerId[] => + h.betting ? realPlayers(h.betting).filter((p) => p.status === 'active' || p.status === 'allIn').map((p) => p.id) : []; +const betterIdx = (h: SeotdaHand, id: PlayerId): number => (h.betting ? h.betting.players.findIndex((p) => p.id === id) : -1); +const maxRebuys = (o: SeotdaOptions): number => (o.rebuy === 'off' ? 0 : o.rebuy === 'limited' ? o.rebuyLimit : Infinity); + +/** Total chips that must exist: Σ stacks + carried pots + live pot. */ +export function chipsTotal(s: SeotdaState): number { + const h = s.hand; + return sum(s.seats.map((x) => x.stack)) + (h ? carriedTotal(h) + livePot(h) : 0); +} + +function syncStacks(s: SeotdaState): void { + const b = s.hand?.betting; + if (!b) return; + for (const p of realPlayers(b)) seatOf(s, p.id)!.stack = p.stack; +} + +/** Runs `fn` on the betting state without the carried-pot placeholder. */ +function withoutCarry(b: BettingState, fn: (core: BettingState) => BettingState): BettingState { + const ph = b.players.find((p) => p.id === CARRY); + const out = fn({ ...b, players: b.players.filter((p) => p.id !== CARRY) }); + if (ph) out.players.push(structuredClone(ph)); + return out; +} + +function cardView(h: SeotdaHand, id: number): SeotdaCardView { + const c = HWATU[id]!; + const key = `h${h.handNo}r${h.rematchNo}c${id}`; + // 8월 둘째 장은 섯다에서 일반 패(기러기)일 뿐이라 열끗 표시를 하지 않는다 (§3). + if (c.month === 8 && c.kind !== 'gwang') return { key, id, month: 8, kind: 'pi', name: '8월 기러기(공산)' }; + return { key, id, month: c.month, kind: c.kind, ...(c.tti ? { tti: c.tti } : {}), name: c.name }; +} + +function orderFrom(ids: PlayerId[], s: SeotdaState, start: PlayerId): PlayerId[] { + const n = s.seats.length; + const st = seatIdx(s, start); + const out: PlayerId[] = []; + for (let k = 0; k < n; k++) { + const id = s.seats[(st + k) % n]!.id; + if (ids.includes(id)) out.push(id); + } + return out; +} + +/** First seat at or after `from` (clockwise, optionally exclusive) that is in `ids`. */ +function firstFrom(s: SeotdaState, from: PlayerId, ids: PlayerId[], inclusive: boolean): PlayerId | null { + const n = s.seats.length; + const st = seatIdx(s, from); + for (let k = inclusive ? 0 : 1; k <= n; k++) { + const id = s.seats[(st + k) % n]!.id; + if (ids.includes(id)) return id; + } + return null; +} + +function pushHistory(s: SeotdaState, r: HandRecord): void { + s.history.push(r); + if (s.history.length > 30) s.history.splice(0, s.history.length - 30); +} + +// ---------------------------------------------------------------- hand flow + +function deal(h: SeotdaHand, ids: PlayerId[], count: number, events: GameEvent[]): void { + for (let r = 0; r < count; r++) + for (const id of ids) { + const c = h.deck.shift(); + if (c === undefined) throw new Error('deck exhausted'); + h.cards[id]!.push(c); + } + for (const id of ids) events.push({ type: 'dealt', to: id, count }); +} + +function firstDealCount(o: SeotdaOptions): number { + return o.cardMode === 'three' || o.dealMode === 'oneRound' ? 2 : 1; +} + +function startHand(s: SeotdaState, rng: Rng, events: GameEvent[]): void { + const o = s.options; + const eligible = s.seats.filter((x) => x.status === 'playing' && x.stack > 0).map((x) => x.id); + let dealer: PlayerId; + if (!s.lastDealer) dealer = eligible[rng.int(eligible.length)]!; + else if (o.dealerRule === 'winner' && s.lastWinner) dealer = firstFrom(s, s.lastWinner, eligible, true)!; + else dealer = firstFrom(s, s.lastDealer, eligible, false)!; + s.lastDealer = dealer; + const order = orderFrom(eligible, s, dealer); + for (const id of order) seatOf(s, id)!.stackAtHandStart = seatOf(s, id)!.stack; + let b = newBetting( + order.map((id) => ({ id, stack: seatOf(s, id)!.stack })), + { style: 'korean', baseUnit: s.baseUnit, koreanMax: o.maxRaise }, + ); + b = postAnte(b, s.baseUnit); + const h: SeotdaHand = { + handNo: s.handsPlayed + 1, + rematchNo: 0, + phase: 'betting', + dealer, + order, + deck: rng.shuffle(SEOTDA_DECK), + cards: Object.fromEntries(order.map((id) => [id, []])), + chosen: Object.fromEntries(order.map((id) => [id, null])), + betting: b, + street: 0, + betSeq: 0, + carried: [], + rematchReason: null, + rematchSet: [], + rejoinPending: [], + rejoinCost: 0, + rejoined: [], + showdown: null, + foldWin: null, + next: null, + ready: [], + lastAction: {}, + }; + s.hand = h; + syncStacks(s); + events.push({ type: 'handStart', handNo: h.handNo, rematchNo: 0, dealer, ante: s.baseUnit }); + deal(h, order, firstDealCount(o), events); + h.betting = startRound(b, 0); +} + +function startRematch(s: SeotdaState, rng: Rng, events: GameEvent[]): void { + const h = s.hand!; + const parts = h.rematchSet; + const dealer = parts.includes(h.dealer) ? h.dealer : firstFrom(s, h.dealer, parts, false)!; + const order = orderFrom(parts, s, dealer); + h.rematchNo += 1; + s.stats.rematches += 1; + h.dealer = dealer; + h.order = order; + h.phase = 'betting'; + h.street = 0; + h.deck = rng.shuffle(SEOTDA_DECK); + h.cards = Object.fromEntries(order.map((id) => [id, []])); + h.chosen = Object.fromEntries(order.map((id) => [id, null])); + h.showdown = null; + h.foldWin = null; + h.next = null; + h.ready = []; + h.rejoinPending = []; + h.rematchSet = []; + h.lastAction = {}; + let b = newBetting( + order.map((id) => ({ id, stack: seatOf(s, id)!.stack })), + { style: 'korean', baseUnit: s.baseUnit, koreanMax: s.options.maxRaise }, + ); + // Players already all-in keep playing for the pots they reached (§5.5-3). + for (const p of b.players) if (p.stack === 0) p.status = 'allIn'; + b.players.push({ id: CARRY, stack: 0, round: 0, total: carriedTotal(h), status: 'out', acted: true, raised: true, mayRaise: false }); + events.push({ type: 'handStart', handNo: h.handNo, rematchNo: h.rematchNo, dealer, ante: 0, reason: h.rematchReason }); + deal(h, order, firstDealCount(s.options), events); + h.betting = startRound(b, 0); +} + +/** Pots of the current match: carried (re-filtered by who is still in) + this match's bets. */ +function currentPots(h: SeotdaHand): PotState[] { + const live = contendersOf(h); + const b = h.betting!; + const fresh = buildPots(realPlayers(b)); + const all: PotState[] = [ + ...h.carried.map((p) => { + const el = p.eligible.filter((id) => live.includes(id)); + return { amount: p.amount, eligible: el.length ? el : [...live] }; + }), + ...fresh.map((p) => ({ amount: p.amount, eligible: p.eligible.filter((id) => live.includes(id)) })), + ]; + // Merge pots with the same eligible set; order by eligibility size (side pots first, §6.5). + const merged: PotState[] = []; + for (const p of all) { + if (p.amount <= 0) continue; + const key = [...p.eligible].sort().join(); + const m = merged.find((x) => [...x.eligible].sort().join() === key); + if (m) m.amount += p.amount; + else merged.push({ amount: p.amount, eligible: h.order.filter((id) => p.eligible.includes(id)) }); + } + return merged.sort((a, b2) => a.eligible.length - b2.eligible.length); +} + +/** Clears the match's chips from the betting state after they were moved into pots / payouts. */ +function clearBetting(s: SeotdaState): void { + const b = s.hand!.betting!; + for (const p of b.players) { + p.total = 0; + p.round = 0; + if (p.id !== CARRY) p.stack = seatOf(s, p.id)!.stack; + } + b.turn = null; +} + +function pay(s: SeotdaState, won: Record): void { + for (const [id, amt] of Object.entries(won)) seatOf(s, id)!.stack += amt; +} + +function noteBiggest(s: SeotdaState, handNo: number, amount: number, winners: PlayerId[]): void { + if (!s.stats.biggestPot || amount > s.stats.biggestPot.amount) s.stats.biggestPot = { handNo, amount, winners: [...winners] }; +} + +function enterHandEnd(s: SeotdaState): void { + const h = s.hand!; + h.phase = 'handEnd'; + h.ready = []; +} + +function settleFold(s: SeotdaState, events: GameEvent[]): void { + const h = s.hand!; + const winner = contendersOf(h)[0] ?? h.order[0]!; + const amount = carriedTotal(h) + livePot(h); + h.carried = []; + clearBetting(s); + pay(s, { [winner]: amount }); + syncBackStacks(s); + h.foldWin = { winner, amount }; + h.next = 'nextHand'; + s.lastWinner = winner; + noteBiggest(s, h.handNo, amount, [winner]); + pushHistory(s, { handNo: h.handNo, rematchNo: h.rematchNo, winners: [winner], amount, name: null, how: 'fold' }); + events.push({ type: 'foldWin', winner, amount }); + enterHandEnd(s); +} + +function settleShowdown(s: SeotdaState, events: GameEvent[]): void { + const h = s.hand!; + const o = s.options; + const rules = rulesOf(o); + const live = h.order.filter((id) => contendersOf(h).includes(id)); + const evals: Record = {}; + const hands: ShowdownHand[] = []; + for (const id of live) { + const pair = (o.cardMode === 'three' ? h.chosen[id] : h.cards[id]) as [number, number]; + const e = evalSeotda(pair[0], pair[1], rules); + evals[id] = e; + hands.push({ id, cards: pair, name: e.name, baseName: e.baseName, value: e.value, special: e.special }); + if (!s.stats.bestHand || e.value > s.stats.bestHand.value) s.stats.bestHand = { playerId: id, name: e.baseName, value: e.value, handNo: h.handNo }; + } + const pots = currentPots(h); + clearBetting(s); + const outcomes: PotOutcome[] = []; + const rematchPots: PotState[] = []; + let reason: SeotdaHand['rematchReason'] = null; + for (const pot of pots) { + const S = pot.eligible; + if (S.length === 1) { + pay(s, { [S[0]!]: pot.amount }); + outcomes.push({ ...pot, type: 'win', winners: [...S], reason: 'normal' }); + continue; + } + const r = resolve(S.map((id) => ({ id, hand: evals[id]! })), o.tieRule, rules); + if (r.type === 'win') { + pay(s, { [r.winners[0]!]: pot.amount }); + outcomes.push({ ...pot, type: 'win', winners: r.winners, reason: r.reason }); + } else if (r.type === 'split') { + pay(s, splitAmount(pot.amount, r.winners)); + outcomes.push({ ...pot, type: 'split', winners: r.winners, reason: 'tie' }); + } else if (h.rematchNo >= o.maxRematches) { + pay(s, splitAmount(pot.amount, r.participants)); + outcomes.push({ ...pot, type: 'split', winners: r.participants, reason: 'limit' }); + } else { + rematchPots.push({ amount: pot.amount, eligible: [...r.participants] }); + outcomes.push({ ...pot, type: 'rematch', winners: r.participants, reason: r.reason }); + if (r.reason !== 'tie') reason = r.reason; + else reason ??= 'tie'; + } + } + syncBackStacks(s); + h.showdown = { hands, pots: outcomes }; + events.push({ type: 'showdown', hands: hands.map((x) => ({ id: x.id, cards: x.cards, name: x.name })), pots: outcomes }); + for (const out of outcomes) { + if (out.type === 'rematch') continue; + noteBiggest(s, h.handNo, out.amount, out.winners); + const name = out.winners.length ? evals[out.winners[0]!]!.name : null; + pushHistory(s, { handNo: h.handNo, rematchNo: h.rematchNo, winners: out.winners, amount: out.amount, name, how: out.reason }); + } + // Main pot = the one with the most eligible players (last after sorting). + const main = outcomes[outcomes.length - 1]; + if (rematchPots.length) { + h.carried = rematchPots; + h.rematchReason = reason; + h.rematchSet = h.order.filter((id) => rematchPots.some((p) => p.eligible.includes(id))); + h.next = 'rematch'; + pushHistory(s, { handNo: h.handNo, rematchNo: h.rematchNo, winners: [], amount: carriedTotal(h), name: null, how: 'rematch' }); + events.push({ type: 'rematch', reason, participants: h.rematchSet }); + } else { + h.carried = []; + h.next = 'nextHand'; + } + if (main && main.type !== 'rematch') s.lastWinner = main.winners[0]!; + enterHandEnd(s); +} + +/** After payouts, keep the betting copy of stacks equal to the seats. */ +function syncBackStacks(s: SeotdaState): void { + const b = s.hand?.betting; + if (!b) return; + for (const p of realPlayers(b)) p.stack = seatOf(s, p.id)!.stack; +} + +function afterHandEnd(s: SeotdaState, rng: Rng, events: GameEvent[]): void { + const h = s.hand!; + if (h.next === 'rematch') { + const gusaRematch = h.rematchReason === 'gusa' || h.rematchReason === 'mungGusa'; + const folded = h.betting ? realPlayers(h.betting).filter((p) => p.status === 'folded').map((p) => p.id) : []; + const pending = folded.filter((id) => { + const st = seatOf(s, id)!; + return st.status === 'playing' && !st.left; + }); + if (gusaRematch && s.options.rejoinCost !== 'none' && pending.length) { + const pot = carriedTotal(h); + h.rejoinCost = s.options.rejoinCost === 'half' ? Math.floor(pot / 2) : pot; + h.rejoinPending = pending; + h.rejoined = []; + h.phase = 'rejoin'; + events.push({ type: 'rejoinAsk', players: pending, cost: h.rejoinCost }); + return; + } + startRematch(s, rng, events); + return; + } + finishHand(s, rng, events); +} + +function finishHand(s: SeotdaState, rng: Rng, events: GameEvent[]): void { + const o = s.options; + const h = s.hand!; + s.handsPlayed += 1; + s.hand = null; + events.push({ type: 'handEnd', handNo: h.handNo }); + if (o.schoolUpEvery > 0 && s.handsPlayed % o.schoolUpEvery === 0) { + const next = Math.min(s.baseUnit * 2, Math.floor(o.startingChips / 5)); + if (next > s.baseUnit) { + s.baseUnit = next; + events.push({ type: 'schoolUp', baseUnit: next }); + } + } + const lastHand = o.endCondition === 'hands' && s.handsPlayed >= o.handLimit; + s.rebuyPending = []; + for (const st of s.seats) { + if (st.status !== 'playing' || st.stack > 0) continue; + if (!st.left && !lastHand && st.rebuys < maxRebuys(o)) { + st.status = 'busted'; + s.rebuyPending.push(st.id); + } else { + eliminate(s, st, h.handNo, events); + } + } + if (s.rebuyPending.length) { + s.phase = 'rebuy'; + events.push({ type: 'rebuyAsk', players: s.rebuyPending }); + return; + } + nextOrFinish(s, rng, events); +} + +function eliminate(s: SeotdaState, st: SeatState, handNo: number, events: GameEvent[]): void { + st.status = 'eliminated'; + st.eliminatedAtHand = handNo; + events.push({ type: 'eliminated', player: st.id, handNo }); +} + +function nextOrFinish(s: SeotdaState, rng: Rng, events: GameEvent[]): void { + s.phase = 'playing'; + const o = s.options; + const connected = s.seats.filter((x) => !x.left && x.status !== 'eliminated'); + const withChips = s.seats.filter((x) => x.status === 'playing' && x.stack > 0); + const done = + connected.length <= 1 || + withChips.length < 2 || + (o.endCondition === 'hands' && s.handsPlayed >= o.handLimit) || + (o.endCondition === 'lastStanding' && s.handsPlayed >= STANDING_CAP); + if (done) finishGame(s, events); + else startHand(s, rng, events); +} + +function finishGame(s: SeotdaState, events: GameEvent[]): void { + const o = s.options; + s.phase = 'finished'; + s.hand = null; + s.rebuyPending = []; + const byScore = o.endCondition === 'hands' || s.handsPlayed >= STANDING_CAP; + const scores: Record = {}; + let ranking: PlayerId[][] = []; + const group = (ids: PlayerId[], keyOf: (id: PlayerId) => string) => { + const out: PlayerId[][] = []; + for (const id of ids) { + const last = out[out.length - 1]; + if (last && keyOf(last[0]!) === keyOf(id)) last.push(id); + else out.push([id]); + } + return out; + }; + if (byScore) { + for (const x of s.seats) scores[x.id] = x.stack - o.startingChips * x.rebuys; + const ids = s.seats.map((x) => x.id).sort((a, b) => scores[b]! - scores[a]!); + ranking = group(ids, (id) => String(scores[id])); + } else { + const alive = s.seats.filter((x) => x.status !== 'eliminated').sort((a, b) => b.stack - a.stack); + const dead = s.seats + .filter((x) => x.status === 'eliminated') + .sort((a, b) => (b.eliminatedAtHand ?? 0) - (a.eliminatedAtHand ?? 0) || b.stackAtHandStart - a.stackAtHandStart); + const aliveKey = (id: PlayerId) => `a${seatOf(s, id)!.stack}`; + const deadKey = (id: PlayerId) => `d${seatOf(s, id)!.eliminatedAtHand}:${seatOf(s, id)!.stackAtHandStart}`; + ranking = [...group(alive.map((x) => x.id), aliveKey), ...group(dead.map((x) => x.id), deadKey)]; + const n = s.seats.length; + let rank = 1; + for (const g of ranking) { + for (const id of g) scores[id] = n + 1 - rank; + rank += g.length; + } + } + const top = ranking[0] ?? []; + const best = s.stats.bestHand ? `, 최고 족보 ${s.stats.bestHand.name}` : ''; + let summary: string; + if (byScore) { + const sc = scores[top[0]!] ?? 0; + const tie = top.length > 1 ? ` (공동 ${top.length}명)` : ''; + summary = `${s.handsPlayed}판 끝. 1위 ${sc >= 0 ? '+' : ''}${sc.toLocaleString('ko-KR')}칩${tie}${best}`; + } else { + summary = `${s.handsPlayed}판 만에 끝났어요. 1위 ${(seatOf(s, top[0]!)?.stack ?? 0).toLocaleString('ko-KR')}칩${best}`; + } + s.final = { ranking, scores, summary }; + events.push({ type: 'gameEnd', ranking }); +} + +// ---------------------------------------------------------------- progression + +function applyBetAction(s: SeotdaState, id: PlayerId, t: BetType, events: GameEvent[]): void { + const h = s.hand!; + const idx = betterIdx(h, id); + const r = applyBet(h.betting!, idx, { kind: toKind(t) } as BetAction); + h.betting = r.state; + h.betSeq += 1; + h.lastAction[id] = { type: t, amount: r.event.amount }; + syncStacks(s); + events.push({ type: 'bet', player: id, kind: t, amount: r.event.amount, allIn: r.event.allIn }); +} + +function chooseFor(s: SeotdaState, id: PlayerId, pair: [number, number], events: GameEvent[]): void { + const h = s.hand!; + h.chosen[id] = [Math.min(...pair), Math.max(...pair)] as [number, number]; + events.push({ type: 'chose', player: id }); +} + +function requiredReady(s: SeotdaState): PlayerId[] { + return s.seats.filter((x) => x.status === 'playing' && !x.left).map((x) => x.id); +} + +/** Advances automatic steps (round ends, dealing, left players) until someone must decide. */ +function progress(s: SeotdaState, rng: Rng, events: GameEvent[]): void { + for (let guard = 0; guard < 10_000; guard++) { + if (s.phase === 'finished') return; + if (s.phase === 'rebuy') { + const gone = s.rebuyPending.filter((id) => seatOf(s, id)!.left); + for (const id of gone) decideRebuy(s, id, false, events); + if (s.rebuyPending.length === 0) { + nextOrFinish(s, rng, events); + continue; + } + return; + } + const h = s.hand; + if (!h) return; + switch (h.phase) { + case 'betting': { + const b = h.betting!; + if (b.turn !== null) { + const id = b.players[b.turn]!.id; + if (seatOf(s, id)!.left) { + applyBetAction(s, id, 'die', events); + continue; + } + return; + } + h.betting = withoutCarry(b, (core) => returnUncalled(core).state); + syncStacks(s); + if (handDecided(h.betting)) { + settleFold(s, events); + continue; + } + if (h.street === 0 && s.options.cardMode === 'three') { + deal(h, h.order.filter((id) => contendersOf(h).includes(id)), 1, events); + h.phase = 'choose'; + continue; + } + if (h.street === 0 && s.options.dealMode === 'twoRounds') { + deal(h, h.order.filter((id) => contendersOf(h).includes(id)), 1, events); + h.street = 1; + h.lastAction = {}; + h.betting = startRound(h.betting, 0); + continue; + } + settleShowdown(s, events); + continue; + } + case 'choose': { + const waiting = contendersOf(h).filter((id) => !h.chosen[id]); + for (const id of waiting) if (seatOf(s, id)!.left) chooseFor(s, id, bestChoice(h.cards[id]!, rulesOf(s.options)), events); + if (contendersOf(h).every((id) => h.chosen[id])) { + h.phase = 'betting'; + h.street = 1; + h.lastAction = {}; + h.betting = startRound(h.betting!, 0); + continue; + } + return; + } + case 'rejoin': { + for (const id of [...h.rejoinPending]) if (seatOf(s, id)!.left) decideRejoin(s, id, false, events); + if (h.rejoinPending.length === 0) { + startRematch(s, rng, events); + continue; + } + return; + } + case 'handEnd': { + if (requiredReady(s).every((id) => h.ready.includes(id))) { + afterHandEnd(s, rng, events); + continue; + } + return; + } + } + } + throw new Error('seotda: progress did not settle'); +} + +function decideRejoin(s: SeotdaState, id: PlayerId, accept: boolean, events: GameEvent[]): void { + const h = s.hand!; + h.rejoinPending = h.rejoinPending.filter((x) => x !== id); + if (accept) { + const st = seatOf(s, id)!; + st.stack -= h.rejoinCost; + // Paid into the main (largest) rematch pot; the rejoiner can win every carried pot. + h.carried[h.carried.length - 1]!.amount += h.rejoinCost; + for (const p of h.carried) if (!p.eligible.includes(id)) p.eligible.push(id); + h.rejoined.push(id); + h.rematchSet = orderFrom([...h.rematchSet, id], s, h.dealer); + } + events.push({ type: 'rejoin', player: id, accept, cost: accept ? h.rejoinCost : 0 }); +} + +function decideRebuy(s: SeotdaState, id: PlayerId, accept: boolean, events: GameEvent[]): void { + const st = seatOf(s, id)!; + s.rebuyPending = s.rebuyPending.filter((x) => x !== id); + if (accept) { + st.stack = s.options.startingChips; + st.rebuys += 1; + st.status = 'playing'; + } else { + eliminate(s, st, s.handsPlayed, events); + } + events.push({ type: 'rebuy', player: id, accept }); +} + +/** Deadline bookkeeping: a new wait (different phase / turn) restarts the clock. */ +function refreshDeadline(s: SeotdaState, now: number): void { + const o = s.options; + const h = s.hand; + let key: string = s.phase; + let ms: number | null = null; + if (s.phase === 'rebuy') { + key += `|${s.handsPlayed}`; + ms = o.decisionSeconds * 1000; + } + else if (s.phase === 'playing' && h) { + key += `|${h.handNo}|${h.rematchNo}|${h.phase}|${h.street}`; + if (h.phase === 'betting' && h.betting?.turn != null) { + const id = h.betting.players[h.betting.turn]!.id; + key += `|${id}|${h.betSeq}`; + ms = seatOf(s, id)!.away ? AWAY_MS : o.turnSeconds * 1000; + } else if (h.phase === 'choose') ms = (o.turnSeconds + 10) * 1000; + else if (h.phase === 'rejoin') ms = o.decisionSeconds * 1000; + else if (h.phase === 'handEnd') ms = HAND_END_MS; + } + if (key !== s.waitKey) { + s.waitKey = key; + s.deadlineAt = ms === null ? null : now + ms; + } + if (ms === null) s.deadlineAt = null; +} + +function waiting(s: SeotdaState): PlayerId[] { + if (s.phase === 'finished') return []; + if (s.phase === 'rebuy') return [...s.rebuyPending]; + const h = s.hand; + if (!h) return []; + switch (h.phase) { + case 'betting': + return h.betting?.turn != null ? [h.betting.players[h.betting.turn]!.id] : []; + case 'choose': + return contendersOf(h).filter((id) => !h.chosen[id]); + case 'rejoin': + return [...h.rejoinPending]; + case 'handEnd': + return requiredReady(s).filter((id) => !h.ready.includes(id)); + } +} + +function isBet(t: string): t is BetType { + return (BET_TYPES as string[]).includes(t); +} + +// ---------------------------------------------------------------- definition + +export function playerRange(o: Pick): { min: number; max: number } { + return { min: 2, max: o.cardMode === 'three' ? 6 : 10 }; +} + +export const seotda: GameDefinition = { + id: 'seotda', + nameKo: '섯다', + minPlayers: 2, + maxPlayers: 10, + stateVersion: 1, + defaultOptions: seotdaOptionsSchema.parse({}), + optionsSchema: seotdaOptionsSchema as unknown as z.ZodType, + actionSchema: seotdaActionSchema as unknown as z.ZodType, + playersFor: playerRange, + + setup({ players, options, rng, now }) { + const range = playerRange(options); + if (players.length < range.min || players.length > range.max) throw new Error(`seotda: ${players.length} players not allowed`); + const s: SeotdaState = { + options, + seats: players.map((id) => ({ + id, + stack: options.startingChips, + rebuys: 0, + status: 'playing', + left: false, + away: false, + timeoutsInRow: 0, + eliminatedAtHand: null, + stackAtHandStart: options.startingChips, + })), + baseUnit: options.baseUnit, + hand: null, + handsPlayed: 0, + lastWinner: null, + lastDealer: null, + phase: 'playing', + rebuyPending: [], + deadlineAt: null, + waitKey: '', + history: [], + stats: { rematches: 0, bestHand: null, biggestPot: null }, + final: null, + }; + const events: GameEvent[] = []; + startHand(s, rng, events); + progress(s, rng, events); + refreshDeadline(s, now); + return s; + }, + + validate(s, actor, a) { + const st = seatOf(s, actor); + if (!st) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase === 'finished') return fail('게임이 이미 끝났어요.'); + if (a.type === 'leave') return st.left ? fail('이미 나간 플레이어예요.') : ok; + if (a.type === 'timeout') return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.'); + if (st.left) return 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('지금은 할 수 없어요.'); + if (isBet(a.type)) { + if (h.phase !== 'betting' || !h.betting) return fail('지금은 베팅 시간이 아니에요.'); + const idx = betterIdx(h, actor); + if (idx < 0) return fail('이번 판에 참가하지 않았어요.'); + const err = validateBet(h.betting, idx, { kind: toKind(a.type) } as BetAction); + if (!err) return ok; + if (h.betting.turn === idx && ['ping', 'ddadang', 'quarter', 'half', 'full'].includes(a.type)) { + if (a.type === 'full' && s.options.maxRaise === 'half') return fail('이 방은 하프까지만 올릴 수 있어요.'); + if (a.type === 'ping' && h.betting.opened) return fail('누가 이미 걸어서 삥을 할 수 없어요.'); + return fail('이번 라운드에는 더 올릴 수 없어요(콜 또는 다이).'); + } + if (a.type === 'check' && h.betting.turn === idx) return fail('누가 이미 걸어서 체크할 수 없어요.'); + return fail(err); + } + switch (a.type) { + case 'choose': { + if (h.phase !== 'choose') return fail('지금은 카드를 고를 때가 아니에요.'); + if (!contendersOf(h).includes(actor)) return fail('이번 판에 남아 있지 않아요.'); + if (h.chosen[actor]) return fail('이미 골랐어요.'); + const [x, y] = a.cardIds; + const mine = h.cards[actor] ?? []; + if (x === y || !mine.includes(x) || !mine.includes(y)) return fail('카드 두 장을 골라 주세요.'); + return ok; + } + case 'rejoin': { + if (h.phase !== 'rejoin' || !h.rejoinPending.includes(actor)) return fail('재참가 대상이 아니에요.'); + if (a.accept && st.stack < h.rejoinCost) return fail('칩이 모자라 재참가할 수 없어요.'); + return ok; + } + case 'ready': { + if (h.phase !== 'handEnd') return fail('아직 판이 끝나지 않았어요.'); + if (!requiredReady(s).includes(actor) || h.ready.includes(actor)) return fail('이미 준비했어요.'); + return ok; + } + } + return fail('알 수 없는 행동이에요.'); + }, + + apply(prev, actor, a, { rng, now }) { + const s = structuredClone(prev); + const events: GameEvent[] = []; + const st = seatOf(s, actor)!; + const h = s.hand; + if (a.type !== 'timeout' && a.type !== 'leave') { + st.timeoutsInRow = 0; + st.away = false; + } + if (isBet(a.type)) applyBetAction(s, actor, a.type, events); + else + switch (a.type) { + case 'choose': + chooseFor(s, actor, a.cardIds, events); + break; + case 'rejoin': + decideRejoin(s, actor, a.accept, events); + break; + case 'ready': + h!.ready.push(actor); + break; + case 'rebuy': + decideRebuy(s, actor, a.accept, events); + break; + case 'leave': + st.left = true; + events.push({ type: 'left', player: actor }); + if (h?.phase === 'handEnd') h.ready = h.ready.filter((x) => x !== actor); + break; + case 'timeout': { + const phase = s.phase === 'rebuy' ? 'rebuy' : h!.phase; + if (phase === 'betting' || phase === 'choose') { + st.timeoutsInRow += 1; + if (st.timeoutsInRow >= 2 && !st.away) { + st.away = true; + events.push({ type: 'away', player: actor }); + } + } + events.push({ type: 'timeout', player: actor }); + if (phase === 'betting') { + const b = h!.betting!; + applyBetAction(s, actor, toType(timeoutBet(b, betterIdx(h!, actor)).kind), events); + } else if (phase === 'choose') chooseFor(s, actor, bestChoice(h!.cards[actor]!, rulesOf(s.options)), events); + else if (phase === 'rejoin') decideRejoin(s, actor, false, events); + else if (phase === 'handEnd') h!.ready.push(actor); + else decideRebuy(s, actor, false, events); + break; + } + } + progress(s, rng, events); + refreshDeadline(s, now); + return { state: s, events }; + }, + + view(s, viewer) { + const o = s.options; + const h = s.hand; + const rules = rulesOf(o); + const b = h?.betting ?? null; + const showdownIds = new Set(h?.showdown?.hands.map((x) => x.id) ?? []); + const seats: SeotdaSeatView[] = s.seats.map((x) => { + const better = b?.players.find((p) => p.id === x.id) ?? null; + const inHand = !!h && h.order.includes(x.id); + const cards = h && inHand ? (h.cards[x.id] ?? []) : []; + let slots: CardSlot[]; + if (h && showdownIds.has(x.id)) { + const pair = h.showdown!.hands.find((y) => y.id === x.id)!.cards; + slots = pair.map((c) => cardView(h, c)); + } else if (h && x.id === viewer) slots = cards.map((c) => cardView(h, c)); + else { + // 3장 섯다 after choosing: others see two face-down cards (the discard stays secret). + const n = o.cardMode === 'three' && h?.chosen[x.id] ? 2 : cards.length; + slots = Array.from({ length: n }, () => ({ hidden: true as const })); + } + return { + id: x.id, + stack: x.stack, + rebuys: x.rebuys, + status: x.status, + left: x.left, + away: x.away, + inHand, + folded: better?.status === 'folded', + allIn: better?.status === 'allIn', + roundBet: better?.round ?? 0, + matchBet: better?.total ?? 0, + cards: slots, + chosen: h?.phase === 'choose' && contendersOf(h).includes(x.id) ? !!h.chosen[x.id] : null, + isDealer: h?.dealer === x.id, + isTurn: !!b && b.turn !== null && h?.phase === 'betting' && b.players[b.turn]!.id === x.id, + ready: !!h && h.phase === 'handEnd' && h.ready.includes(x.id), + lastAction: h?.lastAction[x.id] ?? null, + handName: h?.showdown?.hands.find((y) => y.id === x.id)?.name ?? null, + }; + }); + const me = viewer && seatOf(s, viewer) ? viewer : null; + const myIdx = h && me ? betterIdx(h, me) : -1; + const legal = + h && b && myIdx >= 0 && h.phase === 'betting' && !seatOf(s, me!)!.left + ? legalBets(b, myIdx).map((l) => ({ type: toType(l.kind), amount: l.amount, allIn: l.allIn })) + : []; + let myHand: SeotdaView['myHand'] = null; + let myCombos: SeotdaView['myCombos'] = null; + let myDiscard: SeotdaCardView | null = null; + const myCards = h && me ? (h.cards[me] ?? []) : []; + const myChosen = h && me ? (h.chosen[me] ?? null) : null; + if (h && me && myCards.length) { + if (o.cardMode === 'three' && myCards.length === 3) { + const best = bestChoice(myCards, rules); + myCombos = combos(myCards).map((c) => { + const e = evalSeotda(c[0], c[1], rules); + const ids = [Math.min(...c), Math.max(...c)] as [number, number]; + return { cardIds: ids, name: e.name, value: e.value, best: ids[0] === best[0] && ids[1] === best[1] }; + }); + if (myChosen) myDiscard = cardView(h, myCards.find((c) => !myChosen.includes(c))!); + } + if (o.hints) { + const pair = myChosen ?? (myCards.length === 2 ? (myCards as [number, number]) : myCards.length === 3 ? bestChoice(myCards, rules) : null); + if (pair) { + const e = evalSeotda(pair[0], pair[1], rules); + const prefix = !myChosen && myCards.length === 3 ? '최고 조합 ' : ''; + myHand = { name: prefix + e.name, value: e.value, special: e.special, hint: hintText(e, rules), strength: strengthOf(e) }; + } else { + const c = HWATU[myCards[0]!]!; + myHand = { name: `${c.month}월 한 장`, value: null, special: null, hint: '한 장 더 받으면 족보가 정해져요.', strength: null }; + } + } + } + const pots: PotState[] = h ? (b && (h.phase === 'betting' || h.phase === 'choose') ? currentPots(h) : h.carried) : []; + return { + options: o, + me, + phase: s.phase, + handPhase: h?.phase ?? null, + handNo: h?.handNo ?? s.handsPlayed, + handsPlayed: s.handsPlayed, + rematchNo: h?.rematchNo ?? 0, + rematchReason: h && h.rematchNo > 0 ? h.rematchReason : null, + baseUnit: s.baseUnit, + seats, + dealer: h?.dealer ?? null, + toAct: b && b.turn !== null && h?.phase === 'betting' ? b.players[b.turn]!.id : null, + street: h?.street ?? 0, + pot: h ? carriedTotal(h) + livePot(h) : 0, + pots, + currentBet: b?.currentBet ?? 0, + legal, + myHand, + myCombos, + myChosen, + myDiscard, + rejoin: + h?.phase === 'rejoin' + ? { pending: h.rejoinPending, cost: h.rejoinCost, mine: !!me && h.rejoinPending.includes(me), canAfford: !!me && seatOf(s, me)!.stack >= h.rejoinCost } + : null, + rebuy: + s.phase === 'rebuy' + ? { + pending: s.rebuyPending, + mine: !!me && s.rebuyPending.includes(me), + left: me ? (Number.isFinite(maxRebuys(o)) ? maxRebuys(o) - seatOf(s, me)!.rebuys : null) : null, + } + : null, + showdown: h?.showdown + ? { hands: h.showdown.hands.map((x) => ({ ...x, cards: x.cards.map((c) => cardView(h, c)) })), pots: h.showdown.pots } + : null, + foldWin: h?.foldWin ?? null, + next: h?.next ?? null, + deadlineAt: s.deadlineAt, + history: s.history, + stats: s.stats, + result: s.final, + }; + }, + + activePlayers: waiting, + + deadline(s) { + return s.phase === 'finished' ? null : s.deadlineAt; + }, + + timeoutPlayers: waiting, + + onTimeout() { + return { type: 'timeout' }; + }, + + onLeave(s, player) { + const st = seatOf(s, player); + if (!st || st.left || s.phase === 'finished') return null; + return { type: 'leave' }; + }, + + result(s): GameResult | null { + if (!s.final) return null; + return { ranking: s.final.ranking, scores: s.final.scores, summary: s.final.summary, reason: 'normal' }; + }, + + legalActions(s, p) { + const st = seatOf(s, p); + if (!st || st.left || s.phase === 'finished') return []; + if (s.phase === 'rebuy') return s.rebuyPending.includes(p) ? [{ type: 'rebuy', accept: true }, { type: 'rebuy', accept: false }] : []; + const h = s.hand; + if (!h) return []; + switch (h.phase) { + case 'betting': { + const idx = betterIdx(h, p); + if (idx < 0 || !h.betting) return []; + return legalBets(h.betting, idx).map((l) => ({ type: toType(l.kind) }) as SeotdaAction); + } + case 'choose': + if (!contendersOf(h).includes(p) || h.chosen[p]) return []; + return combos(h.cards[p]!).map((c) => ({ type: 'choose', cardIds: c }) as SeotdaAction); + case 'rejoin': + if (!h.rejoinPending.includes(p)) return []; + return st.stack >= h.rejoinCost ? [{ type: 'rejoin', accept: true }, { type: 'rejoin', accept: false }] : [{ type: 'rejoin', accept: false }]; + case 'handEnd': + return requiredReady(s).includes(p) && !h.ready.includes(p) ? [{ type: 'ready' }] : []; + } + }, + + /** Hidden cards of `player` (unrevealed hand, 3장 discard) plus the deck — keys as used in views. */ + secretsOf(s, player) { + const h = s.hand; + if (!h) return []; + const revealed = new Set(h.showdown?.hands.find((x) => x.id === player)?.cards ?? []); + const key = (c: number) => `h${h.handNo}r${h.rematchNo}c${c}`; + const own = (h.cards[player] ?? []).filter((c) => !revealed.has(c)).map(key); + return [...own, ...h.deck.map(key)]; + }, +}; diff --git a/packages/games/src/seotda/seotda.test.ts b/packages/games/src/seotda/seotda.test.ts new file mode 100644 index 0000000..3e596fa --- /dev/null +++ b/packages/games/src/seotda/seotda.test.ts @@ -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 = {}) => 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 = {}, 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, 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 = {}; + 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 = {}) => + 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 = { 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) { + 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, 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 }).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, 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); +}); diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index 3bc233e..e91dbf2 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -16,6 +16,8 @@ export const CATALOG: CatalogEntry[] = [ { 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: '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);