- 섯다(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>
207 lines
8.0 KiB
TypeScript
207 lines
8.0 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|