- 섯다(packages/games/seotda): 2장·3장 섯다, 1장씩/한 번에 나눠 주기, 족보(광땡·땡·알리…망통), 땡잡이·암행어사·구사/멍텅구리구사 재경기, 동점 재경기·나눔, 리바이·탈락, 사이드 팟. 규칙 테스트 54개 - 포커(packages/games/poker): 홀덤·오마하·7포커(초이스·보스)·7포커 하이로우(동시 선언)·바둑이(아침·점심·저녁)· 5카드 드로우·인디언 포커(이마 카드, 동점 이월, 다이 벌칙). 한국식/노리밋/팟리밋, 블라인드·앤티, 리바이·레벨 상승·최후 1인. 규칙 테스트 52개 + 족보 판정 24개, 무작위 1,000판(칩 보존·비공개 카드 유출 없음) - 웹: 섯다·포커 화면(테이블 좌석, 공용 카드, 사이드 팟, 한국식 버튼/레이즈 슬라이더, 초이스·교환·선언, 체크 가능할 때 포기 확인, 리바이 안내), 섯다 결과 화면에서 가져간 칩 표시 - 엔진 betting.buildPots: 라운드 진행 중에는 아직 콜할 수 있는 플레이어도 각 팟 자격자로 표시 - 서버 테스트: 크래시 흉내 후 소켓 종료로 다시 걸린 타이머가 닫힌 DB를 건드리던 누수 수정 - E2E: 3인(PC + 휴대폰 2) 섯다·7포커 한 판, 6개 게임 모두 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
491 lines
18 KiB
TypeScript
491 lines
18 KiB
TypeScript
/**
|
|
* 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);
|
|
// 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<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')} 칩`;
|
|
}
|