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
+701 -130

No files matched your search

+11 -2
View File
@@ -22,7 +22,15 @@
- ベッティングが終わると次のストリートへ。全員オールインならその時点でショーダウンになる(残りのカードは実物でめくる)。
- **ショーダウン(キャッシュオンリー)**: カードを見せ合い、ホストが勝った人を画面で選んで確定する(ポットの分配はテーブルのホストだけが行える)。サイドポットがあるときはポットごとに選ぶ。複数選ぶと山分け(端数はボタンの左から)。
- **カードを配るテーブル(友人戦・CPU戦)**: 各自のスマホに自分の手札だけが表示され、場札はストリートごとに画面に配られる。ショーダウンでは残った人の手札を公開して役を判定し、ポット(サイドポット含む)を自動で分配する。引き分けは山分け(端数はボタンの左から)。全員オールインになったら手札を公開し、残りの場札を順にめくる。山札はエンジンが暗号論的乱数(Node.js の crypto)でシャッフルし、サーバの外に出さない(他人の手札は公開されるまで送らない)。
- **CPU**: 自前実装(`server/ai.js`。役の強さだけ @pokertools/evaluator を使う)。自分の手札と場札から、残っている相手に勝つ確率をモンテカルロ法で見積もり、ポットオッズ・レイズの回数・相手の人数から行動を決める。CPU ごとに攻撃性・ブラフ頻度がランダムに違う。相手の手札は見ない。1手ごとに 0.7〜1.8 秒考える。チップがなくなると次のハンドで自動でリバイする。人間が1人も参加していないと次のハンドは始まらない。CPU はホストにならない。人間が全員「退席」するとテーブルはすぐに削除される(ブラウザを閉じただけの場合は、ほかのテーブルと同じく誰も接続していない状態が30分続いたら削除)。
- **CPU**: 性格ごとに別の公開ライブラリで考える。テーブルの CPU にはなるべく違う種類が割り当てられ、さらに同じ種類でも攻撃性・ブラフ頻度などがランダムに違う。どの CPU がどのライブラリを使っているかは画面にもブラウザに送るデータにも出さない。どの CPU も相手の手札は見ない(自分の手札と場札、公開されているベット状況だけを使う)。
| 種類 | ライブラリ | 打ち方 |
| --- | --- | --- |
| バランス型 | [@pokertools/evaluator](https://www.npmjs.com/package/@pokertools/evaluator) | モンテカルロ法で残っている相手に勝つ確率を見積もり、ポットオッズ・レイズの回数・相手の人数から決める |
| 計算型 | [poker-calculations](https://www.npmjs.com/package/poker-calculations)(ネイティブ実装) | ライブラリの `decideAction`(勝率・ポットオッズ・コールの期待値)。積極的にレイズする。12BB 以下はオールインの損益分岐勝率でプッシュ/フォールド |
| 理論型(タイト) | [poker-utils](https://www.npmjs.com/package/poker-utils) | プリフロップは手の強さの順位で上位だけ参加。フロップ以降は相手のレンジ(ベットされるほど強い手に絞る)に対する勝率で判断し、ブラフは alpha・bluffEV で割に合うときだけ |
| 直感型(ルーズ) | [pokersolver](https://www.npmjs.com/package/pokersolver) | 勝率は計算せず、いまできている役とドローだけを見る。参加する手が広く、ペアがあればなかなか降りない |
計算型のネイティブアドオンが読み込めない環境では、残りの3種類だけを使う。思考がエラーになったときはバランス型で決め直し、最後に必ず受け付けられるアクションに直す(`server/ai.js`)。1手ごとに 0.7〜1.8 秒考える。チップがなくなると次のハンドで自動でリバイする。人間が1人も参加していないと次のハンドは始まらない。CPU はホストにならない。人間が全員「退席」するとテーブルはすぐに削除される(ブラウザを閉じただけの場合は、ほかのテーブルと同じく誰も接続していない状態が30分続いたら削除)。
- 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。
- 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。
- チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。
@@ -83,7 +91,8 @@ http://localhost:4671 を開く。
| `server/tables.js` | テーブル状態(席・ボタン・ブラインド・タイマー・ハンドの開始/進行) |
| `server/hand.js` | 1ハンドの進行。ルールは @pokertools/engine に任せ、テーブルとの橋渡し(席番号・スタック・退席・離席)、オールイン後に場札を順にめくる表示、キャッシュオンリーでホストが選んだ勝者への分配を行う |
| `server/cards.js` | 役の名前と最強の5枚(@pokertools/evaluator) |
| `server/ai.js` | CPU の思考(勝率の見積もり・行動の決定) |
| `server/ai.js` | CPU の思考の割り当て(種類と性格)と呼び出し |
| `server/bots/*.js` | CPU の思考の種類ごとの実装(`montecarlo` / `calculator` / `range` / `madehand`)と共通の道具(`common.js`) |
| `test/` | チップ処理・役の判定・CPU のテスト(`npm test`) |
| `server/config.js` | `.env` の読み込みと各種上限値 |
| `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) |
+45 -1
View File
@@ -11,10 +11,13 @@
"@pokertools/engine": "2.1.0",
"@pokertools/evaluator": "2.1.0",
"express": "^4.22.3",
"poker-calculations": "4.0.3",
"poker-utils": "14.0.1",
"pokersolver": "2.1.4",
"socket.io": "^4.8.4"
},
"engines": {
"node": ">=18"
"node": ">=24"
}
},
"node_modules/@pokertools/engine": {
@@ -674,6 +677,17 @@
"node": ">= 0.6"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -722,6 +736,36 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/poker-calculations": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/poker-calculations/-/poker-calculations-4.0.3.tgz",
"integrity": "sha512-vpX+2nVuSGxbVd4+oGrbUSthEQ+/T5sLnkOQsxOB9ir7SAl0vcb9XCDWgjXUsFBHXR+h+MQCK2TIrfWaj+SyhA==",
"license": "ISC",
"dependencies": {
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/poker-utils": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/poker-utils/-/poker-utils-14.0.1.tgz",
"integrity": "sha512-wj1dKtUr2uXSs4/53NIoSc6ZRQw+HblwtSVqAZMqraApqrhfv/RfietsLWShd0J7A2U3N3i4umnfh4IhRxs2Cw==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/pokersolver": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/pokersolver/-/pokersolver-2.1.4.tgz",
"integrity": "sha512-vmgZS+K8H8r1RePQykFM5YyvlKo1v3xVec8FMBjg9N6mR2Tj/n/X415w+lG67FWbrk71D/CADmKFinDgaQlAsw==",
"engines": [
"node >= 4.0.0"
],
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+3
View File
@@ -16,6 +16,9 @@
"@pokertools/engine": "2.1.0",
"@pokertools/evaluator": "2.1.0",
"express": "^4.22.3",
"poker-calculations": "4.0.3",
"poker-utils": "14.0.1",
"pokersolver": "2.1.4",
"socket.io": "^4.8.4"
}
}
+51 -125
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');
// 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;
}
// CPU ごとの性格(作成時にランダムに決める)
function randomStyle(rng = Math.random) {
return {
aggression: 0.8 + rng() * 0.45, // 大きいほど積極的にベット・レイズする
bluff: 0.03 + rng() * 0.09, // 弱い手でベットする頻度
looseness: rng() * 0.08, // 大きいほど弱い手でもコールする
};
}
const DEFAULT_STYLE = { aggression: 1, bluff: 0.06, looseness: 0.04 };
// 手番の 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;
const BRAINS = {
montecarlo,
calculator: require('./bots/calculator'),
range: require('./bots/range'),
madehand: require('./bots/madehand'),
};
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);
}
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' };
// この環境で使える思考(ネイティブアドオンが読み込めないなどで使えないものを除く)
function availableBrains() {
return Object.keys(BRAINS).filter((name) => !BRAINS[name].available || BRAINS[name].available());
}
// フロップ以降: 残っている相手全員のランダムな手に対する勝率
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 randomIndex = (rng, n) => Math.floor(rng() * n);
// 強い手はベット・レイズ(何度もレイズされているほど慎重に)
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());
// 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) };
}
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' };
// 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]));
}
// コールするか: 勝率がポットオッズを上回るか
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' };
// 手番の 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;
}
}
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],
};
}
}
+70
View File
@@ -708,6 +708,76 @@ test('CPU: どんな状況でも正しいアクションを返す', () => {
assert.ok(actions > 300, `actions=${actions}`);
});
test('CPU: どの思考(ライブラリ)も自分で正しいアクションを決める', () => {
const brains = ai.availableBrains();
// 少なくとも純粋な JavaScript のライブラリの思考は必ず使える
for (const b of ['montecarlo', 'range', 'madehand']) assert.ok(brains.includes(b), b);
for (const brain of brains) {
const rng = seeded(brain.length * 101);
let decisions = 0;
for (let game = 0; game < 6; game++) {
const n = 2 + (game % 5);
const stacks = Array.from({ length: n }, () => 100 + Math.floor(rng() * 1500));
const t = makeTable(stacks, { mode: 'friends' });
t.start(T(0), 0);
let now = 0;
for (let step = 0; step < 3000 && t.hand && t.hand.number <= 8; step++) {
const h = t.hand;
if (!h.finished && h.toAct != null) {
const style = ai.randomStyle(rng, brain);
assert.equal(style.brain, brain);
// 思考そのものがエラーにならず、何かを返す(バランス型に頼らない)
assert.ok(ai.BRAINS[brain].decide(h, h.toAct, { style, rng, iterations: 80 }), brain);
const d = ai.decide(h, h.toAct, { style, rng, iterations: 80 });
assert.equal(h.act(h.toAct, d.type, d.amount), null, `${brain} ${JSON.stringify(d)}`);
decisions++;
} else {
t.tick((now += 10 ** 5));
}
}
}
assert.ok(decisions > 60, `${brain} decisions=${decisions}`);
}
});
test('CPU: 思考の性格は弱い手で降り、強い手では降りない', () => {
for (const brain of ai.availableBrains()) {
const rng = seeded(3);
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
rig(h, [['7c', '2d'], ['8h', '3s'], ['As', 'Ah']], ['Kd', '9c', '4s', '5h', 'Jd']);
const style = ai.randomStyle(rng, brain);
assert.notEqual(ai.decide(h, btn, { style, rng }).type, 'fold', brain); // AA
t.act(tk(t, btn), 'raise', 1000); // オールイン
assert.equal(ai.decide(h, sb, { style, rng }).type, 'fold', brain); // 72o
}
});
test('CPU戦: CPU ごとに違う思考が割り当てられ、どれを使っているかは画面に出ない', () => {
const brains = ai.availableBrains();
assert.deepEqual(new Set(ai.makeStyles(brains.length).map((s) => s.brain)), new Set(brains));
const t = new Table({
id: 'c',
name: 'cpu',
startingStack: 1000,
smallBlind: 10,
bigBlind: 20,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
mode: 'cpu',
cpuCount: 8,
});
const used = new Set(t.seats.filter((s) => s && s.bot).map((s) => s.style.brain));
assert.equal(used.size, brains.length);
t.join(T(0), 'me');
t.start(T(0), 0);
const json = JSON.stringify([t.publicState(T(0), new Set()), t.summary()]);
for (const word of ['brain', 'style', ...brains, 'aggression', 'Threshold']) assert.ok(!json.includes(word), word);
});
test('CPU戦: CPU が席に着き、ホストは人間のまま、CPU は自動で行動する', () => {
const t = new Table({
id: 'c',