M3 기반: 공용 베팅 엔진, 카드 정의, 카드·칩 화면 컴포넌트, 돈 표현 금지 검사

- 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 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 05:45:13 +09:00
parent a10139b51a
commit 9519b09be8
10 changed files with 1370 additions and 3 deletions

View File

@@ -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 (
<span className={className} style={{ fontVariantNumeric: 'tabular-nums', fontWeight: strong ? 800 : 600, whiteSpace: 'nowrap' }}>
<ChipIcon /> {formatChips(n)}
</span>
);
}
export function ChipIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 20 20" aria-hidden style={{ verticalAlign: '-3px' }}>
<circle cx="10" cy="10" r="9" fill="#2563eb" />
<circle cx="10" cy="10" r="5.5" fill="none" stroke="#fff" strokeWidth="2" strokeDasharray="3 2.4" />
</svg>
);
}
/** Shown once per room in betting games. */
export const PLAY_CHIP_NOTICE = '칩은 이 방에서만 쓰는 놀이용 점수예요. 실제 가치가 없고 바꾸거나 주고받을 수 없으며, 방이 끝나면 사라져요.';
export function PlayChipNotice() {
return (
<p className="small" style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '8px 12px', margin: 0 }}>
<ChipIcon /> {PLAY_CHIP_NOTICE}
</p>
);
}

View File

@@ -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<HwatuInfo['kind'], string> = { 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 <path d="M50 52 L28 92 H72 Z M50 72 L22 116 H78 Z" fill={c} opacity="0.85" />;
case 2: // 매조: plum blossoms
case 3: // 벚꽃
return (
<g fill={c} opacity="0.85">
{[[34, 70], [62, 62], [50, 92], [70, 100], [30, 108]].map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r="10" />
))}
</g>
);
case 4: // 흑싸리
case 7: // 홍싸리
return (
<g stroke={c} strokeWidth="5" strokeLinecap="round" opacity="0.85">
{[30, 45, 60, 75].map((x, i) => (
<line key={i} x1={x} y1="52" x2={x - 6} y2={100 + (i % 2) * 14} />
))}
</g>
);
case 5: // 난초: iris leaves
return (
<g fill="none" stroke={c} strokeWidth="5" opacity="0.85">
<path d="M40 120 Q34 80 44 52" />
<path d="M55 120 Q62 84 52 54" />
<path d="M70 120 Q76 92 68 64" />
</g>
);
case 6: // 모란: peony
return (
<g fill={c} opacity="0.85">
<circle cx="50" cy="86" r="22" />
<circle cx="50" cy="86" r="10" fill="#fde68a" />
</g>
);
case 8: // 공산: hill
return <path d="M8 120 Q50 52 92 120 Z" fill={c} opacity="0.85" />;
case 9: // 국화
return (
<g fill={c} opacity="0.9">
{Array.from({ length: 10 }, (_, i) => {
const a = (i / 10) * Math.PI * 2;
return <ellipse key={i} cx={50 + Math.cos(a) * 14} cy={88 + Math.sin(a) * 14} rx="8" ry="4" transform={`rotate(${(a * 180) / Math.PI} ${50 + Math.cos(a) * 14} ${88 + Math.sin(a) * 14})`} />;
})}
<circle cx="50" cy="88" r="7" fill="#78350f" />
</g>
);
case 10: // 단풍: maple
return <path d="M50 58 L58 78 L78 74 L64 90 L74 108 L50 98 L26 108 L36 90 L22 74 L42 78 Z" fill={c} opacity="0.9" />;
case 11: // 오동
return (
<g fill={c} opacity="0.85">
<ellipse cx="38" cy="82" rx="16" ry="22" />
<ellipse cx="64" cy="94" rx="16" ry="22" />
</g>
);
case 12: // 비: rain lines + umbrella
return (
<g stroke={c} strokeWidth="3" opacity="0.85">
{[20, 35, 50, 65, 80].map((x) => (
<line key={x} x1={x} y1="54" x2={x - 8} y2="118" />
))}
</g>
);
default:
return <text x="50" y="98" fontSize="36" textAnchor="middle" fill={c}>★</text>;
}
}
export type HwatuSize = 'sm' | 'md' | 'lg';
const WIDTH: Record<HwatuSize, number> = { 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 (
<Tag style={s} onClick={onClick} aria-label="뒤집힌 화투">
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="158" rx="8" fill="#991b1b" stroke="#450a0a" strokeWidth="3" />
<rect x="10" y="10" width="80" height="140" rx="5" fill="#b91c1c" />
</svg>
</Tag>
);
}
const color = MONTH_COLOR[card.month] ?? '#374151';
return (
<Tag style={s} onClick={onClick} aria-label={card.name} aria-pressed={onClick ? !!selected : undefined} title={card.name}>
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="158" rx="8" fill="#fffaf0" stroke={selected ? '#2563eb' : '#1f2937'} strokeWidth={selected ? 6 : 3} />
<Motif month={card.month} color={color} />
{card.kind === 'tti' && (
<g>
<rect x="58" y="30" width="18" height="90" rx="3" fill={TTI_COLOR[card.tti ?? 'plain']} />
{TTI_TEXT[card.tti ?? 'plain'] && (
<text x="67" y="62" fontSize="13" fontWeight="800" fill="#fff" textAnchor="middle" style={{ writingMode: 'vertical-rl' } as CSSProperties}>
{TTI_TEXT[card.tti ?? 'plain']}
</text>
)}
</g>
)}
{(card.kind === 'gwang' || card.kind === 'yeol') && (
<g>
<circle cx="78" cy="120" r="15" fill={card.kind === 'gwang' ? '#dc2626' : '#a16207'} />
<text x="78" y="127" fontSize={card.kind === 'gwang' ? 20 : 16} fontWeight="800" fill="#fff" textAnchor="middle">
{card.kind === 'gwang' ? '光' : '열'}
</text>
</g>
)}
{/* Month number: large and always readable */}
<rect x="6" y="6" width="38" height="38" rx="8" fill={color} />
<text x="25" y="35" fontSize="28" fontWeight="900" fill="#fff" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{card.month || '+'}
</text>
<text x="50" y="154" fontSize="13" fontWeight="700" fill="#374151" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{MONTH_NAME[card.month]} · {card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]}
</text>
</svg>
</Tag>
);
}

View File

@@ -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<string, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
const SUIT_KO: Record<string, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
const RANK_LABEL: Record<string, string> = { T: '10', J: 'J', Q: 'Q', K: 'K', A: 'A' };
const FACE_KO: Record<string, string> = { J: '잭', Q: '퀸', K: '킹', A: '에이스' };
export type CardSize = 'sm' | 'md' | 'lg';
const WIDTH: Record<CardSize, number> = { 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 (
<Tag style={{ ...common, ...btnReset }} onClick={onClick} aria-label={title ?? '뒤집힌 카드'} title={title}>
<svg viewBox="0 0 100 140" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="138" rx="9" fill="#1e3a8a" stroke="#fff" strokeWidth="3" />
<rect x="9" y="9" width="82" height="122" rx="6" fill="none" stroke="#93c5fd" strokeWidth="2" />
<path d="M50 30 L70 70 L50 110 L30 70 Z" fill="#3b82f6" opacity="0.7" />
</svg>
</Tag>
);
}
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 (
<Tag style={{ ...common, ...btnReset }} onClick={onClick} aria-label={title ?? cardAria(card)} aria-pressed={onClick ? !!selected : undefined} title={title}>
<svg viewBox="0 0 100 140" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="138" rx="9" fill="#fffef9" stroke={selected ? '#2563eb' : '#9ca3af'} strokeWidth={selected ? 5 : 2} />
<text x="9" y="33" fontSize="30" fontWeight="800" fill={color} fontFamily="Pretendard Variable, sans-serif">
{label}
</text>
<text x="11" y="60" fontSize="26" fill={color}>
{sym}
</text>
{joker ? (
<text x="50" y="98" fontSize="22" fontWeight="800" textAnchor="middle" fill={color}>
JOKER
</text>
) : 'JQK'.includes(rank) ? (
<>
<rect x="30" y="44" width="56" height="78" rx="6" fill={color} opacity="0.1" />
<text x="58" y="100" fontSize="44" fontWeight="800" textAnchor="middle" fill={color}>
{rank}
</text>
</>
) : (
<text x="60" y="108" fontSize="54" textAnchor="middle" fill={color}>
{sym}
</text>
)}
</svg>
</Tag>
);
}

View File

@@ -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([]);
});

View File

@@ -1,9 +1,20 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"], "lib": [
"ES2023",
"DOM",
"DOM.Iterable"
],
"jsx": "react-jsx", "jsx": "react-jsx",
"types": ["vite/client"] "types": [
"vite/client"
]
}, },
"include": ["src"] "include": [
"src"
],
"exclude": [
"src/**/*.test.ts"
]
} }

View File

@@ -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<string, number> = { 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);
}
});
});

View File

@@ -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> = {}): 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<number>();
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<string, string> = {
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<Better, 'id' | 'total' | 'status'>[]): 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<string, number> {
const out: Record<string, number> = {};
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<string, number> {
const won: Record<string, number> = {};
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')} 칩`;
}

View File

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

View File

@@ -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<Suit, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
export const SUIT_KO: Record<Suit, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
export const RANK_LABEL: Record<Rank, string> = { '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<HwatuKind, string> = { gwang: '광', yeol: '열끗', tti: '띠', pi: '피', ssangpi: '쌍피', bonus: '보너스' };
const TTI_KO: Record<TtiKind, string> = { 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();

View File

@@ -3,3 +3,5 @@ export * from './types';
export * from './grid'; export * from './grid';
export * from './turns'; export * from './turns';
export * from './testing'; export * from './testing';
export * from './betting';
export * from './cards';