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:
206
packages/games/src/poker/view.ts
Normal file
206
packages/games/src/poker/view.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user