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>
131 lines
5.2 KiB
JavaScript
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 };
|