Files
Poker/server/ai.js
T
agomizi1cmandClaude Opus 5.5 0d3a054f0d Add CPU opponents and a table type selector (cash only / friends / CPU)
- Create screen: choose キャッシュオンリー (real cards, chips only), 友人戦
  (server deals and settles) or CPU戦 (友人戦 plus 1–8 CPU players); replaces
  the card toggle. The lobby shows each table's type
- Poker AI written from scratch (server/ai.js, no libraries): fast 7-card
  evaluator (checked against bestHand), Monte Carlo equity, and decisions from
  hand rank, pot odds, raise count, opponents and a per-CPU style (aggression,
  bluff rate); continuation bets via the last aggressor. It never sees other
  players' hole cards
- CPU seats: think time of 0.7–1.8s (faster when no human is left in the hand),
  automatic rebuy, never the host, no new hand unless a human is playing,
  re-think after pause
- Play UI: robot avatars and a "考えています…" status for CPU turns
- Tests for the evaluator, equity, legality of AI actions and CPU tables

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

214 lines
8.8 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 RANKS = '23456789TJQKA';
const SUITS = 'shdc';
// "As" → 0〜51(rank * 4 + suit)
const toInt = (c) => RANKS.indexOf(c[0]) * 4 + SUITS.indexOf(c[1]);
// 13ビットのランクの集合から最も高いストレートの上端(0〜12)。無ければ -1
function straightHigh(mask) {
for (let h = 12; h >= 4; h--) {
const need = 0b11111 << (h - 4);
if ((mask & need) === need) return h;
}
// A-2-3-4-5
if ((mask & 0b1000000001111) === 0b1000000001111) return 3;
return -1;
}
// [役, 比較用の数字...] を1つの整数にする(大きいほど強い)
function encode(category, kickers) {
let v = category;
for (let i = 0; i < 5; i++) v = v * 16 + (kickers[i] === undefined ? 0 : kickers[i] + 1);
return v;
}
// 上位から n 個のランク(mask のビット)
function topRanks(mask, n, exclude = []) {
const out = [];
for (let r = 12; r >= 0 && out.length < n; r--) if (mask & (1 << r) && !exclude.includes(r)) out.push(r);
return out;
}
// 5〜7枚(整数)の最強の役の強さ。cards.js の bestHand と同じ順序になる(テストで確認)
function evaluate(cards) {
const counts = new Array(13).fill(0);
const suitCount = [0, 0, 0, 0];
const suitMask = [0, 0, 0, 0];
let mask = 0;
for (const c of cards) {
const r = c >> 2;
const s = c & 3;
counts[r]++;
suitCount[s]++;
suitMask[s] |= 1 << r;
mask |= 1 << r;
}
let flushMask = 0;
for (let s = 0; s < 4; s++) if (suitCount[s] >= 5) flushMask = suitMask[s];
if (flushMask) {
const sf = straightHigh(flushMask);
if (sf >= 0) return encode(8, [sf]);
}
let quad = -1;
const trips = [];
const pairs = [];
for (let r = 12; r >= 0; r--) {
if (counts[r] === 4) quad = r;
else if (counts[r] === 3) trips.push(r);
else if (counts[r] === 2) pairs.push(r);
}
if (quad >= 0) return encode(7, [quad, topRanks(mask, 1, [quad])[0]]);
if (trips.length && (trips.length > 1 || pairs.length)) {
const pair = Math.max(trips[1] === undefined ? -1 : trips[1], pairs[0] === undefined ? -1 : pairs[0]);
return encode(6, [trips[0], pair]);
}
if (flushMask) return encode(5, topRanks(flushMask, 5));
const st = straightHigh(mask);
if (st >= 0) return encode(4, [st]);
if (trips.length) return encode(3, [trips[0], ...topRanks(mask, 2, [trips[0]])]);
if (pairs.length >= 2) return encode(2, [pairs[0], pairs[1], topRanks(mask, 1, [pairs[0], pairs[1]])[0]]);
if (pairs.length) return encode(1, [pairs[0], ...topRanks(mask, 3, [pairs[0]])]);
return encode(0, topRanks(mask, 5));
}
// hole / board は "As" 形式。opponents 人のランダムな手札を相手にした勝率(引き分けは等分)
function equity(hole, board, opponents, iterations, rng = Math.random) {
const mine = hole.map(toInt);
const known = board.map(toInt);
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 best = 0;
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 (v > best) best = v;
}
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 = { evaluate, equity, decide, randomStyle, toInt, straightHigh };