/** * 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> = { 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> = { 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, }; }