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

+132
View File
@@ -0,0 +1,132 @@
'use strict';
// 1回の対局(レビュー付きCPU戦のテーブル1つ)のハンドのレビューをまとめる
// 成績(VPIP・PFR・3bet・Cベット・WTSD・W$SD・AF)、ストリートごとの平均点、
// 色ごとの参加率とレンジどおりに打てた割合、ポジションごとの傾向とクセ、見直したいハンド、収支の推移
const HandRange = require('../../admin/range-data');
const { STREETS, COLOR_LABEL } = require('./analyze');
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
const mean = (xs) => (xs.length ? Math.round(xs.reduce((x, y) => x + y, 0) / xs.length) : null);
const POSITION_ORDER = ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO', 'BTN', 'BTN/SB', 'SB', 'BB'];
const STREET_LABEL = { FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
// クセとして数える判定と、その説明
const LEAK_TEXT = {
loose: 'レンジ外の手で参加',
tight: '参加できる手で降りた',
passive: 'レイズできる手でコール',
aggressive: 'コールまでの手でレイズ',
needless: 'チェックできるのに降りた',
};
function summarize(hands) {
const n = hands.length;
const count = (f) => hands.filter(f).length;
const stat = (key) => count((h) => h.stats[key]);
const sawFlop = stat('sawFlop');
const showdowns = stat('showdown');
const aggressive = hands.reduce((a, h) => a + h.stats.aggressive, 0);
const calls = hands.reduce((a, h) => a + h.stats.calls, 0);
const stats = {
hands: n,
vpip: pct(stat('vpip'), n),
pfr: pct(stat('pfr'), n),
threeBet: pct(stat('threeBet'), stat('threeBetOpp')),
threeBetOpp: stat('threeBetOpp'),
cbet: pct(stat('cbet'), stat('cbetOpp')),
cbetOpp: stat('cbetOpp'),
wtsd: pct(showdowns, sawFlop),
wsd: pct(stat('wonShowdown'), showdowns),
af: calls ? Math.round((aggressive / calls) * 100) / 100 : aggressive ? null : 0,
};
const streets = {};
for (const st of STREETS) streets[st] = mean(hands.map((h) => h.streetScores[st]).filter((x) => x != null));
const total = mean(hands.map((h) => h.handScore).filter((x) => x != null));
// 色ごと: 配られた回数・参加した回数・プリフロップの判断がレンジどおりだった割合
const colors = HandRange.CATEGORIES.map((c) => {
const hs = hands.filter((h) => h.hero.color === c.id);
const judged = hs.flatMap((h) => h.preflop);
return {
id: c.id,
name: c.name,
label: COLOR_LABEL[c.id] || c.label,
dealt: hs.length,
played: hs.filter((h) => h.stats.vpip).length,
judged: judged.length,
good: judged.filter((j) => j.verdict === 'good').length,
};
});
// ポジションごと
const byPos = new Map();
for (const h of hands) {
const pos = h.hero.position;
if (!byPos.has(pos)) byPos.set(pos, { position: pos, hands: 0, played: 0, judged: 0, good: 0, verdicts: {} });
const p = byPos.get(pos);
p.hands++;
if (h.stats.vpip) p.played++;
for (const j of h.preflop) {
p.judged++;
if (j.verdict === 'good') p.good++;
else p.verdicts[j.verdict] = (p.verdicts[j.verdict] || 0) + 1;
}
}
const positions = [...byPos.values()].sort((a, b) => POSITION_ORDER.indexOf(a.position) - POSITION_ORDER.indexOf(b.position));
// クセ: ポジション × 間違いの種類で多いもの。フロップ以降は失った期待値が大きいストリート × 行動
const leaks = [];
for (const p of positions) {
for (const [v, k] of Object.entries(p.verdicts)) leaks.push({ text: `${p.position}: ${LEAK_TEXT[v]}`, count: k, of: p.judged });
}
const postLeaks = new Map();
for (const h of hands) {
for (const d of h.decisions) {
if (d.score >= 70) continue;
const key = `${d.street}:${d.actual.kind}`;
if (!postLeaks.has(key)) postLeaks.set(key, { street: d.street, kind: d.actual.kind, count: 0, lossBB: 0 });
const x = postLeaks.get(key);
x.count++;
x.lossBB += d.lossBB;
}
}
const KIND = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'ベット・レイズ' };
for (const x of postLeaks.values()) {
leaks.push({ text: `${STREET_LABEL[x.street]}: ${KIND[x.kind]}で期待値を失った(合計 ${Math.round(x.lossBB * 10) / 10}BB)`, count: x.count });
}
leaks.sort((a, b) => b.count - a.count);
// 見直したいハンド: 失った期待値(BB)が大きい順、同じならプリフロップの判定が悪い順
const worst = hands
.filter((h) => h.lossBB > 0 || (h.handScore != null && h.handScore < 100))
.map((h) => ({
number: h.number,
key: h.hero.key,
color: h.hero.color,
position: h.hero.position,
lossBB: h.lossBB,
handScore: h.handScore,
net: h.hero.net,
bb: h.bb,
}))
.sort((a, b) => b.lossBB - a.lossBB || a.handScore - b.handScore)
.slice(0, 5);
// 収支の推移(実際の収支と、オールインの期待値で運を除いた収支)
let net = 0;
let evNet = 0;
const graph = hands.map((h) => {
net += h.hero.net;
evNet += h.hero.evNet;
return { number: h.number, net, evNet };
});
return { stats, streets, total, colors, positions, leaks: leaks.slice(0, 6), worst, graph };
}
module.exports = { summarize };