Files
Poker/server/bots/range.js
T
agomizi1cmandClaude Opus 5.5 3116b1da9f Give CPUs distinct personalities built on different poker libraries
Each CPU now thinks with one of four engines, each backed by a different
published library (pinned versions):

- montecarlo (@pokertools/evaluator): the existing balanced Monte Carlo
  equity player, moved to server/bots/montecarlo.js.
- calculator (poker-calculations): the library's decideAction over the
  table state; push/fold below 12BB via its jam breakeven equity.
  Skipped automatically if the native addon cannot load.
- range (poker-utils): tight player; preflop by hand-strength percentile,
  postflop equity vs an estimated opponent range, bluffs only when
  alpha/bluffEV say they pay.
- madehand (pokersolver): loose, sticky player that only looks at the
  made hand and draws.

server/ai.js assigns a mix of engines per table (makeStyles), falls back
to the Monte Carlo engine on errors, and legalizes every decision.
The engine and its parameters stay server-side; players cannot tell
which library a CPU uses. Tests cover every engine and the no-leak rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:51:15 +09:00

131 lines
5.2 KiB
JavaScript

'use strict';
// 理論型(タイト): poker-utils のプリフロップの強さの順位で参加する手を絞り、
// フロップ以降は「相手がこのくらいの範囲の手を持っている」と仮定したレンジに対する勝率で判断する。
// ブラフはライブラリの alpha(必要なフォールド率)と bluffEV で割に合うときだけ打つ。
const pu = require('poker-utils');
const { situation, CHECK, CALL, FOLD, passive, allIn, raiseToAmount, raisePot, between } = require('./common');
// 169種類の手 → 上位何%か(0 が最強)。preflopStrengthOrder は弱い順
const PERCENTILE = new Map();
{
const combos = (h) => (h.length === 2 ? 6 : h[2] === 's' ? 4 : 12);
let stronger = 0;
for (const h of pu.preflopStrengthOrder.slice().reverse()) {
PERCENTILE.set(h, stronger / 1326);
stronger += combos(h);
}
}
const handPercentile = (cards) => PERCENTILE.get(pu.handToIsoPre(pu.boardToInts(cards.join(''))));
// 上位 width の手のレンジ(5%刻みで作り置き)
const ranges = new Map();
function topRange(width) {
const key = Math.min(20, Math.max(1, Math.round(width * 20)));
if (!ranges.has(key)) ranges.set(key, pu.PokerRange.fromPreflop(pu.PreflopRange.fromPercentiles(0, key / 20)));
return ranges.get(key);
}
const RIVER_SAMPLES = 24;
// 相手のレンジに対する勝率(引き分けは半分)。フロップは残り2枚をサンプリングする
function rangeEquity(cards, board, vsRange, rng) {
const hand = pu.boardToInts(cards.join(''));
const known = pu.boardToInts(board.join(''));
const boards = [];
if (known.length === 3) {
const used = new Set([...hand, ...known]);
const deck = [];
for (let c = 0; c < 52; c++) if (!used.has(c)) deck.push(c);
for (let k = 0; k < RIVER_SAMPLES; k++) {
const i = Math.floor(rng() * deck.length);
let j = Math.floor(rng() * (deck.length - 1));
if (j >= i) j++;
boards.push(known.concat([deck[i], deck[j]]));
}
} else {
boards.push(known);
}
let sum = 0;
let n = 0;
for (const b of boards) {
const r = pu.equityEval({ board: b, hand, vsRange });
for (const [win, tie, lose] of Array.isArray(r[0]) ? r : [r]) {
const total = win + tie + lose;
if (total > 0) {
sum += (win + tie / 2) / total;
n++;
}
}
}
return n ? sum / n : 0.5;
}
function randomStyle(rng) {
return {
width: between(rng, 0.8, 1.2), // 参加する手の広さ
aggression: between(rng, 0.9, 1.2), // 大きいほど薄い手でもバリューベットする
bluff: between(rng, 0.04, 0.12), // ブラフを考える頻度
foldEstimate: between(rng, 0.35, 0.5), // 相手が降りると見積もる割合
margin: between(rng, 0, 0.05), // コールに必要な勝率の上乗せ
};
}
function preflop(hand, seat, s, style) {
const { o, p, stack, bb, opponents, raises } = s;
const pct = handPercentile(p.cards);
const stackBb = (stack + p.bet) / bb;
// スタックが 12BB 以下ならプッシュかフォールド
if (stackBb <= 12) {
const pushPct = Math.min(0.6, 2.5 / stackBb) * style.width;
if (pct < (raises === 0 ? pushPct : pushPct * 0.5)) return o.canRaise ? allIn(o) : CALL;
return passive(o);
}
// 残りの人数が少ないほど広く参加する
const open = (opponents >= 5 ? 0.14 : opponents >= 3 ? 0.2 : opponents === 2 ? 0.3 : 0.45) * style.width;
if (raises === 0) {
if (pct < open && o.canRaise) return raiseToAmount(hand, seat, Math.max(2.5 * bb, hand.currentBet * 3));
return passive(o);
}
if (raises === 1) {
if (pct < open * 0.2 && o.canRaise) return raiseToAmount(hand, seat, hand.currentBet * 3);
if (pct < open * 0.55) return CALL;
return passive(o);
}
if (pct < 0.025) return o.canRaise ? allIn(o) : CALL;
if (pct < 0.05) return CALL;
return passive(o);
}
function decide(hand, seat, { style, rng = Math.random } = {}) {
const s = situation(hand, seat);
if (!s) return null;
if (s.preflop) return preflop(hand, seat, s, style);
const { o, p, opponents, pot, potOdds, raises } = s;
// 相手のレンジ: フロップまで残った手(上位45%)。ベット・レイズされるほど強い手に絞る
let width = 0.45 * style.width;
if (o.toCall > 0) width *= 1 - 0.5 * Math.min(1, o.toCall / Math.max(1, pot - o.toCall));
if (raises >= 2) width = Math.min(width, 0.12);
const eq = rangeEquity(p.cards, hand.board, topRange(width), rng);
// 複数の相手全員に勝つ確率(独立とみなす)
const eqAll = eq ** Math.max(1, opponents);
if (o.canCheck) {
if (o.canRaise && eqAll > 0.62 / style.aggression) return raisePot(hand, seat, 0.66);
if (o.canRaise && opponents <= 2 && rng() < style.bluff) {
// ポットの半分のブラフで必要なフォールド率(alpha)より、相手が降りる見積もりが大きければ打つ
const bet = pot * 0.5;
if (pu.bluffEV(style.foldEstimate, pu.alpha(bet, pot)) > 0) return raisePot(hand, seat, 0.5);
}
return CHECK;
}
if (o.canRaise && eqAll > 0.8 && raises < 3) return raisePot(hand, seat, 0.75);
if (eqAll >= potOdds + style.margin) return CALL;
return FOLD;
}
module.exports = { decide, randomStyle, handPercentile, rangeEquity };