- packages/shared/src/bots.ts: 컴퓨터 id(bot:<n>:<ulid>)·프로필(컴퓨터 n)·BOT_GAMES
(도블·할리갈리·그림 맞히기·마피아 제외)
- packages/games/src/bots: 전략 계약(자기 view + 합법 수만), decideBotAction(validate 재검사),
게임별 <게임>/bot.ts 22개 + 행동 테스트, 전 게임 판 끝까지 돌리는 공통 테스트
- 서버: Room.addBot/removeBot(방장·대기실만, 방은 친구만), RoomManager.createPractice
(POST /api/rooms {practice:true}), BotDriver(사람 같은 지연, 사람과 같은 창이면 더 천천히,
재시작 후 다시 예약), 연습 게임은 전적 제외(result_json.practice), 사람이 모두 나가면 무효
- 프로토콜 addBot/removeBot, 문서 03·06 §12·08·09 §8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
/**
|
|
* 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<string, number> = {};
|
|
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<CardId>([...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<PokerView, PokerAction> = ({ 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);
|
|
};
|