Files
Poker/server/hand.js
T
agomizi1cmandClaude Opus 5.5 3f2515a9fa Add admin-only CPU tables with per-hand reviews
A new table type, "review" (レビュー付きCPU戦), can only be created by
admins. Only the creator and CPUs sit there: friends cannot join, and the
table is hidden from non-admin lobbies. After every hand a review opens,
and the next hand waits until the creator presses "次のハンドへ".

- Preflop: shows which color of the registered hand range the hole cards
  are and whether the action followed it. With no entrant before you,
  open with the color for the number of players still to act; facing an
  entrant, estimate their range from their position, call one color
  above it and raise two above. Purple ranks just below white (SB only).
- Flop onward: equity vs ranges estimated from those colors (narrowed by
  later bets/calls) gives a rough EV per option. The score is the EV lost
  against the best option, relative to the pot. It is a guide, not GTO.
- Also: pot-odds call check, all-in equity and luck-adjusted P&L,
  VPIP/PFR/3bet/Cbet/WTSD/W$SD/AF, per-color and per-position compliance,
  habits to watch, worst-5 hands, a P&L chart and a step-by-step replay
  on the admin-only /review page.

Hand now logs every action for the review. The range data moves to
admin/range-data.js, shared by the range page and the server. Reviews are
saved under data/reviews/ (newest 50 sessions). Admin scripts and CSS are
no longer cached for a day, so updates show up immediately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:11:16 +09:00

461 lines
20 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';
const path = require('path');
const { PokerEngine, ActionType, PlayerStatus } = require('@pokertools/engine');
// サイドポットの計算はエンジン内部の関数を使う(パッケージの公開 API に無いので、ファイルを直接読む。
// package.json でバージョンを固定している)
const { calculateSidePots } = require(path.join(path.dirname(require.resolve('@pokertools/engine')), 'rules/side-pots'));
const { bestHand, compareScore } = require('./cards');
// 1ハンド分の進行。ルール(ブラインド・手番・最低レイズ額・再レイズ権・返金・サイドポット・
// ショーダウンの判定と分配)は @pokertools/engine に任せ、ここではテーブルとのやり取りと表示用の状態を持つ。
// cards = false: カードは実物で配るので扱わない。ショーダウンでは勝者をホストが選ぶ。
// cards = true : エンジンが配り、ショーダウンで役を判定して自動で分配する。
// seats は Table.seats への参照で、スタックはエンジンの状態に合わせて書き換える。
// 参加者の席はハンド終了まで空席にならない(退席者は Table 側で予約したまま)。
const RUNOUT_STREET = { 3: 'FLOP', 4: 'TURN', 5: 'RIVER' };
class Hand {
constructor({ number, seats, order, button, sb, bb, cards = false }) {
this.number = number;
this.seats = seats;
this.order = order; // 参加者の席番号。ボタンの次から時計回り
this.button = button;
this.sb = sb;
this.bb = bb;
this.cards = cards;
// エンジンには参加者だけを、ボタンを席0として時計回りに座らせる(空席の無い n 席のテーブル)
const n = order.length;
this.toEngine = new Map(order.map((s, k) => [s, (k + 1) % n]));
this.fromEngine = [];
for (const [s, e] of this.toEngine) this.fromEngine[e] = s;
this.engine = new PokerEngine({ smallBlind: sb, bigBlind: bb, maxPlayers: n });
for (const s of order) this.engine.sit(this.toEngine.get(s), String(s), String(s), seats[s].stack);
this.board = []; // 公開済みの場札(オールイン後はエンジンが配り終えていても順にめくる)
this.revealed = false; // ショーダウン・オールイン時に残った人の手札を公開
this.runout = false; // オールインでこれ以上ベットが無く、残りの場札を順にめくっている
this.street = 'PREFLOP'; // PREFLOP / FLOP / TURN / RIVER / SHOWDOWN
this.pot = 0; // 前のストリートまでに集めた額
this.currentBet = 0;
this.raises = 0; // このストリートのベット・レイズの回数(CPU の判断用)
this.actions = 0; // アクションの通し番号(CPU の手番の管理用)
this.aggressor = null; // このストリートで最後にベット・レイズした席(CPU の判断用)
this.prevAggressor = null; // 前のストリートで最後にベット・レイズした席
this.toAct = null;
this.finished = false;
this.showdown = false;
this.results = null;
this.settled = false; // エンジンの中ではハンドが決着した(結果の公開待ちを含む)
this.pots = null; // ショーダウンのポット(同じ参加者のポットはまとめる)
this.engineWon = null; // エンジンが分配した額(席番号 → 額)
this.taken = new Map(); // ハンド中に退席した人が持ち出したチップ(エンジンから外れた分)
// アクションの記録(レビュー用)。1手ごとに、そのときの状況と行動を残す
this.log = [];
this.startStacks = new Map(order.map((s) => [s, seats[s].stack])); // ブラインドを払う前
this.allIn = null; // オールインで決着した時点の場札とポット(レビューの期待値用)
this.players = new Map();
for (const i of order) {
this.players.set(i, {
seat: i,
token: seats[i].token,
cards: [],
best: null, // ショーダウンでの最強の5枚
bet: 0, // このストリートで出した額
committed: 0, // このハンドで出した合計
folded: false,
allIn: false,
last: null, // 直前のアクション(表示用)
won: 0,
});
}
// ヘッズアップはボタンが SB(エンジンも同じ配置にする)
const headsUp = n === 2;
this.sbSeat = headsUp ? button : order[0];
this.bbSeat = headsUp ? order[0] : order[1];
// プリフロップに行動する順(UTG から BB まで)
this.preflopOrder = headsUp ? [button, order[0]] : order.slice(2).concat(order.slice(0, 2));
this.engine.deal();
this.sync();
}
get state() {
return this.engine.state;
}
get list() {
return this.order.map((i) => this.players.get(i));
}
// 勝者の選択待ち(カードありのときは自動で判定するので来ない)
get awaitingWinners() {
return !this.cards && this.street === 'SHOWDOWN' && !this.finished;
}
live() {
return this.list.filter((p) => !p.folded);
}
// 前のストリートまでのポット + このストリートのベット
get potTotal() {
return this.pot + this.list.reduce((sum, p) => sum + p.bet, 0);
}
// エンジンの状態を写す。ハンドが決着していたら結果を作る
sync() {
if (this.settled) return;
const st = this.state;
for (const p of this.list) {
const e = this.toEngine.get(p.seat);
const ep = st.players[e];
p.bet = st.currentBets.get(e) || 0;
if (!ep) {
// 退席でエンジンから外れた(フォールド済み)
p.folded = true;
this.seats[p.seat].stack = this.taken.get(p.seat) || 0;
continue;
}
if (this.cards) p.cards = ep.hand || [];
p.committed = ep.totalInvestedThisHand;
p.folded = ep.status === PlayerStatus.FOLDED;
p.allIn = ep.status === PlayerStatus.ALL_IN;
this.seats[p.seat].stack = (this.taken.get(p.seat) || 0) + ep.stack;
}
const prevStreet = this.street;
const ended = st.winners !== null;
if (ended || st.street !== prevStreet) {
// ストリートが終わったら直前のアクションの表示を消す(フォールドとオールインは残す)
for (const p of this.list) {
if (p.last && p.last.type !== 'FOLD' && p.last.type !== 'ALLIN') p.last = null;
}
this.raises = 0;
this.prevAggressor = this.aggressor;
this.aggressor = null;
}
// 誰もコールしなかった分が返ってオールインでなくなった
for (const p of this.list) if (!p.allIn && p.last && p.last.type === 'ALLIN') p.last = null;
if (ended) return this.settle();
this.street = st.street;
if (this.cards) this.board = st.board.slice();
this.pot = st.pots.reduce((sum, pot) => sum + pot.amount, 0);
// コール額(プリフロップは BB がショートでも BB)
this.currentBet = Math.max(0, ...st.currentBets.values(), st.street === 'PREFLOP' ? st.bigBlind : 0);
this.toAct = st.actionTo == null ? null : this.fromEngine[st.actionTo];
// 離席中・退席済みの人は自動でチェック/フォールド
if (this.toAct != null) {
const s = this.seats[this.toAct];
if (s.sittingOut || s.left) this.autoAct();
}
}
// エンジンの中でハンドが決着した
settle() {
const st = this.state;
this.settled = true;
this.toAct = null;
this.engineWon = new Map();
for (const w of st.winners) this.engineWon.set(this.fromEngine[w.seat], (this.engineWon.get(this.fromEngine[w.seat]) || 0) + w.amount);
// 結果を公開するまでは分配前のスタックを見せる
for (const [s, amount] of this.engineWon) this.seats[s].stack -= amount;
if (this.live().length <= 1) {
const total = [...this.engineWon.values()].reduce((a, b) => a + b, 0);
this.pot = total;
this.applyEngineWon();
return this.end(false, [{ amount: total, winners: [...this.engineWon.keys()] }]);
}
this.pots = this.toPots(calculateSidePots(st));
this.pot = this.pots.reduce((sum, pot) => sum + pot.amount, 0);
if (!this.cards) {
this.street = 'SHOWDOWN';
return;
}
// 手札を公開する。場札が残っていたら Table のタイマーで順にめくる(runoutStep)
this.revealed = true;
if (this.board.length < 5) {
this.runout = true;
this.allIn = { board: this.board.slice(), pots: this.pots };
}
else this.finishShowdown();
}
// エンジンのサイドポットを席番号に直す。同じ参加者のポットはまとめる
toPots(enginePots) {
const pots = [];
for (const ep of enginePots) {
const eligible = this.order.filter((s) => ep.eligibleSeats.includes(this.toEngine.get(s)));
const last = pots[pots.length - 1];
if (last && last.eligible.join() === eligible.join()) last.amount += ep.amount;
else pots.push({ amount: ep.amount, eligible });
}
return pots;
}
applyEngineWon() {
for (const [s, amount] of this.engineWon) {
this.seats[s].stack += amount;
this.players.get(s).won += amount;
}
}
engineAction(seat, type, amount) {
const playerId = String(seat);
if (type === 'fold') return { type: ActionType.FOLD, playerId };
if (type === 'check') return { type: ActionType.CHECK, playerId };
if (type === 'call') return { type: ActionType.CALL, playerId };
// amount はこのストリートでの合計額("raise to")
return { type: this.currentBet === 0 ? ActionType.BET : ActionType.RAISE, playerId, amount };
}
// 手番の人の選択肢(手番でなければ null)
options(seat) {
if (this.finished || this.settled || this.toAct !== seat) return null;
const p = this.players.get(seat);
const stack = this.seats[seat].stack;
const toCall = Math.min(this.currentBet - p.bet, stack);
const maxTo = p.bet + stack;
// 他の全員がオールインならレイズしても意味がない
const opponentCanAct = this.live().some((q) => q !== p && !q.allIn);
// レイズできるか(足りないオールインのあとの再レイズ権など)はエンジンに聞く
const canRaise =
maxTo > this.currentBet && opponentCanAct && this.engine.validate(this.engineAction(seat, 'raise', maxTo)).valid;
return {
toCall,
canCheck: p.bet >= this.currentBet,
canRaise,
minRaiseTo: Math.min(this.state.minRaise, maxTo),
maxRaiseTo: maxTo,
isBet: this.currentBet === 0,
};
}
// type: fold / check / call / raise(amount = このストリートでの合計額 "raise to")
// auto: 離席・退席などで自動で行動した(レビューでは本人の判断として扱わない)
act(seat, type, amount, auto = false) {
const o = this.options(seat);
if (!o) return 'あなたの番ではありません';
const p = this.players.get(seat);
let last;
if (type === 'fold') {
last = { type: 'FOLD' };
} else if (type === 'check') {
if (!o.canCheck) return 'チェックできません';
last = { type: 'CHECK' };
} else if (type === 'call') {
if (o.canCheck || o.toCall <= 0) return 'コールする額がありません';
last = { type: 'CALL', amount: p.bet + o.toCall };
} else if (type === 'raise') {
if (!o.canRaise) return 'レイズできません';
if (!Number.isInteger(amount) || amount < o.minRaiseTo || amount > o.maxRaiseTo) {
return `${o.minRaiseTo}〜${o.maxRaiseTo} の額を指定してください`;
}
last = { type: o.isBet ? 'BET' : 'RAISE', amount };
} else {
return '不正なアクションです';
}
const allIn = type !== 'fold' && type !== 'check' && last.amount === o.maxRaiseTo;
const fullRaise = type === 'raise' && amount >= this.state.minRaise;
const entry = {
street: this.street,
seat,
type: last.type,
amount: last.amount || 0, // このストリートでの合計額
added: type === 'call' ? o.toCall : type === 'raise' ? amount - p.bet : 0, // この行動で出した額
allIn,
auto,
pot: this.potTotal, // 行動する前のポット(このストリートのベットを含む)
toCall: o.toCall,
currentBet: this.currentBet,
bet: p.bet,
stack: this.seats[seat].stack,
board: this.board.slice(),
canCheck: o.canCheck,
canRaise: o.canRaise,
minRaiseTo: o.minRaiseTo,
maxRaiseTo: o.maxRaiseTo,
live: this.live().length,
};
try {
this.engine.act(this.engineAction(seat, type, amount));
} catch {
// エンジンが受け付けない(チェックできるのに降りてサイドポットの対象者がいなくなる場合など)
return type === 'fold' && o.canCheck ? 'いまはフォールドできません。チェックしてください' : 'そのアクションはできません';
}
p.last = allIn ? { type: 'ALLIN', amount: last.amount } : last;
this.log.push(entry);
this.actions++;
if (type === 'raise') {
this.aggressor = seat;
if (fullRaise) this.raises++;
}
this.sync();
return null;
}
autoAct() {
const o = this.options(this.toAct);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0, true);
}
// 退席などで手番に関係なくフォールドさせる。オールインの人の手は最後まで生きている
forceFold(seat) {
const p = this.players.get(seat);
if (!p || p.folded || this.finished) return;
if (this.toAct === seat) {
if (this.act(seat, 'fold', 0, true)) this.autoAct();
return;
}
if (this.settled) {
// キャッシュオンリーの勝者選び待ち: 選べる人から外す
if (!this.awaitingWinners) return;
p.folded = true;
p.last = { type: 'FOLD' };
if (this.live().length === 1) return this.finishFoldWin();
// 選べる人がいなくなったポットは前のポットにまとめる(メインポットには残った全員が入っている)
const pots = [];
for (const pot of this.pots) {
const eligible = pot.eligible.filter((s) => s !== seat);
const last = pots[pots.length - 1];
if (last && (eligible.length === 0 || last.eligible.join() === eligible.join())) last.amount += pot.amount;
else pots.push({ amount: pot.amount, eligible });
}
this.pots = pots;
return;
}
// エンジンは手番以外のフォールドを「席を立つ」として扱う。持ち出したチップは自分で覚えておく。
// オールインの人などエンジンが受け付けないときは、手番が来たら自動でチェック/フォールドする
const stand = { type: ActionType.STAND, playerId: String(seat) };
if (!this.engine.validate(stand).valid) return;
const before = this.state.initialChips;
this.engine.stand(String(seat));
this.taken.set(seat, (this.taken.get(seat) || 0) + before - this.state.initialChips);
p.last = { type: 'FOLD' };
this.log.push({ street: this.street, seat, type: 'FOLD', amount: 0, added: 0, auto: true, pot: this.potTotal, board: this.board.slice() });
this.sync();
}
// オールイン後に場札を1ストリート分めくる。リバーまでめくり終えていたらショーダウン
runoutStep() {
if (!this.runout || this.finished) return;
const n = this.board.length;
if (n === 5) return this.finishShowdown();
const next = n === 0 ? 3 : n + 1;
this.board = this.state.board.slice(0, next);
this.street = RUNOUT_STREET[next];
}
// キャッシュオンリーのショーダウン中に1人を残して退席した: 残った人がすべて獲得
finishFoldWin() {
const winner = this.live()[0];
winner.won = this.pot;
this.seats[winner.seat].stack += this.pot;
this.end(false, [{ amount: this.pot, winners: [winner.seat] }]);
}
// ショーダウンの勝者を決める。winners[i] = i 番目のポットの勝者の席番号(引き分けは複数)
award(winners) {
if (!this.awaitingWinners) return 'ショーダウンではありません';
const pots = this.pots;
if (!Array.isArray(winners) || winners.length !== pots.length) return 'すべてのポットの勝者を選んでください';
for (let i = 0; i < pots.length; i++) {
const w = winners[i];
if (!Array.isArray(w) || w.length === 0) return 'すべてのポットの勝者を選んでください';
if (new Set(w).size !== w.length || !w.every((s) => pots[i].eligible.includes(s))) return '勝者の指定が不正です';
}
const results = pots.map((pot, i) => ({ amount: pot.amount, winners: this.payout(pot.amount, winners[i]) }));
this.end(true, results);
return null;
}
// カードありのショーダウン: エンジンが分配した結果を公開する。表示用にポットごとの勝者と役を添える
finishShowdown() {
this.board = this.state.board.slice();
for (const p of this.live()) p.best = bestHand(p.cards.concat(this.board));
const results = this.pots.map((pot) => {
const contenders = pot.eligible.map((s) => this.players.get(s));
let top = null;
for (const c of contenders) if (top == null || compareScore(c.best.score, top) > 0) top = c.best.score;
const best = contenders.filter((c) => compareScore(c.best.score, top) === 0);
return {
amount: pot.amount,
winners: best.map((c) => c.seat),
handName: best[0].best.name,
bestCards: best[0].best.cards,
};
});
this.applyEngineWon();
this.end(true, results);
}
// amount を seats で山分けする。割り切れない端数はボタンの左から順に。分配した順の席番号を返す
// (キャッシュオンリーでホストが勝者を選んだときだけ使う)
payout(amount, seats) {
const ws = this.order.filter((s) => seats.includes(s));
const share = Math.floor(amount / ws.length);
let rest = amount - share * ws.length;
for (const s of ws) {
const a = share + (rest > 0 ? 1 : 0);
if (rest > 0) rest--;
this.players.get(s).won += a;
this.seats[s].stack += a;
}
return ws;
}
end(showdown, results) {
this.endStacks = new Map(this.order.map((s) => [s, this.seats[s] ? this.seats[s].stack : 0])); // レビュー用
this.finished = true;
this.runout = false;
this.showdown = showdown;
this.toAct = null;
this.results = results;
}
// 閲覧者に見せてよい情報だけを返す(他人の手札は公開時のみ)
view(viewerSeat, viewerToken) {
const players = {};
for (const p of this.list) {
const v = { bet: p.bet, folded: p.folded, allIn: p.allIn, last: p.last, won: p.won };
if (this.cards) {
const mine = p.seat === viewerSeat && p.token === viewerToken;
const shown = mine || (this.revealed && !p.folded);
v.cards = shown ? p.cards : null;
v.handName =
shown && !p.folded && this.board.length >= 3 ? (p.best || bestHand(p.cards.concat(this.board))).name : null;
}
players[p.seat] = v;
}
return {
number: this.number,
cards: this.cards,
board: this.board,
runout: this.runout,
street: this.street,
pot: this.pot,
potTotal: this.potTotal,
currentBet: this.currentBet,
button: this.button,
sbSeat: this.sbSeat,
bbSeat: this.bbSeat,
toAct: this.toAct,
finished: this.finished,
showdown: this.showdown,
pots: this.awaitingWinners ? this.pots : null,
results: this.results,
players,
};
}
}
module.exports = { Hand };