Files
Poker/server/ai.js
T
agomizi1cmandClaude Opus 5.5 d92953a270 Use @pokertools/engine and evaluator for poker rules and hand ranking
Replace the hand-rolled betting rules, side pots, showdown and hand
evaluator with the published @pokertools/engine and @pokertools/evaluator
libraries (MIT, pinned to 2.1.0).

- server/hand.js now wraps a PokerEngine per hand: it maps seats,
  mirrors stacks, staggers the all-in runout, and pays host-chosen
  winners on cash-only tables.
- server/cards.js and server/ai.js use the library evaluator.
- Out-of-turn folds go through the engine's stand; all-in hands stay
  live when the player leaves; uncalled bets return to a leaver.
- The engine refuses some check-able folds that would leave a side pot
  without eligible players; those now return a message (or check for
  a leaving player) instead of crashing.
- BB must be greater than SB (engine requirement); Node >= 24.
- Tests rig cards via engine snapshots and cover the new edge cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:25:15 +09:00

140 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// CPU プレイヤーの思考。
// モンテカルロで「残っている相手に勝つ確率(エクイティ)」を見積もり、
// ポットオッズ・ポジション・その CPU の性格(攻撃性・ブラフ頻度)から行動を決める。
// 相手の手札は見ない(自分の手札と場札、公開されているベット状況だけを使う)。
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;
};
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' };
}
// フロップ以降: 残っている相手全員のランダムな手に対する勝率
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' };
}
module.exports = { equity, decide, randomStyle };