/** * 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); // Active players can still call up to any level, so they stay eligible mid-round. const eligible = live.filter((p) => p.status === 'active' || p.total >= level).map((p) => p.id); if (amount > 0) pots.push({ amount, eligible }); prev = level; } // 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')} 칩`; }