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

+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 };