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:
agomizi1cmandClaude Opus 5.5 committed 2026-10-17 17:51:15 +09:00
1 parent d92953a270
commit 3116b1da9f
11 files changed
+702 -131

No files matched your search

+52 -126
View File
@@ -1,139 +1,65 @@
'use strict';
// CPU プレイヤーの思考。
// モンテカルロで「残っている相手に勝つ確率(エクイティ)」を見積もり、
// ポットオッズ・ポジション・その CPU の性格(攻撃性・ブラフ頻度)から行動を決める。
// 相手の手札は見ない(自分の手札と場札、公開されているベット状況だけを使う)。
// CPU プレイヤーの思考。性格ごとに別の公開ライブラリを使った思考を持たせる(どれを使っているかは画面に出さない)。
// montecarlo: バランス型。@pokertools/evaluator でモンテカルロ法の勝率を見積もる
// calculator: 計算型。poker-calculations の decideAction(勝率・ポットオッズ・期待値)
// range : 理論型(タイト)。poker-utils のハンドの順位とレンジに対する勝率・ブラフの理論値
// madehand : 直感型(ルーズ)。pokersolver で、いまできている役だけを見て打つ
// どの思考も相手の手札は見ない(自分の手札と場札、公開されているベット状況だけを使う)。
const crypto = require('crypto');
const { legalize } = require('./bots/common');
const montecarlo = require('./bots/montecarlo');
const { evaluate, getCardCode } = require('@pokertools/evaluator');
const BRAINS = {
montecarlo,
calculator: require('./bots/calculator'),
range: require('./bots/range'),
madehand: require('./bots/madehand'),
};
// hole / board は "As" 形式。opponents 人のランダムな手札を相手にした勝率(引き分けは等分)
// 役の強さは @pokertools/evaluator で求める(スコアは小さいほど強い。カードは 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 availableBrains() {
return Object.keys(BRAINS).filter((name) => !BRAINS[name].available || BRAINS[name].available());
}
// CPU ごとの性格(作成時にランダムに決める)
function randomStyle(rng = Math.random) {
return {
aggression: 0.8 + rng() * 0.45, // 大きいほど積極的にベット・レイズする
bluff: 0.03 + rng() * 0.09, // 弱い手でベットする頻度
looseness: rng() * 0.08, // 大きいほど弱い手でもコールする
};
const randomIndex = (rng, n) => Math.floor(rng() * n);
// CPU ごとの性格。brain を省くとランダムに選ぶ
function randomStyle(rng = Math.random, brain) {
const names = availableBrains();
const name = names.includes(brain) ? brain : names[randomIndex(rng, names.length)];
return { brain: name, ...BRAINS[name].randomStyle(rng) };
}
const DEFAULT_STYLE = { aggression: 1, bluff: 0.06, looseness: 0.04 };
// n 人分の性格。同じテーブルになるべく違う思考が並ぶようにする
function makeStyles(n, rng = () => crypto.randomInt(2 ** 32) / 2 ** 32) {
const names = availableBrains();
for (let i = names.length - 1; i > 0; i--) {
const j = randomIndex(rng, i + 1);
[names[i], names[j]] = [names[j], names[i]];
}
return Array.from({ length: n }, (_, k) => randomStyle(rng, names[k % names.length]));
}
// 手番の CPU の行動を決める。{ type, amount } を返す(hand.act にそのまま渡せる)
function decide(hand, seat, { style = DEFAULT_STYLE, rng = Math.random, iterations = 500 } = {}) {
const o = hand.options(seat);
if (!o) return null;
const p = hand.players.get(seat);
const stack = hand.seats[seat].stack;
const bb = hand.bb;
const preflop = hand.board.length === 0;
const opponents = hand.live().length - 1;
const pot = hand.potTotal;
const potOdds = o.toCall / (pot + o.toCall);
const raises = hand.raises || 0;
const aggr = style.aggression;
const raiseTo = (fraction) => {
// ポットの fraction 倍のレイズ。最低額〜オールインに収め、SB 単位に丸める
const unit = Math.max(1, hand.sb);
let to = hand.currentBet + Math.round(fraction * (pot + o.toCall));
to = Math.round(to / unit) * unit;
to = Math.min(o.maxRaiseTo, Math.max(o.minRaiseTo, to));
// 残りがわずかになるならオールイン
if (to - p.bet >= stack * 0.7) to = o.maxRaiseTo;
return { type: 'raise', amount: to };
};
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 { type: 'raise', amount: o.maxRaiseTo };
// 誰もレイズしていなければオープン(残りの人数が少ないほど広く)、レイズされていたら 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);
// 手番の CPU の行動を決める。{ type, amount } を返す(hand.act にそのまま受け付けられる形に直してある)
// 思考がエラーになったり何も返さなかったりしたら、バランス型で決め直す
function decide(hand, seat, { style = montecarlo.DEFAULT_STYLE, rng = Math.random, iterations = 500 } = {}) {
if (!hand.options(seat)) return null;
const brain = BRAINS[style.brain] || montecarlo;
let d = null;
try {
d = brain.decide(hand, seat, { style, rng, iterations });
} catch {
d = null;
}
if (!d && brain !== montecarlo) {
try {
d = montecarlo.decide(hand, seat, { rng, iterations });
} catch {
d = null;
}
if (o.canCheck) return { type: 'check' };
// コール: レイズしてきた相手は強い手を持っているので、その分だけ割り引いてポットオッズと比べる
const callScore = hs - 0.18 * raises - 0.04 * Math.max(0, opponents - 1);
if (callScore + style.looseness >= potOdds + 0.08) return { type: 'call' };
return { type: '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 { type: '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 { type: 'check' };
}
// コールするか: 勝率がポットオッズを上回るか
if (e + style.looseness >= potOdds + 0.02) return { type: 'call' };
if (o.canRaise && raises <= 1 && opponents === 1 && rng() < style.bluff * 0.4) return raiseTo(0.75);
return { type: 'fold' };
return legalize(hand, seat, d);
}
module.exports = { equity, decide, randomStyle };
module.exports = { decide, randomStyle, makeStyles, availableBrains, equity: montecarlo.equity, BRAINS };
+86
View File
@@ -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 };
+87
View File
@@ -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,
};
+97
View File
@@ -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 };
+118
View File
@@ -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 };
+130
View File
@@ -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 };
+3 -2
View File
@@ -30,7 +30,7 @@ class Table {
// { token, name, stack, buyIns, sittingOut, left, bot, style }
// left: ハンド中に退席した人。ハンドが終わるまで席を予約しておく
// bot : CPU プレイヤー(style は思考の性格)
// bot : CPU プレイヤー(style は思考の種類と性格。ブラウザには渡さない)
this.seats = new Array(this.maxSeats).fill(null);
this.botTurn = null; // { key, at }: CPU が行動する時刻
if (this.mode === 'cpu') this.seatBots(opts.cpuCount);
@@ -58,6 +58,7 @@ class Table {
const j = crypto.randomInt(i + 1);
[names[i], names[j]] = [names[j], names[i]];
}
const styles = ai.makeStyles(n); // 思考(使うライブラリ)はなるべくばらけさせる。画面には出さない
for (let k = 0; k < n; k++) {
this.seats[k + 1] = {
token: crypto.randomBytes(16).toString('hex'), // ブラウザには渡さない
@@ -67,7 +68,7 @@ class Table {
sittingOut: false,
left: false,
bot: true,
style: ai.randomStyle(),
style: styles[k],
};
}
}