/** * Poker computer player (all modes): a cautious beginner that bets by the strength of the cards it can see. * Strength (0..1) comes from the game's own hand evaluators applied to my visible cards (+ board, + the open * cards / forehead cards of the others). Strong hands raise, middling ones call fair prices, weak ones check or * fold; an occasional small bluff; never all-in with trash. 초이스, draws and hi-lo declares use the same idea. * In 인디언 포커 my own forehead card is hidden in my view, so the bot guesses it from the cards it can't see. */ import { suitOf } from '@bg/engine'; import type { BotStrategy } from '../bots/types'; import { pick, typeOf } from '../bots/policy'; import { deck52, deckD20, hi, lo, type CardId, type HandValue } from './cards'; import { evalBadugi } from './eval/badugi'; import { evalIndian } from './eval/indian'; import { INTL, bestIntl, bestOmaha } from './eval/international'; import { KOR, bestKorean, evalKorean } from './eval/korean'; import { bestLow } from './eval/low'; import type { PokerAction, PokerView, SeatView } from './index'; const known = (cs: (CardId | null)[]): CardId[] => cs.filter((c): c is CardId => !!c); // ---------------------------------------------------------------- strength /** 국제 족보 category → rough strength. */ function intlStrength(v: HandValue): number { if (v.cat === INTL.high) return 0.08 + (v.key[1]! / 14) * 0.15; if (v.cat === INTL.pair) return 0.3 + ((v.key[1]! - 2) / 12) * 0.2; return [0, 0, 0, 0.62, 0.72, 0.8, 0.85, 0.92, 0.97, 1][v.cat]!; } /** 한국식 족보 category → rough strength (7포커 hands run bigger, so the scale is a bit lower). */ function korStrength(v: HandValue): number { if (v.cat === KOR.high) return 0.05 + (v.key[1]! / 14) * 0.12; if (v.cat === KOR.pair) return 0.25 + ((v.key[1]! - 2) / 12) * 0.17; if (v.cat === KOR.twoPair) return 0.5 + ((v.key[1]! - 3) / 11) * 0.1; return [0, 0, 0, 0, 0.68, 0.76, 0.78, 0.8, 0.84, 0.92, 0.97, 1, 1, 1][v.cat]!; } /** Draw bonus: four cards of one suit while more cards are coming. */ function flushDraw(cards: CardId[]): boolean { const n: Record = {}; for (const c of cards) n[suitOf(c)] = (n[suitOf(c)] ?? 0) + 1; return Object.values(n).some((k) => k === 4); } /** Low-hand strength for 하이로우: a made low by its top card, else how many small distinct cards I hold. */ function lowStrength(cards: CardId[], view: PokerView): { s: number; made: boolean } { const v = cards.length >= 5 ? bestLow(cards, view.options.hiLoLow, view.options.hiLoQualifier) : null; if (v) return { s: Math.max(0.15, 0.85 - (-v.key[0]! - 5) * 0.08), made: true }; if (cards.length >= 7) return { s: 0, made: false }; const small = new Set(cards.map(lo).filter((r) => r <= 8)).size; return { s: 0.08 * Math.min(small, 5), made: false }; } /** 인디언: chance that my unseen forehead (plus my hand card) beats every forehead I can see. */ function indianStrength(view: PokerView, me: SeatView): number { const deck = view.options.indianDeck; const others = view.seats.filter((x) => x.id !== me.id && x.inHand && !x.folded && x.forehead); const mine = known(me.hole); const seen = new Set([...view.indianUsed, ...mine, ...others.map((x) => x.forehead!)]); const rest = (deck === 'd20' ? deckD20() : deck52()).filter((c) => !seen.has(c)); if (!others.length || !rest.length) return 0.5; let total = 0; for (const f of rest) { const my = evalIndian([f, ...mine], deck).key; let p = 1; for (const o of others) { // Their hand card (2장 mode) is unknown too: average over what is left. const pool = mine.length ? rest.filter((c) => c !== f) : [null]; let w = 0; for (const hc of pool) { const their = evalIndian(hc ? [o.forehead!, hc] : [o.forehead!], deck).key; const d = cmp(my, their); w += d > 0 ? 1 : d === 0 ? 0.5 : 0; } p *= w / pool.length; } total += p; } return total / rest.length; } function cmp(a: number[], b: number[]): number { for (let i = 0; i < Math.max(a.length, b.length); i++) { const d = (a[i] ?? -1) - (b[i] ?? -1); if (d) return d; } return 0; } function strength(view: PokerView, me: SeatView): { s: number; low: { s: number; made: boolean } | null } { const hole = known(me.hole); const board = view.board; switch (view.mode) { case 'holdem': { // Heads-up chances, minus a little for every extra opponent still in. const crowd = 0.06 * Math.max(0, view.seats.filter((x) => x.id !== me.id && x.inHand && !x.folded).length - 1); if (!board.length) { const [a, b] = hole.map(hi).sort((x, y) => y - x) as [number, number]; if (a === b) return { s: 0.5 + ((a - 2) / 12) * 0.35 - crowd, low: null }; let s = 0.3 + ((a - 2) / 12) * 0.2 + ((b - 2) / 12) * 0.12; if (suitOf(hole[0]!) === suitOf(hole[1]!)) s += 0.03; if (a - b <= 2) s += 0.02; return { s: s - crowd, low: null }; } const v = bestIntl([...hole, ...board]); let s = intlStrength(v) + (v.cat <= INTL.pair ? 0.12 : 0); // Only the board's hand (e.g. a paired board): my cards add nothing. if (v.cat === bestIntl(board).cat && s < 0.8) s = Math.min(s, 0.25); if (board.length < 5 && flushDraw([...hole, ...board])) s = Math.max(s, 0.45); return { s: s - crowd, low: null }; } case 'omaha': { if (!board.length) { const ranks = hole.map(hi); const pair = Math.max(0, ...ranks.filter((r, i) => ranks.indexOf(r) !== i)); const suited = new Set(hole.map(suitOf)).size <= 3; return { s: 0.22 + ranks.filter((r) => r >= 10).length * 0.05 + (pair ? 0.08 + pair / 100 : 0) + (suited ? 0.04 : 0), low: null }; } const v = bestOmaha(hole, board); let s = v ? intlStrength(v) - 0.08 : 0.1; if (board.length < 5 && hole.some((h) => flushDraw([h, ...board]) || hole.some((g) => g !== h && flushDraw([h, g, ...board])))) s = Math.max(s, 0.4); return { s, low: null }; } case 'sevenPoker': case 'sevenHiLo': { const cards = [...hole, ...known(me.up)]; const v = bestKorean(cards); let s = korStrength(v); if (cards.length < 7 && flushDraw(cards)) s = Math.max(s, 0.42); // Someone's open cards already beat my whole hand: be careful. const threat = view.seats.some((x) => x.id !== me.id && x.inHand && !x.folded && x.up.length && cmp(evalKorean(known(x.up).slice(0, 4)).key, v.key) > 0); if (threat) s *= 0.7; const low = view.mode === 'sevenHiLo' ? lowStrength(cards, view) : null; return { s: low ? Math.max(s, low.s) : s, low }; } case 'badugi': { const v = evalBadugi(hole); const top = -(v.key[1] ?? -13); const drawsLeft = Math.max(0, 3 - view.street); if (v.cat === 4) return { s: 0.65 + ((13 - top) / 12) * 0.33, low: null }; if (v.cat === 3) return { s: 0.25 + ((13 - top) / 12) * 0.15 + drawsLeft * 0.06, low: null }; return { s: 0.08 + drawsLeft * 0.04, low: null }; } case 'fiveDraw': { let s = intlStrength(bestIntl(hole)); if (view.street === 0 && flushDraw(hole)) s = Math.max(s, 0.35); return { s, low: null }; } case 'indian': return { s: indianStrength(view, me), low: null }; } } // ---------------------------------------------------------------- non-betting choices /** 7포커 초이스: keep the three cards with the best start, open the least telling one. */ function chooseCards(view: PokerView, choice: CardId[]): { discard: CardId; open: CardId } { const startScore = (kept: CardId[]): number => { const v = evalKorean(kept); let sc = v.cat * 10 + Math.max(...kept.map(hi)) / 2; if (new Set(kept.map(suitOf)).size === 1) sc += 6; const r = kept.map(hi).sort((a, b) => a - b); if (new Set(r).size === 3 && r[2]! - r[0]! <= 4) sc += 4; if (view.mode === 'sevenHiLo') sc += new Set(kept.map(lo).filter((x) => x <= 6)).size * 4; return sc; }; let discard = choice[0]!; for (const d of choice) if (startScore(choice.filter((c) => c !== d)) > startScore(choice.filter((c) => c !== discard))) discard = d; const kept = choice.filter((c) => c !== discard); const paired = (c: CardId) => kept.some((k) => k !== c && hi(k) === hi(c)); const asc = [...kept].sort((a, b) => hi(a) - hi(b)); return { discard, open: asc.find((c) => !paired(c)) ?? asc[0]! }; } /** Cards I'd like to throw away in 5카드 드로우 / 바둑이. */ function wantedDiscards(view: PokerView, hole: CardId[]): CardId[] { if (view.mode === 'badugi') { const v = evalBadugi(hole); if (v.cat === 4) return []; // Keep the badugi part, but not its high cards. const keep = v.best.filter((c) => lo(c) <= 9); return hole.filter((c) => !keep.includes(c)); } const v = bestIntl(hole); if (v.cat >= INTL.straight) return []; const count = (c: CardId) => hole.filter((x) => hi(x) === hi(c)).length; if (v.cat >= INTL.pair) return hole.filter((c) => count(c) < 2); for (const s of new Set(hole.map(suitOf))) { const same = hole.filter((c) => suitOf(c) === s); if (same.length === 4) return hole.filter((c) => suitOf(c) !== s); } // Four to a straight (no pairs here): drop the card farthest from the others. const asc = [...hole].sort((a, b) => hi(a) - hi(b)); if (hi(asc[3]!) - hi(asc[0]!) <= 4) return [asc[4]!]; if (hi(asc[4]!) - hi(asc[1]!) <= 4) return [asc[0]!]; return asc.slice(0, 4); // keep the highest card only } // ---------------------------------------------------------------- strategy type Bet = PokerView['my']['legal'][number]; export const pokerBot: BotStrategy = ({ me, view, legal, random }) => { const first = legal[0]!; const t = typeOf(first); if (t === 'ready') return first; if (t === 'rebuy') return { type: 'rebuy', accept: true }; if (t === 'choose') { const c = chooseCards(view, view.my.choice ?? []); return legal.find((a) => a.type === 'choose' && a.discardId === c.discard && a.openId === c.open) ?? first; } const seat = view.seats.find((x) => x.id === me)!; if (t === 'draw') { const want = new Set(wantedDiscards(view, known(seat.hole))); // The closest allowed draw: as many wanted discards as possible, never a card I want to keep. let top = first; let topScore = -Infinity; for (const a of legal) { if (a.type !== 'draw') continue; const hit = a.cardIds.filter((c) => want.has(c)).length; const sc = hit * 10 - (a.cardIds.length - hit) * 25; if (sc > topScore) [top, topScore] = [a, sc]; } return top; } const { s, low } = strength(view, seat); if (t === 'declare') { const high = korStrength(bestKorean([...known(seat.hole), ...known(seat.up)])); const side = low?.made && high >= 0.84 && low.s >= 0.75 ? 'swing' : low?.made && low.s > high ? 'low' : 'high'; return { type: 'declare', side }; } // Betting. const bets = view.my.legal; const by = (k: string) => bets.find((b) => b.type.endsWith(`.${k}`)); const act = (b: Bet | undefined) => (b ? (legal.find((a) => a.type === b.type) ?? null) : null); const check = by('check'); const call = by('call'); const fold = by('die') ?? by('fold'); const pot = Math.max(view.pot, 1); const raise = (big: boolean): PokerAction | null => { if (view.betStyle === 'korean') { const order = big ? ['half', 'full', 'quarter', 'ddadang', 'ping'] : ['ping', 'quarter', 'ddadang', 'half']; for (const k of order) { const b = by(k); if (b && (!b.allIn || s >= 0.85)) return act(b); } return null; } const r = bets.find((b) => b.type === 'nl.raise'); if (!r) return null; const stackTo = seat.roundBet + seat.stack; const to = Math.min(r.maxTo!, Math.max(r.minTo!, Math.round(view.currentBet + pot * (big ? 0.75 : 0.4)))); if (to >= stackTo && s < 0.85) return null; return { type: 'nl.raise', to }; }; if (check) { if (s >= 0.72) return raise(true) ?? act(check)!; if ((s >= 0.5 && random() < 0.5) || random() < 0.07) return raise(false) ?? act(check)!; return act(check)!; } if (s >= 0.75 && random() < 0.6) { const r = raise(true); if (r) return r; } if (call) { const toCall = call.amount; const price = toCall / (pot + toCall); const worth = call.allIn ? s >= 0.55 : s >= Math.max(0.28, price + 0.12); const cheapBluff = !call.allIn && toCall <= view.baseUnit && random() < 0.3; if (worth || cheapBluff) return act(call)!; } // Only an all-in left to stay in: monsters only. const allIn = bets.find((b) => b.type === 'nl.allIn'); if (!call && allIn && s >= 0.75) return act(allIn)!; return act(fold) ?? pick(legal, random); };