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>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-19 22:11:16 +09:00
1 parent 3116b1da9f
commit 3f2515a9fa
25 files changed
+2262 -60

No files matched your search

+37 -4
View File
@@ -52,6 +52,10 @@ class Hand {
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) {
@@ -73,6 +77,8 @@ class Hand {
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();
}
@@ -174,7 +180,10 @@ class Hand {
}
// 手札を公開する。場札が残っていたら Table のタイマーで順にめくる(runoutStep)
this.revealed = true;
if (this.board.length < 5) this.runout = true;
if (this.board.length < 5) {
this.runout = true;
this.allIn = { board: this.board.slice(), pots: this.pots };
}
else this.finishShowdown();
}
@@ -229,7 +238,8 @@ class Hand {
}
// type: fold / check / call / raise(amount = このストリートでの合計額 "raise to")
act(seat, type, amount) {
// auto: 離席・退席などで自動で行動した(レビューでは本人の判断として扱わない)
act(seat, type, amount, auto = false) {
const o = this.options(seat);
if (!o) return 'あなたの番ではありません';
const p = this.players.get(seat);
@@ -254,6 +264,26 @@ class Hand {
}
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 {
@@ -261,6 +291,7 @@ class Hand {
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;
@@ -272,7 +303,7 @@ class Hand {
autoAct() {
const o = this.options(this.toAct);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0, true);
}
// 退席などで手番に関係なくフォールドさせる。オールインの人の手は最後まで生きている
@@ -280,7 +311,7 @@ class Hand {
const p = this.players.get(seat);
if (!p || p.folded || this.finished) return;
if (this.toAct === seat) {
if (this.act(seat, 'fold', 0)) this.autoAct();
if (this.act(seat, 'fold', 0, true)) this.autoAct();
return;
}
if (this.settled) {
@@ -308,6 +339,7 @@ class Hand {
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();
}
@@ -381,6 +413,7 @@ class Hand {
}
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;