M3: 섯다·포커 규칙과 화면, 카드 게임 E2E

- 섯다(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>
This commit is contained in:
EJClaw
2026-10-05 03:48:56 +09:00
parent 31aedbfcc9
commit 5dd8e6ea07
28 changed files with 7545 additions and 12 deletions

View File

@@ -0,0 +1,206 @@
/**
* Per-viewer view (docs/games/poker.md §8.3). Hidden cards are sent as null (no id).
* Spectators (viewer = null) never see hidden cards or forehead cards before the hand ends.
*/
import { buildPots, legalBets, potTotal, toCallOf, type BetAction, type PlayerId } from '@bg/engine';
import type { CardId } from './cards';
import { bestIntl, bestOmaha } from './eval/international';
import { bestKorean } from './eval/korean';
import { bestLow } from './eval/low';
import { evalBadugi } from './eval/badugi';
import { bossOf, idxOf, inPlay, rebuysLeft, seatOf, waitingFor } from './hand';
import { isSeven, streetName } from './modes';
import type { BetActionType, HandPlayer, LegalBetView, PokerState, PokerView, SeatView } from './types';
const KOREAN_TYPE: Partial<Record<BetAction['kind'], BetActionType>> = {
check: 'k.check',
ping: 'k.ping',
call: 'k.call',
double: 'k.ddadang',
quarter: 'k.quarter',
half: 'k.half',
full: 'k.full',
die: 'k.die',
};
const NL_TYPE: Partial<Record<BetAction['kind'], BetActionType>> = {
fold: 'nl.fold',
check: 'nl.check',
call: 'nl.call',
raise: 'nl.raise',
allIn: 'nl.allIn',
};
export function betTypeOf(korean: boolean, kind: BetAction['kind']): BetActionType {
return (korean ? KOREAN_TYPE : NL_TYPE)[kind]!;
}
/** Hand names of the viewer's own cards (hints option). */
function hintNames(s: PokerState, p: HandPlayer, board: CardId[]): { high: string | null; low: string | null } {
const mode = s.options.mode;
switch (mode) {
case 'holdem':
return { high: p.hole.length ? bestIntl([...p.hole, ...board]).name : null, low: null };
case 'omaha':
return { high: bestOmaha(p.hole, board)?.name ?? null, low: null };
case 'fiveDraw':
return { high: p.hole.length ? bestIntl(p.hole).name : null, low: null };
case 'badugi':
return { high: p.hole.length ? evalBadugi(p.hole).name : null, low: null };
case 'sevenPoker':
case 'sevenHiLo': {
const cards = p.choice ?? [...p.hole, ...p.up];
if (!cards.length) return { high: null, low: null };
const low = mode === 'sevenHiLo' && cards.length >= 5 ? (bestLow(cards, s.options.hiLoLow, s.options.hiLoQualifier)?.name ?? '로우 없음') : null;
return { high: bestKorean(cards).name, low };
}
case 'indian':
return { high: null, low: null };
}
}
/** Log lines sent to clients (the state keeps 100). */
const VIEW_LOG = 30;
export function drawLimitFor(s: PokerState): { max: number; aceBonus: boolean } {
if (s.options.mode === 'badugi') return { max: 4, aceBonus: false };
return s.options.drawLimit === 'three' ? { max: 3, aceBonus: true } : { max: 5, aceBonus: false };
}
export function buildView(s: PokerState, viewer: PlayerId | null): PokerView {
const o = s.options;
const h = s.hand;
const live = !!h && !h.settled;
const boss = h && live && isSeven(o.mode) ? bossOf(h) : null;
const waiting = waitingFor(s);
const revealedIds = new Set(h?.showdown?.map((e) => e.id) ?? []);
const viewerInHand = !!(viewer && h && h.order.includes(viewer));
const seats: SeatView[] = s.seats.map((seat, i) => {
const hp = h?.players[seat.id];
const bp = h && hp ? h.bet.players[idxOf(h, seat.id)] : undefined;
const mine = viewer === seat.id;
const shown = !!h && (mine || revealedIds.has(seat.id) || (o.mode === 'indian' && h.settled));
const v: SeatView = {
id: seat.id,
seat: i,
stack: live && bp ? bp.stack : seat.stack,
rebuys: seat.rebuys,
status: seat.status,
away: seat.timeoutsInRow >= 2,
inHand: !!hp,
folded: bp?.status === 'folded',
allIn: bp?.status === 'allIn',
roundBet: live && bp ? bp.round : 0,
handBet: bp ? bp.total : 0,
dealer: !!h && h.dealerSeat === i,
sb: h?.sb === seat.id,
bb: h?.bb === seat.id,
boss: boss === seat.id,
lastAction: hp?.lastAction ?? null,
hole: hp ? hp.hole.map((c) => (shown ? c : null)) : [],
up: hp ? [...hp.up] : [],
choiceCount: hp?.choice?.length ?? 0,
choiceDone: !!hp?.choiceSel,
drawDone: hp?.drawPending != null,
drawCounts: hp ? [...hp.drawCounts] : [],
declare: hp && (mine || h!.declaresRevealed) ? hp.declare : null,
declared: !!hp?.declare,
ready: !!hp?.ready,
rebuyPending: s.rebuyPending.includes(seat.id),
};
if (hp?.forehead) {
// Never to its owner or spectators while the hand is running (§8.3 "고스트" 방지).
const visible = h!.settled || (viewerInHand && !mine);
v.forehead = visible ? hp.forehead : null;
}
return v;
});
const me = viewer ? seatOf(s, viewer) : undefined;
const hp = viewer && h ? h.players[viewer] : undefined;
const myIdx = h && viewer ? idxOf(h, viewer) : -1;
const bp = h && myIdx >= 0 ? h.bet.players[myIdx] : undefined;
const toAct = h && live && h.phase === 'betting' && h.waiting && h.bet.turn !== null ? h.order[h.bet.turn]! : null;
const korean = s.betStyle === 'korean';
const legal: LegalBetView[] =
toAct !== null && toAct === viewer
? legalBets(h!.bet, myIdx).map((l) => ({
type: betTypeOf(korean, l.kind),
amount: l.amount,
allIn: l.allIn,
...(l.minTo !== undefined ? { minTo: l.minTo, maxTo: l.maxTo } : {}),
}))
: [];
const hints = o.hints && hp && live && inPlay(h!, viewer!) ? hintNames(s, hp, h!.board) : { high: null, low: null };
const contesting = !!(h && live && viewer && inPlay(h, viewer));
const dl = drawLimitFor(s);
const left = me ? rebuysLeft(s, me) : 0;
return {
mode: o.mode,
betStyle: s.betStyle,
options: {
koreanMaxRaise: o.koreanMaxRaise,
hints: o.hints,
indianCards: o.indianCards,
indianDeck: o.indianDeck,
handLimit: o.handLimit,
endCondition: o.endCondition,
turnSeconds: o.turnSeconds,
startingChips: o.startingChips,
drawLimit: o.drawLimit,
hiLoLow: o.hiLoLow,
hiLoQualifier: o.hiLoQualifier,
rebuy: o.rebuy,
rebuyLimit: o.rebuyLimit,
},
me: me ? me.id : null,
seats,
phase: s.phase,
handPhase: h?.phase ?? null,
handNo: h?.no ?? 0,
handsPlayed: s.handsPlayed,
baseUnit: s.baseUnit,
level: s.level,
street: h?.street ?? 0,
streetName: h && h.phase === 'betting' ? streetName(o.mode, h.street) : '',
board: h ? [...h.board] : [],
pot: h ? (live ? potTotal(h.bet) : h.potTotal) : 0,
pots: h && live ? buildPots(h.bet.players).map((p) => ({ amount: p.amount, eligible: p.eligible })) : [],
currentBet: h && live ? h.bet.currentBet : 0,
carryOver: s.carryOver,
penalties: h ? h.penalties.map((p) => ({ ...p })) : [],
toAct,
waiting,
deadlineAt: s.deadlineAt,
deadlineTotalMs: s.deadlineMs,
my: {
seated: !!me,
choice: hp?.choice ? [...hp.choice] : null,
choiceSel: hp?.choiceSel ? { ...hp.choiceSel } : null,
discarded: hp?.discarded ?? null,
drawPending: hp?.drawPending ? [...hp.drawPending] : null,
declare: hp?.declare ?? null,
handName: hints.high,
lowName: hints.low,
toCall: bp && live ? toCallOf(h!.bet, bp) : 0,
legal,
canChoose: contesting && h!.phase === 'choice' && !hp!.choiceSel && !!hp!.choice,
canDraw: contesting && h!.phase === 'draw' && h!.waiting && hp!.drawPending === null,
drawMax: dl.max,
drawAceBonus: dl.aceBonus,
canDeclare: contesting && h!.phase === 'declare' && h!.waiting && !hp!.declare,
canReady: !!(s.phase === 'playing' && h?.phase === 'handEnd' && hp && !hp.ready && me?.status !== 'left'),
canRebuy: s.phase === 'rebuy' && !!viewer && s.rebuyPending.includes(viewer),
rebuysLeft: left,
},
showdown: h?.showdown ? h.showdown.map((e) => ({ ...e, cards: [...e.cards], best: [...e.best] })) : null,
awards: h?.awards ? h.awards.map((a) => ({ ...a })) : null,
winners: h ? [...h.winners] : [],
foldWin: !!h?.foldWin,
indianUsed: [...s.indianUsed],
log: s.log.slice(-VIEW_LOG).map((e) => ({ ...e })),
stats: structuredClone(s.stats),
result: s.result ? structuredClone(s.result) : null,
};
}