From 9519b09be8c0138de272371a4e31dbec53575679 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Sun, 4 Oct 2026 05:45:13 +0900 Subject: [PATCH] =?UTF-8?q?M3=20=EA=B8=B0=EB=B0=98:=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=20=EB=B2=A0=ED=8C=85=20=EC=97=94=EC=A7=84,=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EC=9D=98,=20=EC=B9=B4=EB=93=9C=C2=B7?= =?UTF-8?q?=EC=B9=A9=20=ED=99=94=EB=A9=B4=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8,=20=EB=8F=88=20=ED=91=9C=ED=98=84=20=EA=B8=88?= =?UTF-8?q?=EC=A7=80=20=EA=B2=80=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/engine/betting: 한국식(체크·삥·콜·따당·쿼터·하프·풀·다이, 라운드당 1회·첫 행동에서만 레이즈), 노리밋·팟리밋(최소 레이즈, 불완전 올인 레이즈는 재오픈 안 함, BB 옵션), 앤티·블라인드, 올인, 받지 않은 베팅 반환, 사이드 팟 분할, 동점 분배(홀수 칩 순서), 시간 초과 자동 행동 - 테스트 24개: 포커·섯다 계획서 계산 예시, 행동별 상태 전이, 사이드 팟 예시, 무작위 베팅 1,500판 (칩 총량 보존·라운드 종료·라운드 끝 금액 일치·팟 합계=판 전체) - packages/engine/cards: 트럼프 52+조커, 화투 48(+보너스)·섯다 20장 정의와 테스트 - 웹: 직접 그린 SVG 트럼프·화투 카드, 칩 표시("1,250 칩"), 놀이용 칩 안내문 - apps/web/src/no-money-wording.test.ts: 베팅 게임 화면에 통화 기호·"원"·판돈·충전·환전 등 금지 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/cards/Chips.tsx | 35 ++ apps/web/src/components/cards/HwatuCard.tsx | 178 +++++++ apps/web/src/components/cards/PlayingCard.tsx | 102 ++++ apps/web/src/no-money-wording.test.ts | 39 ++ apps/web/tsconfig.json | 17 +- packages/engine/src/betting.test.ts | 376 ++++++++++++++ packages/engine/src/betting.ts | 489 ++++++++++++++++++ packages/engine/src/cards.test.ts | 32 ++ packages/engine/src/cards.ts | 103 ++++ packages/engine/src/index.ts | 2 + 10 files changed, 1370 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/cards/Chips.tsx create mode 100644 apps/web/src/components/cards/HwatuCard.tsx create mode 100644 apps/web/src/components/cards/PlayingCard.tsx create mode 100644 apps/web/src/no-money-wording.test.ts create mode 100644 packages/engine/src/betting.test.ts create mode 100644 packages/engine/src/betting.ts create mode 100644 packages/engine/src/cards.test.ts create mode 100644 packages/engine/src/cards.ts diff --git a/apps/web/src/components/cards/Chips.tsx b/apps/web/src/components/cards/Chips.tsx new file mode 100644 index 0000000..c8835ca --- /dev/null +++ b/apps/web/src/components/cards/Chips.tsx @@ -0,0 +1,35 @@ +/** + * Play-chip display (docs/04 §6): chips are room-only play points. + * Never shown as money: no currency symbols, no "원/돈/충전/환전" wording. + */ +export function formatChips(n: number): string { + return `${Math.max(0, Math.round(n)).toLocaleString('ko-KR')} 칩`; +} + +export function Chips({ n, strong, className }: { n: number; strong?: boolean; className?: string }) { + return ( + + {formatChips(n)} + + ); +} + +export function ChipIcon({ size = 16 }: { size?: number }) { + return ( + + + + + ); +} + +/** Shown once per room in betting games. */ +export const PLAY_CHIP_NOTICE = '칩은 이 방에서만 쓰는 놀이용 점수예요. 실제 가치가 없고 바꾸거나 주고받을 수 없으며, 방이 끝나면 사라져요.'; + +export function PlayChipNotice() { + return ( +

+ {PLAY_CHIP_NOTICE} +

+ ); +} diff --git a/apps/web/src/components/cards/HwatuCard.tsx b/apps/web/src/components/cards/HwatuCard.tsx new file mode 100644 index 0000000..947efa6 --- /dev/null +++ b/apps/web/src/components/cards/HwatuCard.tsx @@ -0,0 +1,178 @@ +/** + * Hwatu card drawn in SVG (our own simple artwork, no commercial hwatu images — docs/games/seotda.md §12). + * Large month number + kind badge so the card is readable without knowing the pictures. + */ +import type { CSSProperties, ReactNode } from 'react'; + +export interface HwatuInfo { + id: number; + month: number; // 0 for bonus + kind: 'gwang' | 'yeol' | 'tti' | 'pi' | 'ssangpi' | 'bonus'; + tti?: 'hong' | 'cheong' | 'cho' | 'plain'; + name: string; + piValue?: number; +} + +const MONTH_COLOR = ['#6b7280', '#15803d', '#be185d', '#db2777', '#1f2937', '#7c3aed', '#dc2626', '#b91c1c', '#111827', '#ca8a04', '#c2410c', '#7c2d12', '#1e40af']; +const MONTH_NAME = ['보너스', '송학', '매조', '벚꽃', '흑싸리', '난초', '모란', '홍싸리', '공산', '국화', '단풍', '오동', '비']; +const KIND_BADGE: Record = { gwang: '光', yeol: '열', tti: '띠', pi: '피', ssangpi: '쌍피', bonus: '+' }; +const TTI_COLOR = { hong: '#dc2626', cheong: '#2563eb', cho: '#15803d', plain: '#dc2626' }; +const TTI_TEXT = { hong: '홍단', cheong: '청단', cho: '', plain: '' }; + +/** A small motif per month (flowers/plants), drawn with basic shapes. */ +function Motif({ month, color }: { month: number; color: string }): ReactNode { + const c = color; + switch (month) { + case 1: // 송학: pine + return ; + case 2: // 매조: plum blossoms + case 3: // 벚꽃 + return ( + + {[[34, 70], [62, 62], [50, 92], [70, 100], [30, 108]].map(([x, y], i) => ( + + ))} + + ); + case 4: // 흑싸리 + case 7: // 홍싸리 + return ( + + {[30, 45, 60, 75].map((x, i) => ( + + ))} + + ); + case 5: // 난초: iris leaves + return ( + + + + + + ); + case 6: // 모란: peony + return ( + + + + + ); + case 8: // 공산: hill + return ; + case 9: // 국화 + return ( + + {Array.from({ length: 10 }, (_, i) => { + const a = (i / 10) * Math.PI * 2; + return ; + })} + + + ); + case 10: // 단풍: maple + return ; + case 11: // 오동 + return ( + + + + + ); + case 12: // 비: rain lines + umbrella + return ( + + {[20, 35, 50, 65, 80].map((x) => ( + + ))} + + ); + default: + return ★; + } +} + +export type HwatuSize = 'sm' | 'md' | 'lg'; +const WIDTH: Record = { sm: 40, md: 60, lg: 84 }; + +export function HwatuCard({ + card, + size = 'md', + selected, + dimmed, + highlight, + onClick, + style, +}: { + card: HwatuInfo | null; + size?: HwatuSize; + selected?: boolean; + dimmed?: boolean; + highlight?: boolean; + onClick?: () => void; + style?: CSSProperties; +}) { + const w = WIDTH[size]; + const h = Math.round(w * 1.6); + const Tag = onClick ? 'button' : 'span'; + const s: CSSProperties = { + width: w, + height: h, + flex: 'none', + display: 'inline-block', + padding: 0, + border: 0, + background: 'none', + cursor: onClick ? 'pointer' : 'default', + opacity: dimmed ? 0.45 : 1, + transform: selected ? `translateY(${-Math.round(w * 0.2)}px)` : undefined, + transition: 'transform 0.12s ease', + filter: highlight ? 'drop-shadow(0 0 6px #f59e0b)' : 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))', + ...style, + }; + if (!card) { + return ( + + + + + + + ); + } + const color = MONTH_COLOR[card.month] ?? '#374151'; + return ( + + + + + {card.kind === 'tti' && ( + + + {TTI_TEXT[card.tti ?? 'plain'] && ( + + {TTI_TEXT[card.tti ?? 'plain']} + + )} + + )} + {(card.kind === 'gwang' || card.kind === 'yeol') && ( + + + + {card.kind === 'gwang' ? '光' : '열'} + + + )} + {/* Month number: large and always readable */} + + + {card.month || '+'} + + + {MONTH_NAME[card.month]} · {card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]} + + + + ); +} diff --git a/apps/web/src/components/cards/PlayingCard.tsx b/apps/web/src/components/cards/PlayingCard.tsx new file mode 100644 index 0000000..3f2056b --- /dev/null +++ b/apps/web/src/components/cards/PlayingCard.tsx @@ -0,0 +1,102 @@ +/** Trump card drawn in SVG (no image assets). Big corner index for readability on PC and phones. */ +import type { CSSProperties } from 'react'; + +const SUIT_SYMBOL: Record = { S: '♠', H: '♥', D: '◆', C: '♣' }; +const SUIT_KO: Record = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' }; +const RANK_LABEL: Record = { T: '10', J: 'J', Q: 'Q', K: 'K', A: 'A' }; +const FACE_KO: Record = { J: '잭', Q: '퀸', K: '킹', A: '에이스' }; + +export type CardSize = 'sm' | 'md' | 'lg'; +const WIDTH: Record = { sm: 44, md: 64, lg: 88 }; + +export interface PlayingCardProps { + /** e.g. "AS", "TD", "X1" (joker). null = face down. */ + card: string | null; + size?: CardSize; + selected?: boolean; + dimmed?: boolean; + highlight?: boolean; + onClick?: () => void; + title?: string; + style?: CSSProperties; + /** Colour-blind friendly: 4-colour suits. */ + fourColor?: boolean; +} + +export function cardAria(card: string | null): string { + if (!card) return '뒤집힌 카드'; + if (card.startsWith('X')) return '조커'; + const r = card[0]!; + return `${SUIT_KO[card[1]!]} ${FACE_KO[r] ?? RANK_LABEL[r] ?? r}`; +} + +export function PlayingCard({ card, size = 'md', selected, dimmed, highlight, onClick, title, style, fourColor }: PlayingCardProps) { + const w = WIDTH[size]; + const h = Math.round(w * 1.4); + const lift = selected ? -Math.round(w * 0.22) : 0; + const common: CSSProperties = { + width: w, + height: h, + flex: 'none', + transform: `translateY(${lift}px)`, + transition: 'transform 0.12s ease', + cursor: onClick ? 'pointer' : 'default', + opacity: dimmed ? 0.45 : 1, + filter: highlight ? 'drop-shadow(0 0 6px #f59e0b)' : 'drop-shadow(0 1px 2px rgba(0,0,0,0.25))', + ...style, + }; + const Tag = onClick ? 'button' : 'span'; + const btnReset: CSSProperties = onClick ? { padding: 0, border: 0, background: 'none' } : {}; + if (!card) { + return ( + + + + + + + + ); + } + const joker = card.startsWith('X'); + const suit = card[1]!; + const rank = card[0]!; + const color = joker + ? card === 'X1' ? '#111827' : '#dc2626' + : fourColor + ? { S: '#111827', H: '#dc2626', D: '#2563eb', C: '#15803d' }[suit]! + : suit === 'H' || suit === 'D' + ? '#dc2626' + : '#111827'; + const label = joker ? '★' : (RANK_LABEL[rank] ?? rank); + const sym = joker ? '' : SUIT_SYMBOL[suit]; + return ( + + + + + {label} + + + {sym} + + {joker ? ( + + JOKER + + ) : 'JQK'.includes(rank) ? ( + <> + + + {rank} + + + ) : ( + + {sym} + + )} + + + ); +} diff --git a/apps/web/src/no-money-wording.test.ts b/apps/web/src/no-money-wording.test.ts new file mode 100644 index 0000000..7095d2d --- /dev/null +++ b/apps/web/src/no-money-wording.test.ts @@ -0,0 +1,39 @@ +/** + * Guard (docs/04 §6, reviewer request): betting-game UI text must never look like real money. + * Scans the betting games' screens and shared chip/card components for currency-like wording. + */ +import { expect, test } from 'bun:test'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'components/cards'].map((p) => join(import.meta.dir, p)); +const FORBIDDEN: [RegExp, string][] = [ + [/₩|\$\s?\d|\d\s?\$/, '통화 기호'], + [/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'], + [/판돈|돈을|돈이|돈은|현금|머니|골드/, '돈을 떠올리는 낱말'], + [/충전|환전|입금|출금|송금|결제|구매/, '거래 낱말'], +]; + +function files(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir).flatMap((f) => { + const p = join(dir, f); + return statSync(p).isDirectory() ? files(p) : /\.(tsx?|css)$/.test(f) ? [p] : []; + }); +} + +/** Only string literals / JSX text matter; comments are ignored. */ +function visibleText(src: string): string { + return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); +} + +test('no currency-like wording in betting game UI', () => { + const problems: string[] = []; + for (const f of ROOTS.flatMap(files)) { + const text = visibleText(readFileSync(f, 'utf8')); + text.split('\n').forEach((line, i) => { + for (const [re, why] of FORBIDDEN) if (re.test(line)) problems.push(`${f.split('/src/')[1]}:${i + 1} ${why}: ${line.trim().slice(0, 80)}`); + }); + } + expect(problems).toEqual([]); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4205d7e..f4e0c80 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,9 +1,20 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "lib": ["ES2023", "DOM", "DOM.Iterable"], + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], "jsx": "react-jsx", - "types": ["vite/client"] + "types": [ + "vite/client" + ] }, - "include": ["src"] + "include": [ + "src" + ], + "exclude": [ + "src/**/*.test.ts" + ] } diff --git a/packages/engine/src/betting.test.ts b/packages/engine/src/betting.test.ts new file mode 100644 index 0000000..fa57932 --- /dev/null +++ b/packages/engine/src/betting.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, test } from 'bun:test'; +import { + applyBet, + awardPots, + bettingClosed, + buildPots, + chipsInPlay, + formatChips, + handDecided, + legalBets, + newBetting, + postAnte, + postBlind, + potTotal, + returnUncalled, + roundComplete, + setFirstToAct, + splitAmount, + startRound, + timeoutBet, + validateBet, + type BetAction, + type BettingState, +} from './betting'; +import { SeededRng } from './rng'; + +const ids = (n: number, stack = 1000) => Array.from({ length: n }, (_, i) => ({ id: 'ABCDEFGHIJ'[i]!, stack })); +const kinds = (s: BettingState, i: number) => legalBets(s, i).map((l) => l.kind); +function act(s: BettingState, i: number, a: BetAction): BettingState { + return applyBet(s, i, a).state; +} +const idx = (s: BettingState, id: string) => s.players.findIndex((p) => p.id === id); + +describe('korean betting — documented examples', () => { + test('poker.md §5.2: 4 players, ante 10, ping → half → double → die → call → call = 280', () => { + let s = newBetting(ids(4), { style: 'korean', baseUnit: 10 }); + s = postAnte(s, 10); + s = startRound(s, 0); + expect(potTotal(s)).toBe(40); + s = act(s, 0, { kind: 'ping' }); + expect([potTotal(s), s.currentBet]).toEqual([50, 10]); + expect(legalBets(s, 1).find((l) => l.kind === 'half')!.amount).toBe(40); + s = act(s, 1, { kind: 'half' }); + expect([potTotal(s), s.currentBet]).toEqual([90, 40]); + s = act(s, 2, { kind: 'double' }); + expect([potTotal(s), s.currentBet]).toEqual([170, 80]); + s = act(s, 3, { kind: 'die' }); + expect(s.turn).toBe(0); + expect(kinds(s, 0)).toEqual(['call', 'die']); // already pinged → no re-raise + s = act(s, 0, { kind: 'call' }); + expect(potTotal(s)).toBe(240); + expect(kinds(s, 1)).toEqual(['call', 'die']); + s = act(s, 1, { kind: 'call' }); + expect(potTotal(s)).toBe(280); + expect(s.turn).toBeNull(); + expect(roundComplete(s)).toBe(true); + expect(chipsInPlay(s)).toBe(4000); + }); + + test('seotda.md §5.4: 3 players, 학교 10, ping → half 35 → double 70 → call → call = 240', () => { + let s = newBetting(ids(3), { style: 'korean', baseUnit: 10 }); + s = startRound(postAnte(s, 10), 0); + s = act(s, 0, { kind: 'ping' }); + expect(legalBets(s, 1).find((l) => l.kind === 'half')!.amount).toBe(35); + s = act(s, 1, { kind: 'half' }); + s = act(s, 2, { kind: 'double' }); + expect(s.currentBet).toBe(70); + s = act(s, 0, { kind: 'call' }); + s = act(s, 1, { kind: 'call' }); + expect(potTotal(s)).toBe(240); + expect(s.turn).toBeNull(); + }); +}); + +describe('korean betting — state transitions', () => { + const fresh = (n = 3) => startRound(postAnte(newBetting(ids(n), { style: 'korean', baseUnit: 10 }), 10), 0); + + test('unopened round offers check/ping/quarter/half/die; full only when allowed', () => { + const s = fresh(); + expect(kinds(s, 0)).toEqual(['check', 'ping', 'quarter', 'half', 'die']); + const full = startRound(postAnte(newBetting(ids(3), { style: 'korean', baseUnit: 10, koreanMax: 'full' }), 10), 0); + expect(kinds(full, 0)).toEqual(['check', 'ping', 'quarter', 'half', 'full', 'die']); + // quarter of 30 = 7 → bumped to the base unit 10 + expect(legalBets(s, 0).find((l) => l.kind === 'quarter')!.amount).toBe(10); + expect(legalBets(s, 0).find((l) => l.kind === 'half')!.amount).toBe(15); + }); + + test('everyone checks → round complete, nothing added', () => { + let s = fresh(); + s = act(s, 0, { kind: 'check' }); + expect(kinds(s, 1)).toContain('ping'); + s = act(s, 1, { kind: 'check' }); + s = act(s, 2, { kind: 'check' }); + expect(s.turn).toBeNull(); + expect(potTotal(s)).toBe(30); + }); + + test('check then someone bets: the checker may only call or die (no check-raise)', () => { + let s = fresh(); + s = act(s, 0, { kind: 'check' }); + s = act(s, 1, { kind: 'ping' }); + s = act(s, 2, { kind: 'call' }); + expect(s.turn).toBe(0); + expect(kinds(s, 0)).toEqual(['call', 'die']); + s = act(s, 0, { kind: 'call' }); + expect(s.turn).toBeNull(); + }); + + test('caller cannot raise later in the same round (no call-raise)', () => { + let s = fresh(); + s = act(s, 0, { kind: 'ping' }); + s = act(s, 1, { kind: 'call' }); + s = act(s, 2, { kind: 'half' }); + expect(kinds(s, 1)).toEqual([]); // not their turn yet: A acts first + expect(s.turn).toBe(0); + expect(kinds(s, 0)).toEqual(['call', 'die']); // pinged already + s = act(s, 0, { kind: 'call' }); + expect(kinds(s, 1)).toEqual(['call', 'die']); // called already → no raise + s = act(s, 1, { kind: 'call' }); + expect(s.turn).toBeNull(); + }); + + test('a new round resets raise rights', () => { + let s = fresh(); + s = act(s, 0, { kind: 'ping' }); + s = act(s, 1, { kind: 'call' }); + s = act(s, 2, { kind: 'call' }); + s = startRound(s, 0); + expect(kinds(s, 0)).toContain('ping'); + expect(s.players.every((p) => p.round === 0 && !p.acted && !p.raised)).toBe(true); + }); + + test('die until one player remains → hand decided, round over', () => { + let s = fresh(); + s = act(s, 0, { kind: 'ping' }); + s = act(s, 1, { kind: 'die' }); + s = act(s, 2, { kind: 'die' }); + expect(handDecided(s)).toBe(true); + expect(s.turn).toBeNull(); + const back = returnUncalled(s); + expect(back.refunded).toEqual({ id: 'A', amount: 10 }); // the ping nobody called comes back + expect(potTotal(back.state)).toBe(30); + }); + + test('raise that exceeds the stack becomes an all-in; short all-in call creates a side pot', () => { + let s = newBetting([{ id: 'A', stack: 1000 }, { id: 'B', stack: 60 }, { id: 'C', stack: 1000 }], { style: 'korean', baseUnit: 10 }); + s = startRound(postAnte(s, 10), 0); + s = act(s, 0, { kind: 'half' }); // 15 + const bHalf = legalBets(s, 1).find((l) => l.kind === 'half')!; + expect(bHalf.allIn).toBe(false); + s = act(s, 1, { kind: 'double' }); // 30 of 50 left + s = act(s, 2, { kind: 'half' }); + expect(s.currentBet).toBeGreaterThan(50); + s = act(s, 0, { kind: 'call' }); + // B has 20 left and owes more → call is a partial all-in + const call = legalBets(s, 1).find((l) => l.kind === 'call')!; + expect(call).toMatchObject({ amount: 20, allIn: true }); + s = act(s, 1, { kind: 'call' }); + expect(s.players[1]!.status).toBe('allIn'); + expect(s.turn).toBeNull(); + const pots = buildPots(s.players); + expect(pots[0]).toEqual({ amount: 180, eligible: ['A', 'B', 'C'] }); // 60 each + expect(pots.reduce((a, p) => a + p.amount, 0)).toBe(potTotal(s)); + expect(pots[1]!.eligible).toEqual(['A', 'C']); + }); + + test('all-in players are skipped; betting closes when ≤1 player can still act', () => { + let s = newBetting([{ id: 'A', stack: 20 }, { id: 'B', stack: 500 }], { style: 'korean', baseUnit: 10 }); + s = startRound(postAnte(s, 10), 0); + s = act(s, 0, { kind: 'ping' }); // A all-in with the ping + expect(s.players[0]!.status).toBe('allIn'); + s = act(s, 1, { kind: 'call' }); + expect(s.turn).toBeNull(); + expect(bettingClosed(s)).toBe(true); + s = startRound(s, 0); + expect(s.turn).toBeNull(); // next round has no decisions + }); + + test('timeouts: check when possible, otherwise die', () => { + let s = fresh(); + expect(timeoutBet(s, 0)).toEqual({ kind: 'check' }); + s = act(s, 0, { kind: 'ping' }); + expect(timeoutBet(s, 1)).toEqual({ kind: 'die' }); + }); + + test('validation messages', () => { + const s = fresh(); + expect(validateBet(s, 1, { kind: 'check' })).toBe('지금은 내 차례가 아니에요.'); + expect(validateBet(s, 0, { kind: 'call' })).toBe('지금은 콜을(를) 할 수 없어요.'); + expect(validateBet(s, 0, { kind: 'raise', to: 50 })).toBe('지금은 올릴 수 없어요.'); + expect(() => applyBet(s, 0, { kind: 'double' })).toThrow(); + }); +}); + +describe('no-limit / pot-limit', () => { + /** Heads-up or ring hold'em preflop: SB/BB posted, first actor given. */ + function preflop(n: number, style: 'noLimit' | 'potLimit' = 'noLimit', stacks?: number[]) { + let s = newBetting( + ids(n).map((p, i) => ({ ...p, stack: stacks?.[i] ?? p.stack })), + { style, baseUnit: 10 }, + ); + s = startRound(s, 0); + const sb = n === 2 ? 0 : 1; + const bb = n === 2 ? 1 : 2; + s = postBlind(s, sb, 5); + s = postBlind(s, bb, 10); + s = setFirstToAct(s, n === 2 ? 0 : 3 % n); + return s; + } + + test('min raise = current bet + last raise; raise 30 makes next min 50', () => { + let s = preflop(4); + expect(s.turn).toBe(3); + const r = legalBets(s, 3).find((l) => l.kind === 'raise')!; + expect([r.minTo, r.maxTo]).toEqual([20, 1000]); + s = act(s, 3, { kind: 'raise', to: 30 }); + expect(legalBets(s, 0).find((l) => l.kind === 'raise')!.minTo).toBe(50); + expect(validateBet(s, 0, { kind: 'raise', to: 40 })).toBe('50~1000 칩 사이로 올려 주세요.'); + }); + + test('big blind option: limpers → BB may check or raise', () => { + let s = preflop(3); + s = act(s, 0, { kind: 'call' }); // dealer/UTG at seat 0 in 3-handed + s = act(s, 1, { kind: 'call' }); // SB completes + expect(s.turn).toBe(2); + expect(kinds(s, 2)).toEqual(['fold', 'check', 'raise', 'allIn']); + s = act(s, 2, { kind: 'check' }); + expect(s.turn).toBeNull(); + expect(potTotal(s)).toBe(30); + }); + + test('heads-up: dealer (SB) acts first preflop', () => { + const s = preflop(2); + expect(s.turn).toBe(0); + expect(kinds(s, 0)).toContain('call'); + }); + + test('incomplete all-in raise does not reopen betting for players who already acted', () => { + // A raises to 100 (raise size 90), B goes all-in for 150 (only +50, incomplete), A may only call/fold. + let s = preflop(3, 'noLimit', [1000, 150, 1000]); + s = act(s, 0, { kind: 'raise', to: 100 }); + s = act(s, 1, { kind: 'allIn' }); + expect(s.currentBet).toBe(150); + expect(s.lastRaise).toBe(90); + // C has not acted yet → may still raise (min 150 + 90) + expect(legalBets(s, 2).find((l) => l.kind === 'raise')!.minTo).toBe(240); + s = act(s, 2, { kind: 'call' }); + expect(kinds(s, 0)).toEqual(['fold', 'call']); + s = act(s, 0, { kind: 'call' }); + expect(s.turn).toBeNull(); + }); + + test('full raise re-opens betting for everyone', () => { + let s = preflop(3); + s = act(s, 0, { kind: 'raise', to: 40 }); + s = act(s, 1, { kind: 'call' }); + s = act(s, 2, { kind: 'raise', to: 100 }); + expect(kinds(s, 0)).toContain('raise'); + }); + + test('pot-limit maximum (poker.md §5.3): pot 100, opponent bets 20 → max raise to 160', () => { + let s = newBetting(ids(2), { style: 'potLimit', baseUnit: 10 }); + s = postAnte(s, 50); // pot 100 from earlier rounds + s = startRound(s, 0); + s = act(s, 0, { kind: 'raise', to: 20 }); // pot 120 + const r = legalBets(s, 1).find((l) => l.kind === 'raise')!; + expect(potTotal(s)).toBe(120); + expect(r.maxTo).toBe(160); + expect(validateBet(s, 1, { kind: 'raise', to: 161 })).not.toBeNull(); + }); + + test('uncalled bet is returned when everyone folds', () => { + let s = preflop(3); + s = act(s, 0, { kind: 'raise', to: 300 }); + s = act(s, 1, { kind: 'fold' }); + s = act(s, 2, { kind: 'fold' }); + expect(handDecided(s)).toBe(true); + const r = returnUncalled(s); + expect(r.refunded).toEqual({ id: 'A', amount: 290 }); + expect(potTotal(r.state)).toBe(25); + expect(chipsInPlay(r.state)).toBe(3000); + }); +}); + +describe('pots and awards', () => { + test('poker.md §5.5 example: A 100 / B 300 all-in / C 300 → main 300, side 400', () => { + const pots = buildPots([ + { id: 'A', total: 100, status: 'allIn' }, + { id: 'B', total: 300, status: 'allIn' }, + { id: 'C', total: 300, status: 'active' }, + ]); + expect(pots).toEqual([ + { amount: 300, eligible: ['A', 'B', 'C'] }, + { amount: 400, eligible: ['B', 'C'] }, + ]); + }); + + test('folded chips stay in the pots they reached', () => { + const pots = buildPots([ + { id: 'A', total: 50, status: 'allIn' }, + { id: 'B', total: 80, status: 'folded' }, + { id: 'C', total: 200, status: 'active' }, + { id: 'D', total: 200, status: 'active' }, + ]); + expect(pots).toEqual([ + { amount: 200, eligible: ['A', 'C', 'D'] }, + { amount: 330, eligible: ['C', 'D'] }, + ]); + }); + + test('award: side pot to the best eligible, ties split with odd chip by seat order', () => { + const rank: Record = { A: 9, B: 5, C: 5 }; + const pots = [ + { amount: 301, eligible: ['A', 'B', 'C'] }, + { amount: 401, eligible: ['B', 'C'] }, + ]; + const won = awardPots(pots, (x, y) => rank[x]! - rank[y]!, ['C', 'A', 'B']); + expect(won).toEqual({ A: 301, C: 201, B: 200 }); + expect(splitAmount(10, ['X', 'Y', 'Z'])).toEqual({ X: 4, Y: 3, Z: 3 }); + }); + + test('chips are shown as play chips, never money', () => { + expect(formatChips(12500)).toBe('12,500 칩'); + expect(formatChips(5)).not.toMatch(/원|₩|\$/); + }); +}); + +describe('property: random betting sequences', () => { + test('chips conserved, rounds always terminate, pots partition the total, complete rounds are matched', () => { + for (let seed = 1; seed <= 1500; seed++) { + const rng = SeededRng.fromSeed([seed, seed * 7, seed * 13, 99]); + const style = (['korean', 'noLimit', 'potLimit'] as const)[seed % 3]!; + const n = 2 + rng.int(7); + const players = Array.from({ length: n }, (_, i) => ({ id: `P${i}`, stack: 1 + rng.int(400) })); + const total = players.reduce((a, p) => a + p.stack, 0); + let s = newBetting(players, { style, baseUnit: 10, koreanMax: rng.int(2) ? 'full' : 'half' }); + if (style === 'korean') s = postAnte(s, 5); + for (let round = 0; round < 4 && !bettingClosed(s); round++) { + s = startRound(s, rng.int(n)); + if (style !== 'korean' && round === 0) { + s = postBlind(s, 0, 5); + s = postBlind(s, 1 % n, 10); + s = setFirstToAct(s, 2 % n); + } + let steps = 0; + while (s.turn !== null) { + const legal = legalBets(s, s.turn); + expect(legal.length).toBeGreaterThan(0); + const pick = legal[rng.int(legal.length)]!; + const a: BetAction = + pick.kind === 'raise' ? { kind: 'raise', to: pick.minTo! + rng.int(pick.maxTo! - pick.minTo! + 1) } : ({ kind: pick.kind } as BetAction); + expect(validateBet(s, s.turn, a)).toBeNull(); + s = applyBet(s, s.turn, a).state; + expect(chipsInPlay(s)).toBe(total); + expect(s.players.every((p) => p.stack >= 0 && p.round >= 0)).toBe(true); + if (++steps > 4 * n + 4) throw new Error(`round did not terminate (seed ${seed})`); + } + expect(roundComplete(s)).toBe(true); + for (const p of s.players) if (p.status === 'active' && !handDecided(s)) expect(p.round).toBe(s.currentBet); + s = returnUncalled(s).state; + expect(chipsInPlay(s)).toBe(total); + } + const pots = buildPots(s.players); + expect(pots.reduce((a, p) => a + p.amount, 0)).toBe(potTotal(s)); + for (const pot of pots) { + expect(pot.eligible.length).toBeGreaterThan(0); + for (const id of pot.eligible) expect(['active', 'allIn']).toContain(s.players[idx(s, id)]!.status); + } + // Award everything with random hand strengths: chips stay conserved. + const strength = Object.fromEntries(s.players.map((p) => [p.id, rng.int(5)])); + const won = awardPots(pots, (a, b) => strength[a]! - strength[b]!, s.players.map((p) => p.id)); + const after = s.players.reduce((a, p) => a + p.stack, 0) + Object.values(won).reduce((a, b) => a + b, 0); + expect(after).toBe(total); + } + }); +}); diff --git a/packages/engine/src/betting.ts b/packages/engine/src/betting.ts new file mode 100644 index 0000000..c0daa47 --- /dev/null +++ b/packages/engine/src/betting.ts @@ -0,0 +1,489 @@ +/** + * Shared betting engine for poker modes and seotda (docs/games/poker.md §5.2, §5.3, §5.5; + * docs/games/seotda.md §5.4). Pure functions over a JSON state. + * + * Chips here are room-only play points (no money value, docs/04 §6). + * + * Styles: + * - korean: check / 삥(ping) / call / 따당(double) / quarter / half / full / die. + * One raise-type action (ping included) per player per round, and only as that + * player's first action in the round (no check-raise, no call-raise). + * - noLimit: fold / check / call / bet-raise (raiseTo between min and stack) / all-in. + * - potLimit: same, max raiseTo = currentBet + (pot + toCall). + */ + +export type BetStyle = 'korean' | 'noLimit' | 'potLimit'; +export type KoreanMax = 'half' | 'full'; +export type BetterStatus = 'active' | 'folded' | 'allIn' | 'out'; + +export interface Better { + id: string; + stack: number; + /** Chips put in during the current round (blinds included, antes excluded). */ + round: number; + /** Chips put in during the whole hand (antes included). */ + total: number; + status: BetterStatus; + /** Has taken a voluntary action this round (posting a blind does not count). */ + acted: boolean; + /** korean: already used the one raise-type action this round. */ + raised: boolean; + /** noLimit/potLimit: may raise when next to act (false after an incomplete all-in raise). */ + mayRaise: boolean; +} + +export interface BettingState { + style: BetStyle; + baseUnit: number; + koreanMax: KoreanMax; + /** Seat order (index = seat in this hand). */ + players: Better[]; + currentBet: number; + /** korean: someone has put chips in this round. */ + opened: boolean; + /** noLimit/potLimit: size of the last full raise (starts at baseUnit). */ + lastRaise: number; + /** Index of the player to act, or null when the round is complete. */ + turn: number | null; +} + +export type KoreanKind = 'check' | 'ping' | 'call' | 'double' | 'quarter' | 'half' | 'full' | 'die'; +export type BetAction = + | { kind: KoreanKind } + | { kind: 'fold' } + | { kind: 'check' } + | { kind: 'call' } + | { kind: 'raise'; to: number } + | { kind: 'allIn' }; + +export interface LegalBet { + kind: BetAction['kind']; + /** Chips that leave the stack for this action (capped at the stack → all-in). */ + amount: number; + /** True when the amount is the whole stack. */ + allIn: boolean; + /** noLimit/potLimit raise range (raiseTo totals for this round). */ + minTo?: number; + maxTo?: number; +} + +export interface BetEvent { + type: 'bet'; + player: string; + kind: BetAction['kind']; + amount: number; + allIn: boolean; +} + +// ---------------------------------------------------------------- setup + +export function newBetting( + ids: { id: string; stack: number }[], + opts: { style: BetStyle; baseUnit: number; koreanMax?: KoreanMax }, +): BettingState { + return { + style: opts.style, + baseUnit: opts.baseUnit, + koreanMax: opts.koreanMax ?? 'half', + players: ids.map((p) => ({ + id: p.id, + stack: p.stack, + round: 0, + total: 0, + status: p.stack > 0 ? 'active' : 'out', + acted: false, + raised: false, + mayRaise: true, + })), + currentBet: 0, + opened: false, + lastRaise: opts.baseUnit, + turn: null, + }; +} + +const clone = (s: BettingState): BettingState => structuredClone(s); + +export const potTotal = (s: BettingState): number => s.players.reduce((a, p) => a + p.total, 0); +export const chipsInPlay = (s: BettingState): number => s.players.reduce((a, p) => a + p.total + p.stack, 0); +export const contenders = (s: BettingState): Better[] => s.players.filter((p) => p.status === 'active' || p.status === 'allIn'); +export const canAct = (p: Better): boolean => p.status === 'active'; + +function put(p: Better, want: number, toRound: boolean): number { + const amt = Math.max(0, Math.min(want, p.stack)); + p.stack -= amt; + p.total += amt; + if (toRound) p.round += amt; + if (p.stack === 0 && p.status === 'active') p.status = 'allIn'; + return amt; +} + +/** Ante: goes to the pot, not to the round (korean 학교 / 7포커 기본 판돈). */ +export function postAnte(state: BettingState, amount: number): BettingState { + const s = clone(state); + for (const p of s.players) if (p.status === 'active') put(p, amount, false); + return s; +} + +/** Blind: counts towards the round but is not a voluntary action. */ +export function postBlind(state: BettingState, idx: number, amount: number): BettingState { + const s = clone(state); + const p = s.players[idx]!; + if (p.status !== 'active') return s; + put(p, amount, true); + s.currentBet = Math.max(s.currentBet, p.round); + s.opened = s.currentBet > 0; + return s; +} + +/** Starts a betting round. Blinds (if any) must be posted after this call. */ +export function startRound(state: BettingState, firstIdx: number): BettingState { + const s = clone(state); + for (const p of s.players) { + p.round = 0; + p.acted = false; + p.raised = false; + p.mayRaise = true; + } + s.currentBet = 0; + s.opened = false; + s.lastRaise = s.baseUnit; + s.turn = null; + s.turn = findNext(s, firstIdx - 1); + if (roundComplete(s)) s.turn = null; + return s; +} + +/** After posting blinds, (re)select who acts first. */ +export function setFirstToAct(state: BettingState, firstIdx: number): BettingState { + const s = clone(state); + s.turn = findNext(s, firstIdx - 1); + if (roundComplete(s)) s.turn = null; + return s; +} + +// ---------------------------------------------------------------- queries + +export const toCallOf = (s: BettingState, p: Better): number => Math.max(0, s.currentBet - p.round); + +function needsAction(s: BettingState, p: Better): boolean { + return p.status === 'active' && (!p.acted || p.round < s.currentBet); +} + +/** True when nobody needs to act any more in this round. */ +export function roundComplete(s: BettingState): boolean { + if (contenders(s).length <= 1) return true; + const actives = s.players.filter(canAct); + if (actives.length === 0) return true; + if (actives.length === 1 && actives[0]!.round >= s.currentBet) { + // Lone player facing only all-ins and nothing to call: nothing to decide. + const others = contenders(s).filter((p) => p !== actives[0]); + if (others.every((p) => p.status === 'allIn')) return true; + } + return actives.every((p) => !needsAction(s, p)); +} + +/** Only one player left who has not folded → hand is decided without showdown. */ +export const handDecided = (s: BettingState): boolean => contenders(s).length <= 1; + +/** No more betting possible for the rest of the hand (≤1 player can still act and nothing is owed). */ +export function bettingClosed(s: BettingState): boolean { + const actives = s.players.filter(canAct); + return handDecided(s) || (actives.length <= 1 && roundComplete(s)); +} + +function findNext(s: BettingState, from: number): number | null { + const n = s.players.length; + for (let k = 1; k <= n; k++) { + const i = (((from + k) % n) + n) % n; + if (needsAction(s, s.players[i]!)) return i; + } + return null; +} + +function koreanAmount(s: BettingState, p: Better, kind: KoreanKind): number { + const toCall = toCallOf(s, p); + const pot = potTotal(s); + const bump = (extra: number) => toCall + Math.max(extra, s.baseUnit); + switch (kind) { + case 'check': + case 'die': + return 0; + case 'ping': + return s.baseUnit; + case 'call': + return toCall; + case 'double': + return 2 * toCall; + case 'quarter': + return bump(Math.floor((pot + toCall) / 4)); + case 'half': + return bump(Math.floor((pot + toCall) / 2)); + case 'full': + return bump(pot + toCall); + } +} + +/** All legal betting actions for player `idx` (empty if it is not their turn). */ +export function legalBets(s: BettingState, idx: number): LegalBet[] { + if (s.turn !== idx) return []; + const p = s.players[idx]!; + const toCall = toCallOf(s, p); + const mk = (kind: BetAction['kind'], want: number, extra: Partial = {}): LegalBet => ({ + kind, + amount: Math.min(want, p.stack), + allIn: want >= p.stack && want > 0, + ...extra, + }); + if (s.style === 'korean') { + const out: LegalBet[] = []; + const mayRaise = !p.acted && !p.raised; + if (!s.opened) { + out.push(mk('check', 0)); + if (mayRaise) out.push(mk('ping', s.baseUnit)); + } else if (toCall > 0) out.push(mk('call', toCall)); + else out.push(mk('check', 0)); + if (mayRaise) { + const raises: KoreanKind[] = toCall > 0 ? ['double', 'quarter', 'half'] : ['quarter', 'half']; + if (s.koreanMax === 'full') raises.push('full'); + for (const k of raises) { + const amt = koreanAmount(s, p, k); + // A raise must put in more than the call; if the stack can't, the call/all-in covers it. + if (amt > toCall && p.stack > toCall) out.push(mk(k, amt)); + } + } + out.push(mk('die', 0)); + return dedupeAllIn(out); + } + // noLimit / potLimit + const out: LegalBet[] = [mk('fold', 0)]; + if (toCall === 0) out.push(mk('check', 0)); + else out.push(mk('call', toCall)); + if (p.mayRaise && p.stack > toCall) { + const minTo = s.currentBet === 0 ? s.baseUnit : s.currentBet + s.lastRaise; + const stackTo = p.round + p.stack; + let maxTo = stackTo; + if (s.style === 'potLimit') maxTo = Math.min(stackTo, s.currentBet + potTotal(s) + toCall); + if (stackTo > s.currentBet) { + if (minTo <= maxTo) out.push({ kind: 'raise', amount: minTo - p.round, allIn: minTo >= stackTo, minTo, maxTo }); + if (s.style === 'noLimit' || stackTo <= maxTo) out.push(mk('allIn', p.stack)); + } + } + return out; +} + +function dedupeAllIn(list: LegalBet[]): LegalBet[] { + // Several korean raises may all cap at the stack; keep the smallest-named one plus call/die. + const seen = new Set(); + return list.filter((b) => { + if (!b.allIn || b.kind === 'call' || b.kind === 'die') return true; + if (seen.has(b.amount)) return false; + seen.add(b.amount); + return true; + }); +} + +/** Returns a Korean error message if the action is illegal, else null. */ +export function validateBet(s: BettingState, idx: number, a: BetAction): string | null { + if (s.turn !== idx) return '지금은 내 차례가 아니에요.'; + const legal = legalBets(s, idx); + if (a.kind === 'raise') { + const r = legal.find((l) => l.kind === 'raise'); + if (!r) return '지금은 올릴 수 없어요.'; + const p = s.players[idx]!; + const stackTo = p.round + p.stack; + if (!Number.isInteger(a.to) || a.to < r.minTo! || a.to > r.maxTo!) { + if (a.to === stackTo && legal.some((l) => l.kind === 'allIn')) return null; + return `${r.minTo}~${r.maxTo} 칩 사이로 올려 주세요.`; + } + return null; + } + if (!legal.some((l) => l.kind === a.kind)) { + const names: Record = { + check: '체크', ping: '삥', call: '콜', double: '따당', quarter: '쿼터', half: '하프', full: '풀', die: '다이', fold: '폴드', allIn: '올인', + }; + return `지금은 ${names[a.kind] ?? a.kind}을(를) 할 수 없어요.`; + } + return null; +} + +// ---------------------------------------------------------------- apply + +/** Applies a validated action. Advances the turn; `turn === null` means the round is over. */ +export function applyBet(state: BettingState, idx: number, a: BetAction): { state: BettingState; event: BetEvent } { + const err = validateBet(state, idx, a); + if (err) throw new Error(`illegal bet: ${err}`); + const s = clone(state); + const p = s.players[idx]!; + const before = p.round; + let paid = 0; + const raiseTo = (to: number) => { + paid = put(p, to - p.round, true); + const size = p.round - s.currentBet; + const full = size >= s.lastRaise; + if (p.round > s.currentBet) { + if (full || s.style === 'korean') { + if (s.style !== 'korean') s.lastRaise = size; + // A full raise reopens the action for everyone else. + for (const o of s.players) if (o !== p && o.status === 'active') o.mayRaise = true; + } else { + // Incomplete all-in raise: players who already acted may only call or fold. + for (const o of s.players) if (o !== p && o.status === 'active' && o.acted) o.mayRaise = false; + } + s.currentBet = p.round; + } + }; + switch (a.kind) { + case 'check': + break; + case 'die': + case 'fold': + p.status = 'folded'; + break; + case 'call': + paid = put(p, toCallOf(s, p), true); + break; + case 'ping': + case 'double': + case 'quarter': + case 'half': + case 'full': { + const amt = koreanAmount(s, p, a.kind); + raiseTo(p.round + amt); + p.raised = true; + s.opened = true; + break; + } + case 'raise': + raiseTo(Math.min(a.to, p.round + p.stack)); + break; + case 'allIn': + if (p.round + p.stack > s.currentBet) raiseTo(p.round + p.stack); + else paid = put(p, p.stack, true); + break; + } + if (s.style === 'korean' && p.round > 0) s.opened = true; + p.acted = true; + if (p.stack === 0 && p.status === 'active') p.status = 'allIn'; + s.turn = roundComplete(s) ? null : findNext(s, idx); + return { + state: s, + event: { type: 'bet', player: p.id, kind: a.kind, amount: p.round - before || paid, allIn: p.status === 'allIn' && paid > 0 }, + }; +} + +/** + * Auto action on timeout / disconnect (docs: korean → check if possible else die; + * noLimit → check if possible else fold). + */ +export function timeoutBet(s: BettingState, idx: number): BetAction { + const legal = legalBets(s, idx); + if (legal.some((l) => l.kind === 'check')) return { kind: 'check' }; + return s.style === 'korean' ? { kind: 'die' } : { kind: 'fold' }; +} + +// ---------------------------------------------------------------- pots + +export interface Pot { + amount: number; + /** Player ids eligible to win this pot (not folded). */ + eligible: string[]; +} + +/** + * Returns the uncalled part of the biggest contribution to its owner + * (the amount above the second-largest total). Call when a round ends or the hand is decided. + */ +export function returnUncalled(state: BettingState): { state: BettingState; refunded: { id: string; amount: number } | null } { + const s = clone(state); + const sorted = [...s.players].sort((a, b) => b.total - a.total); + const top = sorted[0]; + const second = sorted[1]?.total ?? 0; + if (!top || top.total <= second) return { state: s, refunded: null }; + const extra = top.total - second; + top.total -= extra; + top.round = Math.max(0, top.round - extra); + top.stack += extra; + if (top.status === 'allIn' && top.stack > 0) top.status = 'active'; + s.currentBet = Math.max(...s.players.map((p) => p.round)); + return { state: s, refunded: { id: top.id, amount: extra } }; +} + +/** + * Splits everything put in this hand into main/side pots (poker.md §5.5). + * Levels are the totals of non-folded all-in players; folded chips stay in the pots they reached. + * Side pots come last; callers usually award them first. + */ +export function buildPots(players: Pick[]): Pot[] { + const live = players.filter((p) => p.status === 'active' || p.status === 'allIn'); + const levels = [...new Set(live.filter((p) => p.status === 'allIn').map((p) => p.total))].sort((a, b) => a - b); + const maxLive = Math.max(0, ...live.map((p) => p.total)); + if (!levels.includes(maxLive)) levels.push(maxLive); + const pots: Pot[] = []; + let prev = 0; + for (const level of levels) { + 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); + if (amount > 0) pots.push({ amount, eligible }); + prev = level; + } + // Chips from folded players above the top live level (only possible if a folder over-contributed). + const leftover = players.reduce((a, p) => a + Math.max(0, p.total - prev), 0); + if (leftover > 0) { + if (pots.length) pots[pots.length - 1]!.amount += leftover; + else pots.push({ amount: leftover, eligible: live.map((p) => p.id) }); + } + // Merge consecutive pots with identical eligibility. + const merged: Pot[] = []; + for (const pot of pots) { + const last = merged[merged.length - 1]; + if (last && last.eligible.join() === pot.eligible.join()) last.amount += pot.amount; + else merged.push({ ...pot, eligible: [...pot.eligible] }); + } + return merged; +} + +/** + * Splits `amount` among `winners` (already ordered by priority for odd chips, + * e.g. seat order starting left of the dealer). Returns id → chips. + */ +export function splitAmount(amount: number, winners: string[]): Record { + const out: Record = {}; + if (winners.length === 0) return out; + const share = Math.floor(amount / winners.length); + let rest = amount - share * winners.length; + for (const w of winners) { + out[w] = (out[w] ?? 0) + share + (rest > 0 ? 1 : 0); + if (rest > 0) rest--; + } + return out; +} + +/** + * Awards every pot to the best eligible hand(s). `compare(a, b)` > 0 when a beats b. + * `order` gives odd-chip priority (seat order from left of dealer). + */ +export function awardPots(pots: Pot[], compare: (a: string, b: string) => number, order: string[]): Record { + const won: Record = {}; + for (const pot of pots) { + let best: string[] = []; + for (const id of pot.eligible) { + if (best.length === 0) best = [id]; + else { + const c = compare(id, best[0]!); + if (c > 0) best = [id]; + else if (c === 0) best.push(id); + } + } + best.sort((x, y) => order.indexOf(x) - order.indexOf(y)); + for (const [id, amt] of Object.entries(splitAmount(pot.amount, best))) won[id] = (won[id] ?? 0) + amt; + } + return won; +} + +/** Display helper shared by UIs: never a currency (docs/04 §6). */ +export function formatChips(n: number): string { + return `${n.toLocaleString('ko-KR')} 칩`; +} diff --git a/packages/engine/src/cards.test.ts b/packages/engine/src/cards.test.ts new file mode 100644 index 0000000..45a3ab6 --- /dev/null +++ b/packages/engine/src/cards.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from 'bun:test'; +import { HWATU, SEOTDA_DECK, cardLabel, hwatu, hwatuBonus, rankValue, standardDeck } from './cards'; + +test('standard deck', () => { + const d = standardDeck(2); + expect(d).toHaveLength(54); + expect(new Set(d).size).toBe(54); + expect(rankValue('AS')).toBe(14); + expect(rankValue('2C')).toBe(2); + expect(cardLabel('TD')).toBe('◆10'); + expect(cardLabel('X1')).toBe('조커'); +}); + +test('hwatu composition matches gostop.md §3.1', () => { + expect(HWATU).toHaveLength(48); + const count = (k: string) => HWATU.filter((c) => c.kind === k).length; + expect([count('gwang'), count('yeol'), count('tti'), count('pi') + count('ssangpi')]).toEqual([5, 9, 10, 24]); + expect(HWATU.filter((c) => c.kind === 'ssangpi').map((c) => c.id)).toEqual([41, 47]); + expect(HWATU.filter((c) => c.tags.includes('godori')).map((c) => c.id)).toEqual([4, 12, 29]); + expect(hwatu(32).tags).toContain('gukjin'); + expect(hwatu(44).name).toBe('12월 비광(비)'); + expect(HWATU.every((c) => c.month === Math.floor(c.id / 4) + 1)).toBe(true); + expect(hwatuBonus(3).map((c) => c.piValue)).toEqual([2, 2, 3]); +}); + +test('seotda deck: 20 cards, months 1–10 twice, 광 1·3·8, 열끗 4·7·9 specials', () => { + expect(SEOTDA_DECK).toHaveLength(20); + const months = SEOTDA_DECK.map((id) => hwatu(id).month).sort((a, b) => a - b); + expect(months).toEqual([1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10]); + expect(SEOTDA_DECK.filter((id) => hwatu(id).kind === 'gwang').map((id) => hwatu(id).month)).toEqual([1, 3, 8]); + expect([12, 24, 32].every((id) => SEOTDA_DECK.includes(id) && hwatu(id).kind === 'yeol')).toBe(true); +}); diff --git a/packages/engine/src/cards.ts b/packages/engine/src/cards.ts new file mode 100644 index 0000000..c4ade34 --- /dev/null +++ b/packages/engine/src/cards.ts @@ -0,0 +1,103 @@ +/** Card definitions shared by card games (docs/09 §5). */ + +// ---------------------------------------------------------------- standard 52 (+ jokers) + +export const SUITS = ['S', 'H', 'D', 'C'] as const; // ♠ ♥ ◆ ♣ +export const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K', 'A'] as const; +export type Suit = (typeof SUITS)[number]; +export type Rank = (typeof RANKS)[number]; +/** e.g. "AS" (ace of spades), "TD" (ten of diamonds), "X1"/"X2" jokers. */ +export type CardId = string; + +export const SUIT_SYMBOL: Record = { S: '♠', H: '♥', D: '◆', C: '♣' }; +export const SUIT_KO: Record = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' }; +export const RANK_LABEL: Record = { '2': '2', '3': '3', '4': '4', '5': '5', '6': '6', '7': '7', '8': '8', '9': '9', T: '10', J: 'J', Q: 'Q', K: 'K', A: 'A' }; + +export function standardDeck(jokers = 0): CardId[] { + const deck: CardId[] = []; + for (const s of SUITS) for (const r of RANKS) deck.push(`${r}${s}`); + for (let j = 1; j <= jokers; j++) deck.push(`X${j}`); + return deck; +} + +export const isJoker = (c: CardId): boolean => c.startsWith('X'); +export const rankOf = (c: CardId): Rank => c[0] as Rank; +export const suitOf = (c: CardId): Suit => c[1] as Suit; +/** 2..14 (ace high). */ +export const rankValue = (c: CardId): number => RANKS.indexOf(rankOf(c)) + 2; +export const cardLabel = (c: CardId): string => (isJoker(c) ? '조커' : `${SUIT_SYMBOL[suitOf(c)]}${RANK_LABEL[rankOf(c)]}`); + +// ---------------------------------------------------------------- hwatu 48 (+ bonus) + +export type HwatuKind = 'gwang' | 'yeol' | 'tti' | 'pi' | 'ssangpi' | 'bonus'; +export type TtiKind = 'hong' | 'cheong' | 'cho' | 'plain'; + +export interface HwatuCard { + id: number; + /** 1..12, 0 for bonus cards. */ + month: number; + kind: HwatuKind; + tti?: TtiKind; + /** Special flags used by scoring: godori birds, 비광, 국진(9월 열끗) etc. */ + tags: string[]; + /** Korean display name, e.g. "1월 광(송학)". */ + name: string; + /** Junk value (피 1, 쌍피 2, bonus 2/3). */ + piValue: number; +} + +const MONTH_NAME = ['', '송학', '매조', '벚꽃', '흑싸리', '난초', '모란', '홍싸리', '공산', '국화', '단풍', '오동', '비']; + +/** id = (month-1)*4 + slot, matching docs/games/gostop.md §3.1. */ +const TABLE: [HwatuKind, TtiKind | null, string[]][][] = [ + /* 1 */ [['gwang', null, ['crane']], ['tti', 'hong', []], ['pi', null, []], ['pi', null, []]], + /* 2 */ [['yeol', null, ['godori']], ['tti', 'hong', []], ['pi', null, []], ['pi', null, []]], + /* 3 */ [['gwang', null, ['cherry']], ['tti', 'hong', []], ['pi', null, []], ['pi', null, []]], + /* 4 */ [['yeol', null, ['godori']], ['tti', 'cho', []], ['pi', null, []], ['pi', null, []]], + /* 5 */ [['yeol', null, []], ['tti', 'cho', []], ['pi', null, []], ['pi', null, []]], + /* 6 */ [['yeol', null, []], ['tti', 'cheong', []], ['pi', null, []], ['pi', null, []]], + /* 7 */ [['yeol', null, []], ['tti', 'cho', []], ['pi', null, []], ['pi', null, []]], + /* 8 */ [['gwang', null, ['moon']], ['yeol', null, ['godori']], ['pi', null, []], ['pi', null, []]], + /* 9 */ [['yeol', null, ['gukjin']], ['tti', 'cheong', []], ['pi', null, []], ['pi', null, []]], + /* 10 */ [['yeol', null, []], ['tti', 'cheong', []], ['pi', null, []], ['pi', null, []]], + /* 11 */ [['gwang', null, ['phoenix']], ['ssangpi', null, []], ['pi', null, []], ['pi', null, []]], + /* 12 */ [['gwang', null, ['rain']], ['yeol', null, ['swallow']], ['tti', 'plain', []], ['ssangpi', null, []]], +]; + +const KIND_KO: Record = { gwang: '광', yeol: '열끗', tti: '띠', pi: '피', ssangpi: '쌍피', bonus: '보너스' }; +const TTI_KO: Record = { hong: '홍단', cheong: '청단', cho: '초단', plain: '띠' }; + +export const HWATU: HwatuCard[] = TABLE.flatMap((cards, m) => + cards.map(([kind, tti, tags], slot) => { + const month = m + 1; + const id = m * 4 + slot; + const label = kind === 'tti' ? TTI_KO[tti!] : month === 12 && kind === 'gwang' ? '비광' : KIND_KO[kind]; + return { + id, + month, + kind, + ...(tti ? { tti } : {}), + tags, + name: `${month}월 ${label}(${MONTH_NAME[month]})`, + piValue: kind === 'pi' ? 1 : kind === 'ssangpi' ? 2 : 0, + }; + }), +); + +/** Bonus cards: ids 48.. (2피 cards, then a 3피 card last). */ +export function hwatuBonus(count: number): HwatuCard[] { + return Array.from({ length: count }, (_, i) => { + const three = i === count - 1 && count >= 3; + return { id: 48 + i, month: 0, kind: 'bonus' as const, tags: [], name: three ? '보너스 3피' : '보너스 2피', piValue: three ? 3 : 2 }; + }); +} + +export const hwatu = (id: number): HwatuCard => { + const c = HWATU[id]; + if (c) return c; + if (id >= 48) return hwatuBonus(id - 47).at(-1)!; + throw new Error(`unknown hwatu id ${id}`); +}; + +/** 섯다 20장: each month 1–10, its special card (slot 0) and the next card (slot 1) — docs/games/seotda.md §3. */ +export const SEOTDA_DECK: number[] = Array.from({ length: 10 }, (_, m) => [m * 4, m * 4 + 1]).flat(); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 7924724..f2edfdd 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -3,3 +3,5 @@ export * from './types'; export * from './grid'; export * from './turns'; export * from './testing'; +export * from './betting'; +export * from './cards';