diff --git a/README.md b/README.md index 8305f59..37a3ac4 100644 --- a/README.md +++ b/README.md @@ -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` | ブラインド計算(サーバ・ブラウザ共用) | diff --git a/package-lock.json b/package-lock.json index ba20809..49ac5ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5f20423..596f624 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/server/ai.js b/server/ai.js index cf31e13..7f45d39 100644 --- a/server/ai.js +++ b/server/ai.js @@ -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 }; diff --git a/server/bots/calculator.js b/server/bots/calculator.js new file mode 100644 index 0000000..56620c9 --- /dev/null +++ b/server/bots/calculator.js @@ -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 }; diff --git a/server/bots/common.js b/server/bots/common.js new file mode 100644 index 0000000..21ba61c --- /dev/null +++ b/server/bots/common.js @@ -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, +}; diff --git a/server/bots/madehand.js b/server/bots/madehand.js new file mode 100644 index 0000000..f06e314 --- /dev/null +++ b/server/bots/madehand.js @@ -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 }; diff --git a/server/bots/montecarlo.js b/server/bots/montecarlo.js new file mode 100644 index 0000000..4b6fc6a --- /dev/null +++ b/server/bots/montecarlo.js @@ -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 }; diff --git a/server/bots/range.js b/server/bots/range.js new file mode 100644 index 0000000..06ea99a --- /dev/null +++ b/server/bots/range.js @@ -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 }; diff --git a/server/tables.js b/server/tables.js index 47e582e..5a47985 100644 --- a/server/tables.js +++ b/server/tables.js @@ -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], }; } } diff --git a/test/engine.test.js b/test/engine.test.js index fc949f4..a2a574b 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -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',