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>
This commit is contained in:
1 parent
d92953a270
commit
3116b1da9f
11 files changed
+702
-131
No files matched your search
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
|
||||
// 計算型: poker-calculations(ネイティブ実装)の decideAction に、勝率・ポットオッズ・コールの期待値から決めさせる。
|
||||
// スタックが少ないときは、同じライブラリのオールインの損益分岐勝率と比べてプッシュかフォールドにする。
|
||||
const { situation, CALL, FOLD, CHECK, passive, allIn, raiseToAmount, seedFrom, between } = require('./common');
|
||||
|
||||
// ネイティブアドオンが読み込めない環境では、この思考は使わない
|
||||
let pc = null;
|
||||
try {
|
||||
pc = require('poker-calculations');
|
||||
} catch {
|
||||
pc = null;
|
||||
}
|
||||
|
||||
const available = () => pc !== null;
|
||||
|
||||
function randomStyle(rng) {
|
||||
return {
|
||||
// 小さいほどベット・レイズが多い(0.4 未満は無謀になり、0.6 を超えるとほとんどレイズしない)
|
||||
aggressionThreshold: between(rng, 0.4, 0.52),
|
||||
riskTolerance: between(rng, 0.4, 0.6), // 大きいほど薄い勝率でもコールする
|
||||
raisePotFraction: between(rng, 0.55, 0.9), // ベット・レイズの大きさ(ポットに対する割合)
|
||||
pushBias: between(rng, -0.04, 0.04), // プッシュの基準勝率への上乗せ(負ほど広くプッシュ)
|
||||
};
|
||||
}
|
||||
|
||||
const PHASES = ['preflop', null, null, 'flop', 'turn', 'river'];
|
||||
|
||||
// decideAction に渡すテーブルの状態。他人の手札は空にする
|
||||
function tableState(hand, seat) {
|
||||
const list = hand.list;
|
||||
const index = (s) => list.findIndex((p) => p.seat === s);
|
||||
const firstToAct = hand.board.length === 0 ? (index(hand.bbSeat) + 1) % list.length : 0;
|
||||
return {
|
||||
players: list.map((p, i) => ({
|
||||
holeCards: p.seat === seat ? p.cards : [],
|
||||
stack: hand.seats[p.seat].stack,
|
||||
committedThisStreet: p.bet,
|
||||
totalCommittedHand: p.committed,
|
||||
folded: p.folded,
|
||||
seat: i,
|
||||
})),
|
||||
communityCards: hand.board,
|
||||
phase: PHASES[hand.board.length],
|
||||
pot: hand.potTotal,
|
||||
currentBet: hand.currentBet,
|
||||
buttonSeat: index(hand.button),
|
||||
smallBlind: hand.sb,
|
||||
bigBlind: hand.bb,
|
||||
actingIndex: index(seat),
|
||||
lastRaiseIncrement: hand.state.lastRaiseAmount || hand.bb,
|
||||
streetOpeningIndex: firstToAct,
|
||||
// このストリートで動いた人(直前のアクションの表示はストリートが変わると消える)
|
||||
actedThisStreet: list.map((p) => Boolean(p.last && p.last.type !== 'FOLD')),
|
||||
};
|
||||
}
|
||||
|
||||
function decide(hand, seat, { style, rng = Math.random, iterations = 500 } = {}) {
|
||||
const s = situation(hand, seat);
|
||||
if (!s) return null;
|
||||
const { o, p, stack, bb, preflop, opponents, pot } = s;
|
||||
const villains = Math.max(1, opponents);
|
||||
|
||||
// スタックが 12BB 以下ならプッシュかフォールド
|
||||
if (preflop && stack + p.bet <= 12 * bb) {
|
||||
const eq = pc.simulateHandOutcome(p.cards, [], iterations, seedFrom(rng), villains);
|
||||
const breakeven = pc.pushFoldSymmetricBreakevenEquity(stack + p.bet, pot - p.bet);
|
||||
if (eq >= breakeven + style.pushBias + 0.04 * (villains - 1)) return o.canRaise ? allIn(o) : CALL;
|
||||
return passive(o);
|
||||
}
|
||||
|
||||
const d = pc.decideAction(tableState(hand, seat), {
|
||||
aggressionThreshold: style.aggressionThreshold,
|
||||
riskTolerance: style.riskTolerance,
|
||||
raisePotFraction: style.raisePotFraction,
|
||||
monteCarloSimulations: iterations,
|
||||
monteCarloVillains: villains,
|
||||
rngSeed: seedFrom(rng),
|
||||
});
|
||||
if (d.action === 'raise' && o.canRaise) return raiseToAmount(hand, seat, hand.currentBet + d.raiseBy);
|
||||
if (d.action === 'raise' || d.action === 'call') return o.canCheck ? CHECK : CALL;
|
||||
if (d.action === 'check') return passive(o);
|
||||
return FOLD;
|
||||
}
|
||||
|
||||
module.exports = { decide, randomStyle, available };
|
||||
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
|
||||
// CPU の思考で共通に使う道具。どの思考も相手の手札は見ない(自分の手札と場札、公開されているベット状況だけを使う)
|
||||
|
||||
// 手番の状況。手番でなければ null
|
||||
function situation(hand, seat) {
|
||||
const o = hand.options(seat);
|
||||
if (!o) return null;
|
||||
const p = hand.players.get(seat);
|
||||
const pot = hand.potTotal;
|
||||
return {
|
||||
o,
|
||||
p,
|
||||
cards: p.cards,
|
||||
board: hand.board,
|
||||
stack: hand.seats[seat].stack,
|
||||
bb: hand.bb,
|
||||
preflop: hand.board.length === 0,
|
||||
opponents: hand.live().length - 1,
|
||||
pot,
|
||||
potOdds: o.toCall / (pot + o.toCall),
|
||||
raises: hand.raises || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const CHECK = { type: 'check' };
|
||||
const CALL = { type: 'call' };
|
||||
const FOLD = { type: 'fold' };
|
||||
|
||||
// チェックできればチェック、できなければフォールド
|
||||
const passive = (o) => (o.canCheck ? CHECK : FOLD);
|
||||
// チェックできればチェック、できなければコール
|
||||
const keepGoing = (o) => (o.canCheck ? CHECK : CALL);
|
||||
const allIn = (o) => ({ type: 'raise', amount: o.maxRaiseTo });
|
||||
|
||||
// 合計 amount までのレイズ。最低額〜オールインに収め、SB 単位に丸め、残りがわずかになるならオールイン
|
||||
function raiseToAmount(hand, seat, amount) {
|
||||
const o = hand.options(seat);
|
||||
const p = hand.players.get(seat);
|
||||
const unit = Math.max(1, hand.sb);
|
||||
let to = Math.round(amount / unit) * unit;
|
||||
to = Math.min(o.maxRaiseTo, Math.max(o.minRaiseTo, to));
|
||||
if (to - p.bet >= hand.seats[seat].stack * 0.7) to = o.maxRaiseTo;
|
||||
return { type: 'raise', amount: to };
|
||||
}
|
||||
|
||||
// ポット(コールした後)の fraction 倍のレイズ
|
||||
function raisePot(hand, seat, fraction) {
|
||||
const o = hand.options(seat);
|
||||
return raiseToAmount(hand, seat, hand.currentBet + fraction * (hand.potTotal + o.toCall));
|
||||
}
|
||||
|
||||
// 思考の結果を、その場で必ず受け付けられるアクションに直す
|
||||
function legalize(hand, seat, d) {
|
||||
const o = hand.options(seat);
|
||||
if (!o) return null;
|
||||
const type = d && d.type;
|
||||
if (type === 'raise' && o.canRaise && Number.isFinite(d.amount)) {
|
||||
const amount = Math.min(o.maxRaiseTo, Math.max(o.minRaiseTo, Math.round(d.amount)));
|
||||
return { type: 'raise', amount };
|
||||
}
|
||||
if (type === 'raise' || type === 'call') return keepGoing(o);
|
||||
if (type === 'check') return passive(o);
|
||||
// ただで見られるのに降りることはしない
|
||||
return passive(o);
|
||||
}
|
||||
|
||||
// 0 以上 2^31 未満の整数のシード(ネイティブのシミュレーション用)
|
||||
const seedFrom = (rng) => Math.floor(rng() * 2147483647);
|
||||
|
||||
// 範囲内の一様乱数
|
||||
const between = (rng, min, max) => min + rng() * (max - min);
|
||||
|
||||
module.exports = {
|
||||
situation,
|
||||
CHECK,
|
||||
CALL,
|
||||
FOLD,
|
||||
passive,
|
||||
keepGoing,
|
||||
allIn,
|
||||
raiseToAmount,
|
||||
raisePot,
|
||||
legalize,
|
||||
seedFrom,
|
||||
between,
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
// 直感型(ルーズ): pokersolver で「いまできている役」だけを見て打つ。勝率の計算はしない。
|
||||
// 参加する手が広く、ペアやドローがあればなかなか降りない。強い役ができたときだけ大きく打つ。
|
||||
const { Hand } = require('pokersolver');
|
||||
const { situation, CHECK, CALL, FOLD, passive, keepGoing, allIn, raiseToAmount, raisePot, between } = require('./common');
|
||||
|
||||
// pokersolver のカードのランク: 2 → 1 … A → 13。役のランク: ハイカード 1、ワンペア 2、ツーペア 3 …
|
||||
const PAIR = 2;
|
||||
const TWO_PAIR = 3;
|
||||
const rankOf = (card) => '23456789TJQKA'.indexOf(card[0]) + 1;
|
||||
|
||||
function randomStyle(rng) {
|
||||
return {
|
||||
callCapBb: between(rng, 4, 9), // プリフロップでコールする上限(BB)
|
||||
stickiness: between(rng, 0.8, 1.5), // ペアでコールするベットの上限(ポットに対する割合)
|
||||
looseness: between(rng, 0.15, 0.4), // 何もなくても小さいベットにはコールする頻度
|
||||
bluff: between(rng, 0.05, 0.2), // ドローでベットする頻度
|
||||
betSize: between(rng, 0.5, 0.85),
|
||||
};
|
||||
}
|
||||
|
||||
function preflop(hand, seat, s, style, rng) {
|
||||
const { o, p, stack, bb, raises } = s;
|
||||
const solved = Hand.solve(p.cards);
|
||||
const [hi, lo] = p.cards.map(rankOf).sort((a, b) => b - a);
|
||||
const pair = solved.rank === PAIR;
|
||||
const suited = p.cards[0][1] === p.cards[1][1];
|
||||
const premium = (pair && hi >= 10) || (hi === 13 && lo === 12); // JJ 以上・AK
|
||||
const playable = pair || hi === 13 || lo >= 9 || suited || (hi - lo <= 2 && lo >= 4);
|
||||
|
||||
// スタックが 10BB 以下なら、ペア・エース・プレミアムはオールイン
|
||||
if (stack + p.bet <= 10 * bb) {
|
||||
if (pair || hi === 13 || premium) return o.canRaise ? allIn(o) : CALL;
|
||||
return passive(o);
|
||||
}
|
||||
if (premium && o.canRaise && raises < 2) {
|
||||
return raiseToAmount(hand, seat, raises === 0 ? Math.max(3 * bb, hand.currentBet * 3) : hand.currentBet * 3);
|
||||
}
|
||||
if (premium) return keepGoing(o);
|
||||
if (playable && o.toCall <= style.callCapBb * bb) {
|
||||
// たまにリンプではなく小さくオープンする
|
||||
if (raises === 0 && o.canRaise && rng() < 0.15) return raiseToAmount(hand, seat, 2.5 * bb);
|
||||
return keepGoing(o);
|
||||
}
|
||||
return passive(o);
|
||||
}
|
||||
|
||||
// フラッシュドローかストレートドロー(オープンエンド)があるか。自分の手札を使うものだけ
|
||||
function hasDraw(cards, board) {
|
||||
if (board.length >= 5) return false;
|
||||
const all = cards.concat(board);
|
||||
for (const suit of 'shdc') {
|
||||
const n = all.filter((c) => c[1] === suit).length;
|
||||
if (n === 4 && cards.some((c) => c[1] === suit)) return true;
|
||||
}
|
||||
const ranks = new Set(all.map(rankOf));
|
||||
if (ranks.has(13)) ranks.add(0); // A は 1 としても数える
|
||||
const mine = new Set(cards.map(rankOf));
|
||||
for (let lo = 0; lo <= 9; lo++) {
|
||||
const run = [lo, lo + 1, lo + 2, lo + 3];
|
||||
if (run.every((r) => ranks.has(r)) && run.some((r) => mine.has(r) || (r === 0 && mine.has(13)))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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, rng);
|
||||
|
||||
const { o, p, pot, raises } = s;
|
||||
const mine = Hand.solve(p.cards.concat(hand.board));
|
||||
const onBoard = Hand.solve(hand.board);
|
||||
const improved = mine.rank > onBoard.rank; // 自分の手札で役が上がっている
|
||||
const strong = improved && mine.rank >= TWO_PAIR;
|
||||
const boardTop = Math.max(...hand.board.map(rankOf));
|
||||
const topPair = improved && mine.rank === PAIR && mine.cards[0].rank >= boardTop;
|
||||
const pair = improved && mine.rank === PAIR;
|
||||
const draw = hasDraw(p.cards, hand.board);
|
||||
|
||||
if (o.canCheck) {
|
||||
if (strong && o.canRaise) return raisePot(hand, seat, style.betSize);
|
||||
if (topPair && o.canRaise && rng() < 0.6) return raisePot(hand, seat, style.betSize * 0.7);
|
||||
if (draw && o.canRaise && rng() < style.bluff) return raisePot(hand, seat, 0.5);
|
||||
return CHECK;
|
||||
}
|
||||
|
||||
const bet = o.toCall / Math.max(1, pot - o.toCall); // ポットに対するベットの大きさ
|
||||
if (strong) return o.canRaise && raises < 2 ? raisePot(hand, seat, style.betSize) : CALL;
|
||||
if (pair && bet <= style.stickiness * (topPair ? 1.3 : 0.8)) return CALL;
|
||||
if (draw && bet <= 0.7) return CALL;
|
||||
if (bet <= 0.35 && rng() < style.looseness) return CALL;
|
||||
return FOLD;
|
||||
}
|
||||
|
||||
module.exports = { decide, randomStyle, hasDraw };
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
// バランス型: @pokertools/evaluator の役判定でモンテカルロ法の勝率を見積もり、
|
||||
// ポットオッズ・レイズの回数・相手の人数から行動を決める。
|
||||
const { evaluate, getCardCode } = require('@pokertools/evaluator');
|
||||
const { situation, CHECK, CALL, FOLD, allIn, raisePot, between } = require('./common');
|
||||
|
||||
// hole / board は "As" 形式。opponents 人のランダムな手札を相手にした勝率(引き分けは等分)
|
||||
// 役の強さのスコアは小さいほど強い。カードは 0〜51 の整数
|
||||
function equity(hole, board, opponents, iterations, rng = Math.random) {
|
||||
const mine = hole.map(getCardCode);
|
||||
const known = board.map(getCardCode);
|
||||
const used = new Set([...mine, ...known]);
|
||||
const deck = [];
|
||||
for (let c = 0; c < 52; c++) if (!used.has(c)) deck.push(c);
|
||||
const needBoard = 5 - known.length;
|
||||
const need = needBoard + opponents * 2;
|
||||
|
||||
let score = 0;
|
||||
for (let it = 0; it < iterations; it++) {
|
||||
// 必要な枚数だけ部分的にシャッフル
|
||||
for (let i = 0; i < need; i++) {
|
||||
const j = i + Math.floor(rng() * (deck.length - i));
|
||||
const tmp = deck[i];
|
||||
deck[i] = deck[j];
|
||||
deck[j] = tmp;
|
||||
}
|
||||
const fullBoard = known.concat(deck.slice(0, needBoard));
|
||||
const me = evaluate(mine.concat(fullBoard));
|
||||
let ties = 0;
|
||||
let lost = false;
|
||||
for (let k = 0; k < opponents; k++) {
|
||||
const o = needBoard + k * 2;
|
||||
const v = evaluate([deck[o], deck[o + 1]].concat(fullBoard));
|
||||
if (v < me) {
|
||||
lost = true;
|
||||
break;
|
||||
}
|
||||
if (v === me) ties++;
|
||||
}
|
||||
if (!lost) score += 1 / (ties + 1);
|
||||
}
|
||||
return score / iterations;
|
||||
}
|
||||
|
||||
function randomStyle(rng) {
|
||||
return {
|
||||
aggression: between(rng, 0.8, 1.25), // 大きいほど積極的にベット・レイズする
|
||||
bluff: between(rng, 0.03, 0.12), // 弱い手でベットする頻度
|
||||
looseness: between(rng, 0, 0.08), // 大きいほど弱い手でもコールする
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE = { aggression: 1, bluff: 0.06, looseness: 0.04 };
|
||||
|
||||
function decide(hand, seat, { style = DEFAULT_STYLE, rng = Math.random, iterations = 500 } = {}) {
|
||||
const s = situation(hand, seat);
|
||||
if (!s) return null;
|
||||
const { o, p, stack, bb, preflop, opponents, pot, potOdds, raises } = s;
|
||||
const aggr = style.aggression;
|
||||
const raiseTo = (fraction) => raisePot(hand, seat, fraction);
|
||||
const sizing = () => {
|
||||
const r = rng();
|
||||
return r < 0.4 ? 0.5 : r < 0.8 ? 0.75 : 1;
|
||||
};
|
||||
|
||||
if (preflop) {
|
||||
// プリフロップ: 1人のランダムな手に対する勝率で手の強さを順位付けする
|
||||
// (0.6 ≒ 上位15%、0.65 ≒ 上位5%、0.7 ≒ 上位3%)
|
||||
const hs = equity(p.cards, [], 1, iterations, rng);
|
||||
const s1 = (hs - 0.5) / 0.5;
|
||||
|
||||
// スタックが少ないときはプッシュかフォールド
|
||||
if (o.canRaise && stack + p.bet <= 10 * bb && s1 > 0.24 / aggr) return allIn(o);
|
||||
|
||||
// 誰もレイズしていなければオープン(残りの人数が少ないほど広く)、レイズされていたら 3bet・4bet
|
||||
const raiseBar = (raises === 0 ? (opponents <= 3 ? 0.13 : 0.2) : raises === 1 ? 0.33 : 0.45) / aggr;
|
||||
if (o.canRaise && (s1 > raiseBar || (raises === 1 && s1 > 0.1 && rng() < style.bluff * 0.5))) {
|
||||
// オープンは 2.5〜3BB 程度、リレイズはポットの 0.75〜1倍
|
||||
return raiseTo(raises === 0 ? 0.6 + rng() * 0.3 : 0.75 + rng() * 0.25);
|
||||
}
|
||||
if (o.canCheck) return CHECK;
|
||||
// コール: レイズしてきた相手は強い手を持っているので、その分だけ割り引いてポットオッズと比べる
|
||||
const callScore = hs - 0.18 * raises - 0.04 * Math.max(0, opponents - 1);
|
||||
if (callScore + style.looseness >= potOdds + 0.08) return CALL;
|
||||
return FOLD;
|
||||
}
|
||||
|
||||
// フロップ以降: 残っている相手全員のランダムな手に対する勝率
|
||||
let e = equity(p.cards, hand.board, Math.max(1, opponents), iterations, rng);
|
||||
// ベットしてきた相手は強い手を持っていることが多いので、ベットが大きいほど勝率を割り引く
|
||||
if (o.toCall > 0) e *= 1 - 0.25 * Math.min(1, o.toCall / Math.max(pot - o.toCall, bb));
|
||||
const fair = 1 / (Math.max(1, opponents) + 1);
|
||||
const strength = (e - fair) / (1 - fair); // 0 で平均的、1 で必勝
|
||||
|
||||
// 強い手はベット・レイズ(何度もレイズされているほど慎重に)
|
||||
const raiseBar = (0.35 + 0.17 * Math.min(raises, 3)) / aggr;
|
||||
if (o.canRaise && strength > raiseBar) {
|
||||
// 最強クラスはたまにスロープレイ
|
||||
if (o.canCheck && strength > 0.85 && rng() < 0.2) return CHECK;
|
||||
return raiseTo(strength > 0.75 ? Math.max(0.75, sizing()) : sizing());
|
||||
}
|
||||
|
||||
if (o.canCheck) {
|
||||
// 前のストリートで最後にレイズした人は、相手が少なければ続けてベット(コンティニュエーションベット)
|
||||
if (o.canRaise && hand.prevAggressor === seat && opponents <= 2 && rng() < 0.55 * aggr) return raiseTo(0.5);
|
||||
// たまにブラフ
|
||||
if (o.canRaise && opponents <= 2 && rng() < style.bluff * aggr) return raiseTo(0.5);
|
||||
return CHECK;
|
||||
}
|
||||
|
||||
// コールするか: 勝率がポットオッズを上回るか
|
||||
if (e + style.looseness >= potOdds + 0.02) return CALL;
|
||||
if (o.canRaise && raises <= 1 && opponents === 1 && rng() < style.bluff * 0.4) return raiseTo(0.75);
|
||||
return FOLD;
|
||||
}
|
||||
|
||||
module.exports = { decide, randomStyle, equity, DEFAULT_STYLE };
|
||||
@@ -0,0 +1,130 @@
|
||||
'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 };
|
||||
Reference in new issue
Block a user