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

+75
View File
@@ -0,0 +1,75 @@
// 登録してあるハンドレンジ(ハンドレンジ学習ページとレビュー付きCPU戦で共用・管理者専用)
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.HandRange = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const RANKS = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
// 強い順。tier が大きいほど強い(レビューでは「一つ上」「二つ上」をこの段階で数える)
// 人数は「自分を含めず、まだ行動していない人の数」(自分より前に誰もコール・レイズしていないとき)。
// 紫枠は後ろが1人(SB で全員が降りて回ってきた)ときだけオープンできる手として、白の一つ下に置く
const CATEGORIES = [
{ id: 'navy', name: '紺', label: '8人(強)', tier: 7 },
{ id: 'red', name: '赤', label: '8人(弱)', tier: 6 },
{ id: 'yellow', name: '黄', label: '6〜7人', tier: 5 },
{ id: 'green', name: '緑', label: '4〜5人', tier: 4 },
{ id: 'blue', name: '青', label: '3人', tier: 3 },
{ id: 'white', name: '白', label: '2人', tier: 2 },
{ id: 'purple', name: '紫枠', label: '紫枠', tier: 1 },
{ id: 'gray', name: 'グレー', label: 'フォールド', tier: 0 },
];
// 全169ハンドの分類(載っていないハンドはフォールド)。紫枠はクイズ上の独立した回答
const RANGE = {
AA: 'navy', AKs: 'navy', AQs: 'red', AJs: 'red', ATs: 'red', A9s: 'green', A8s: 'green', A7s: 'green', A6s: 'green', A5s: 'green', A4s: 'green', A3s: 'green', A2s: 'green',
AKo: 'navy', KK: 'navy', KQs: 'red', KJs: 'yellow', KTs: 'green', K9s: 'green', K8s: 'white', K7s: 'white', K6s: 'white', K5s: 'white', K4s: 'white', K3s: 'white', K2s: 'white',
AQo: 'red', KQo: 'yellow', QQ: 'navy', QJs: 'yellow', QTs: 'green', Q9s: 'blue', Q8s: 'white', Q7s: 'white', Q6s: 'white', Q5s: 'purple', Q4s: 'purple', Q3s: 'purple', Q2s: 'purple',
AJo: 'yellow', KJo: 'green', QJo: 'blue', JJ: 'red', JTs: 'yellow', J9s: 'blue', J8s: 'white', J7s: 'white', J6s: 'purple',
ATo: 'green', KTo: 'blue', QTo: 'white', JTo: 'blue', TT: 'red', T9s: 'green', T8s: 'blue', T7s: 'purple',
A9o: 'blue', K9o: 'white', Q9o: 'white', J9o: 'white', T9o: 'white', 99: 'red', '98s': 'blue', '97s': 'white', '96s': 'purple',
A8o: 'white', '98o': 'purple', 88: 'yellow', '87s': 'white', '86s': 'purple',
A7o: 'white', 77: 'yellow', '76s': 'white', '75s': 'purple',
A6o: 'purple', 66: 'green', '65s': 'white', '64s': 'purple',
55: 'green', '54s': 'purple',
44: 'blue',
33: 'blue',
22: 'blue',
};
const BY_ID = new Map(CATEGORIES.map((c) => [c.id, c]));
// 13×13 の表の (行, 列) のハンド。右上がスーテッド、左下がオフスート
const handAt = (r, c) => (r === c ? RANKS[r] + RANKS[c] : r < c ? RANKS[r] + RANKS[c] + 's' : RANKS[c] + RANKS[r] + 'o');
const categoryOf = (hand) => RANGE[hand] || 'gray';
const category = (id) => BY_ID.get(id) || BY_ID.get('gray');
const labelOf = (id) => category(id).label;
const tierOf = (hand) => category(categoryOf(hand)).tier;
const categoryOfTier = (tier) => CATEGORIES.find((c) => c.tier === tier) || BY_ID.get('gray');
// ["As", "Kd"] → "AKo"
function handKey(cards) {
const [a, b] = cards;
const ia = RANKS.indexOf(a[0]);
const ib = RANKS.indexOf(b[0]);
const hi = ia <= ib ? a : b;
const lo = ia <= ib ? b : a;
if (hi[0] === lo[0]) return hi[0] + lo[0];
return hi[0] + lo[0] + (hi[1] === lo[1] ? 's' : 'o');
}
// 自分より前に誰もコール・レイズしていないとき、後ろの人数(自分を含めず、まだ行動していない人)で
// 参加してよい一番弱い段階。8人以上は赤(紺は赤の一つ上)。0人(全員降りてBBに回った)は無し
function requiredTier(behind) {
if (behind >= 8) return 6;
if (behind >= 6) return 5;
if (behind >= 4) return 4;
if (behind === 3) return 3;
if (behind === 2) return 2;
if (behind === 1) return 1;
return 0;
}
return { RANKS, CATEGORIES, RANGE, handAt, handKey, categoryOf, category, labelOf, tierOf, categoryOfTier, requiredTier };
});
+1
View File
@@ -70,6 +70,7 @@
</section>
</main>
<script src="/range/files/range-data.js"></script>
<script src="/range/files/range.js"></script>
</body>
</html>
+1 -33
View File
@@ -3,39 +3,7 @@
'use strict';
const $ = (id) => document.getElementById(id);
const RANKS = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
const CATEGORIES = [
{ id: 'navy', label: '8人(強)' },
{ id: 'red', label: '8人(弱)' },
{ id: 'yellow', label: '6〜7人' },
{ id: 'green', label: '4〜5人' },
{ id: 'blue', label: '3人' },
{ id: 'white', label: '2人' },
{ id: 'purple', label: '紫枠' },
{ id: 'gray', label: 'フォールド' },
];
// 全169ハンドの分類(載っていないハンドはフォールド)。紫枠はクイズ上の独立した回答
const RANGE = {
AA: 'navy', AKs: 'navy', AQs: 'red', AJs: 'red', ATs: 'red', A9s: 'green', A8s: 'green', A7s: 'green', A6s: 'green', A5s: 'green', A4s: 'green', A3s: 'green', A2s: 'green',
AKo: 'navy', KK: 'navy', KQs: 'red', KJs: 'yellow', KTs: 'green', K9s: 'green', K8s: 'white', K7s: 'white', K6s: 'white', K5s: 'white', K4s: 'white', K3s: 'white', K2s: 'white',
AQo: 'red', KQo: 'yellow', QQ: 'navy', QJs: 'yellow', QTs: 'green', Q9s: 'blue', Q8s: 'white', Q7s: 'white', Q6s: 'white', Q5s: 'purple', Q4s: 'purple', Q3s: 'purple', Q2s: 'purple',
AJo: 'yellow', KJo: 'green', QJo: 'blue', JJ: 'red', JTs: 'yellow', J9s: 'blue', J8s: 'white', J7s: 'white', J6s: 'purple',
ATo: 'green', KTo: 'blue', QTo: 'white', JTo: 'blue', TT: 'red', T9s: 'green', T8s: 'blue', T7s: 'purple',
A9o: 'blue', K9o: 'white', Q9o: 'white', J9o: 'white', T9o: 'white', 99: 'red', '98s': 'blue', '97s': 'white', '96s': 'purple',
A8o: 'white', '98o': 'purple', 88: 'yellow', '87s': 'white', '86s': 'purple',
A7o: 'white', 77: 'yellow', '76s': 'white', '75s': 'purple',
A6o: 'purple', 66: 'green', '65s': 'white', '64s': 'purple',
55: 'green', '54s': 'purple',
44: 'blue',
33: 'blue',
22: 'blue',
};
const handAt = (r, c) => (r === c ? RANKS[r] + RANKS[c] : r < c ? RANKS[r] + RANKS[c] + 's' : RANKS[c] + RANKS[r] + 'o');
const categoryOf = (hand) => RANGE[hand] || 'gray';
const labelOf = (id) => (CATEGORIES.find((c) => c.id === id) || CATEGORIES[CATEGORIES.length - 1]).label;
const { RANKS, CATEGORIES, handAt, categoryOf, labelOf } = window.HandRange;
function swatch(id) {
const s = document.createElement('span');
+145
View File
@@ -0,0 +1,145 @@
/* CPU戦のレビュー(管理者専用)。基本のデザインは /assets/style.css、タブは range.css、
ハンドレンジの色・点数の印(.rv-color / .rv-mark など)は style.css の「レビュー付きCPU戦」を使う */
.review-wrap { padding-bottom: 40px; }
.plus { color: var(--green); }
.minus { color: var(--red); }
/* 点数の色(◎ ○ △ ✕ と同じ段階) */
.score-great { color: var(--green); }
.score-good { color: #8fd18f; }
.score-warn { color: var(--orange); }
.score-bad { color: var(--red); }
.score-none { color: var(--muted); }
/* 対局の一覧 */
.rv-sessions { display: grid; gap: 8px; margin-top: 12px; }
.rv-session {
display: grid;
gap: 4px;
text-align: left;
font: inherit;
color: var(--text);
background: var(--panel);
border: 0;
border-radius: var(--radius);
padding: 14px 16px;
cursor: pointer;
}
.rv-session:hover, .rv-session:focus-visible { background: var(--panel-2); }
.rv-session-name { font-weight: 800; font-size: 16px; display: flex; align-items: center; gap: 8px; }
.rv-session-meta { color: var(--muted); font-size: 12px; }
/* 概要とタイル */
.rv-overview { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; }
.rv-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.rv-tile { background: var(--panel); border-radius: 12px; padding: 10px 12px; display: grid; gap: 2px; min-width: 0; }
.rv-tile span { font-size: 12px; color: var(--muted); font-weight: 700; }
.rv-tile b { font-size: 22px; font-variant-numeric: tabular-nums; }
.rv-tile small { font-size: 11px; color: var(--muted); line-height: 1.4; }
.rv-tabs .seg-btn { font-size: 13px; padding: 9px 2px; }
/* 表 */
.rv-table-wrap { overflow-x: auto; margin-top: 8px; border-radius: 12px; background: var(--panel); }
.rv-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.rv-table th, .rv-table td { padding: 8px 6px; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--line); }
.rv-table th { color: var(--muted); font-size: 11px; font-weight: 700; }
.rv-table .num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; }
.rv-table tbody tr[data-hand] { cursor: pointer; }
.rv-table tbody tr[data-hand]:hover, .rv-table tbody tr[data-hand]:focus-visible { background: var(--panel-2); outline: none; }
.rv-table tfoot td { font-weight: 800; border-bottom: 0; }
.rv-hand-cell b { margin-right: 2px; }
.rv-table .rv-color { font-size: 11px; padding: 1px 6px; }
.rv-leaks { margin: 0; padding-left: 20px; display: grid; gap: 4px; font-size: 14px; }
.rv-leaks b { color: var(--gold); }
/* 見直したいハンド */
.rv-worst { display: grid; gap: 8px; }
.rv-worst-item {
display: grid;
grid-template-columns: 28px 1fr;
gap: 2px 8px;
align-items: center;
text-align: left;
font: inherit;
color: var(--text);
background: var(--panel);
border: 0;
border-radius: 12px;
padding: 10px 12px;
cursor: pointer;
}
.rv-worst-item:hover, .rv-worst-item:focus-visible { background: var(--panel-2); }
.rv-rank { grid-row: span 2; font-size: 20px; font-weight: 900; color: var(--gold); text-align: center; }
.rv-worst-meta { font-size: 12px; color: var(--muted); }
.rv-worst-item .rv-color { width: 14px; height: 14px; padding: 0; vertical-align: -2px; }
/* 収支のグラフ(実際 = 実線、運を除く = 破線。色だけに頼らない) */
.rv-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 13px; margin-bottom: 6px; }
.sw { display: inline-block; width: 16px; height: 0; border-top: 2px solid; vertical-align: middle; margin-right: 6px; }
.sw.actual { border-color: #b8891c; }
.sw.ev { border-color: #4a90e2; border-top-style: dashed; }
.rv-chart { position: relative; background: var(--panel); border-radius: 12px; padding: 8px; }
.rv-chart svg { display: block; width: 100%; height: auto; touch-action: pan-y; }
.rv-chart .gl { stroke: var(--line); stroke-width: 1; }
.rv-chart .zero { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 2 3; }
.rv-chart .tick { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.rv-chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.rv-chart .line.actual { stroke: #b8891c; }
.rv-chart .line.ev { stroke: #4a90e2; stroke-dasharray: 6 4; }
.rv-chart .cross { stroke: var(--muted); stroke-width: 1; }
.rv-chart .dot { stroke: var(--panel); stroke-width: 2; }
.rv-chart .dot.actual { fill: #b8891c; }
.rv-chart .dot.ev { fill: #4a90e2; }
.rv-chart .hit { fill: transparent; cursor: crosshair; }
.rv-tip {
position: absolute;
top: 8px;
background: var(--bg-deep);
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
pointer-events: none;
white-space: nowrap;
}
/* リプレイ */
.rv-note { margin-top: 10px; font-size: 13px; background: var(--panel); border-radius: 10px; padding: 8px 10px; }
.rv-board-box { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 14px; background: var(--felt-dark); border-radius: 12px; padding: 10px; }
.rv-board { display: flex; gap: 3px; }
.rv-pot { font-weight: 900; color: #fff; font-variant-numeric: tabular-nums; }
.rv-step { margin-top: 10px; background: var(--panel); border-radius: 12px; padding: 10px 12px; font-size: 14px; min-height: 64px; }
.rv-step-title { font-size: 12px; font-weight: 800; color: var(--gold); margin-bottom: 2px; }
.rv-controls { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 8px; }
.rv-controls .btn { padding: 10px 0; font-size: 16px; }
.rv-analysis { margin-top: 10px; background: var(--panel); border: 1px solid var(--gold); border-radius: 12px; padding: 6px 12px 10px; }
.rv-analysis .rv-item:first-child { border-top: 0; }
.rv-ev { margin-top: 6px; }
.rv-ev tr.actual td { background: rgba(246, 199, 68, 0.08); }
.rv-ev .best { color: var(--green); }
.rv-players { display: grid; gap: 6px; margin-top: 12px; }
.rv-player { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; background: var(--panel); border-radius: 10px; padding: 6px 10px; border: 2px solid transparent; }
.rv-player.hero { border-color: rgba(246, 199, 68, 0.35); }
.rv-player.acting { border-color: var(--gold); }
.rv-player.folded { opacity: 0.55; }
.rv-player-info { min-width: 0; font-size: 14px; }
.rv-player-act { font-size: 12px; font-weight: 800; color: var(--muted); text-align: right; }
.rv-pos { font-size: 11px; font-weight: 800; color: var(--gold); margin-left: 4px; }
.rv-log { display: flex; flex-wrap: wrap; gap: 4px; }
.rv-log-street { width: 100%; font-size: 11px; color: var(--muted); font-weight: 800; margin-top: 6px; }
.rv-log-item {
font: inherit;
font-size: 12px;
color: var(--text);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
}
.rv-log-item .hint { margin: 0 4px; font-size: 11px; }
.rv-log-item.mine { border-color: rgba(246, 199, 68, 0.6); }
.rv-log-item.current { background: var(--gold); color: #1a1a1a; }
.rv-log-item.current .hint { color: #333; }
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<meta name="robots" content="noindex, nofollow">
<title>CPU戦のレビュー - Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/range/files/range.css">
<link rel="stylesheet" href="/range/files/review.css">
</head>
<body>
<main class="wrap review-wrap">
<div class="topbar">
<a class="icon-btn" id="back" href="/" aria-label="戻る">←</a>
<h1 id="title">CPU戦のレビュー</h1>
</div>
<!-- 対局の一覧 -->
<section id="view-list">
<p class="hint">レビュー付きCPU戦の対局です(新しい順・最大50件)。</p>
<div id="sessions" class="rv-sessions"></div>
</section>
<!-- 1回の対局 -->
<section id="view-session" hidden>
<div class="rv-overview" id="overview"></div>
<div class="seg rv-tabs" id="tabs">
<button type="button" class="seg-btn active" data-tab="scores">点数表</button>
<button type="button" class="seg-btn" data-tab="stats">成績</button>
<button type="button" class="seg-btn" data-tab="range">色・位置</button>
<button type="button" class="seg-btn" data-tab="worst">見直し</button>
<button type="button" class="seg-btn" data-tab="graph">収支</button>
</div>
<div id="tab-body"></div>
</section>
<!-- 1ハンドのリプレイ -->
<section id="view-replay" hidden>
<div class="rv-replay-head" id="replay-head"></div>
<div class="rv-board-box">
<div class="rv-board" id="replay-board"></div>
<div class="rv-pot" id="replay-pot"></div>
</div>
<div class="rv-step" id="replay-step" aria-live="polite"></div>
<div class="rv-controls">
<button type="button" class="btn btn-dark" data-step="first" aria-label="最初へ">⏮</button>
<button type="button" class="btn btn-dark" data-step="prev" aria-label="前へ">◀</button>
<button type="button" class="btn btn-blue" data-step="next" aria-label="次へ">▶</button>
<button type="button" class="btn btn-dark" data-step="last" aria-label="最後へ">⏭</button>
</div>
<div id="replay-analysis"></div>
<div class="rv-players" id="replay-players"></div>
<div class="rv-section">アクション一覧</div>
<div class="rv-log" id="replay-log"></div>
</section>
</main>
<script src="/assets/common.js"></script>
<script src="/range/files/review.js"></script>
</body>
</html>
+559
View File
@@ -0,0 +1,559 @@
// CPU戦のレビュー(管理者専用): 対局ごとの点数表・成績・色とポジションの傾向・見直したいハンド・収支の推移と、
// 1ハンドずつのリプレイ
(function () {
'use strict';
const { esc, chips, api, toast } = App;
const $ = (id) => document.getElementById(id);
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
const STREET_LABEL = { PREFLOP: 'プリフロップ', FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
const STREET_SHORT = { PREFLOP: 'PF', FLOP: 'Flop', TURN: 'Turn', RIVER: 'River' };
const BOARD_COUNT = { PREFLOP: 0, FLOP: 3, TURN: 4, RIVER: 5 };
const KIND_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
const TYPE_LABEL = { FOLD: 'フォールド', CHECK: 'チェック', CALL: 'コール', BET: 'ベット', RAISE: 'レイズ' };
const MARK_CLASS = { '◎': 'great', '○': 'good', '△': 'warn', '✕': 'bad' };
const SUIT_SYMBOL = { s: '♠︎', h: '♥︎', d: '♦︎', c: '♣︎' };
let session = null;
let tab = 'scores';
let replay = null; // { hand, step }
const markOf = (score) => (score == null ? '' : score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕');
const scoreClass = (score) => (score == null ? 'none' : MARK_CLASS[markOf(score)]);
const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '±') + chips(Math.abs(n));
const signClass = (n) => (n > 0 ? 'plus' : n < 0 ? 'minus' : '');
const pctText = (v) => (v == null ? '—' : `${v}%`);
const dateText = (t) => new Date(t).toLocaleString('ja-JP', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
const optionLabel = (o) => (o.kind === 'raise' ? `ベット/レイズ ${chips(o.to)}` : KIND_LABEL[o.kind]);
const colorChip = (h) => `<span class="rv-color cat-${esc(h.color)}">${esc(h.colorName)}</span>`;
function cardHtml(c, extra = '') {
if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return `<span class="pcard slot ${extra}"></span>`;
const rank = c[0] === 'T' ? '10' : c[0];
return `<span class="pcard suit-${c[1]} ${extra}"><b>${rank}</b><i>${SUIT_SYMBOL[c[1]]}</i></span>`;
}
// ---------- 画面の切り替え(URL: ?s=対局ID&h=ハンド番号) ----------
function show(view) {
for (const v of ['list', 'session', 'replay']) $(`view-${v}`).hidden = v !== view;
window.scrollTo(0, 0);
}
function go(params, push = true) {
const q = new URLSearchParams(params).toString();
const url = location.pathname + (q ? `?${q}` : '');
if (push) history.pushState(null, '', url);
else history.replaceState(null, '', url);
route();
}
async function route() {
const p = new URLSearchParams(location.search);
const id = p.get('s');
const hand = Number(p.get('h'));
if (!id) {
$('title').textContent = 'CPU戦のレビュー';
$('back').href = '/';
show('list');
return loadList();
}
if (!session || session.id !== id || !hand) {
try {
session = await api('GET', `/api/reviews/${encodeURIComponent(id)}`);
} catch (err) {
toast(err.message);
return go({}, false);
}
}
$('title').textContent = session.tableName;
if (hand) {
const h = session.hands.find((x) => x.number === hand);
if (h) {
$('back').href = `?s=${encodeURIComponent(id)}`;
return openReplay(h);
}
}
$('back').href = '/review';
show('session');
renderSession();
}
window.addEventListener('popstate', route);
// 戻るボタンは履歴を積まずに一つ上へ
$('back').addEventListener('click', (e) => {
const href = $('back').getAttribute('href');
if (href === '/') return;
e.preventDefault();
const p = new URLSearchParams(location.search);
go(p.get('h') ? { s: p.get('s') } : {});
});
// ---------- 対局の一覧 ----------
async function loadList() {
let list;
try {
list = await api('GET', '/api/reviews');
} catch (err) {
$('sessions').innerHTML = `<div class="empty">${esc(err.message)}</div>`;
return;
}
if (!list.length) {
$('sessions').innerHTML = '<div class="empty">まだ対局がありません。<br>ロビーの「テーブルを作成」で「レビュー付きCPU戦」を選んでください。</div>';
return;
}
$('sessions').innerHTML = list
.map(
(s) => `
<button type="button" class="rv-session" data-id="${esc(s.id)}">
<span class="rv-session-name">${esc(s.tableName)}${s.active ? '<span class="badge live">PLAYING</span>' : ''}</span>
<span class="rv-session-meta">${dateText(s.createdAt)} ・ ${s.hands}ハンド ・ ${esc(s.heroName)}</span>
</button>`
)
.join('');
}
$('sessions').addEventListener('click', (e) => {
const b = e.target.closest('[data-id]');
if (b) go({ s: b.dataset.id });
});
// ---------- 1回の対局 ----------
function renderSession() {
const s = session.summary;
const net = s.graph.length ? s.graph[s.graph.length - 1].net : 0;
$('overview').innerHTML = `
<div class="rv-tile"><span>ハンド数</span><b>${s.stats.hands}</b></div>
<div class="rv-tile"><span>平均点</span><b class="score-${scoreClass(s.total)}">${s.total == null ? '—' : s.total}</b></div>
<div class="rv-tile"><span>収支</span><b class="${signClass(net)}">${signed(net)}</b></div>`;
for (const b of $('tabs').children) b.classList.toggle('active', b.dataset.tab === tab);
const render = { scores: renderScores, stats: renderStats, range: renderRange, worst: renderWorst, graph: renderGraph }[tab];
$('tab-body').innerHTML = '';
render();
}
$('tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-tab]');
if (!b) return;
tab = b.dataset.tab;
renderSession();
});
function scoreCell(v) {
return `<td class="num score-${scoreClass(v)}">${v == null ? '—' : v}</td>`;
}
// 点数表: ハンドごと・ストリートごとの点数
function renderScores() {
const hands = session.hands;
if (!hands.length) {
$('tab-body').innerHTML = '<div class="empty">まだハンドがありません。</div>';
return;
}
const rows = hands
.slice()
.reverse()
.map(
(h) => `
<tr data-hand="${h.number}" tabindex="0">
<td class="num">${h.number}</td>
<td class="rv-hand-cell"><b>${esc(h.hero.key)}</b> ${colorChip(h.hero)}</td>
<td>${esc(h.hero.position)}</td>
${STREETS.map((st) => scoreCell(h.streetScores[st])).join('')}
${scoreCell(h.handScore)}
<td class="num ${signClass(h.hero.net)}">${signed(h.hero.net)}</td>
</tr>`
)
.join('');
const avg = session.summary.streets;
$('tab-body').innerHTML = `
<p class="hint">行をタップするとリプレイと詳しい判定を表示します。PF はハンドレンジどおりか、Flop 以降は推定レンジに対する期待値の目安(100点 = 最善)。</p>
<div class="rv-table-wrap">
<table class="rv-table">
<thead><tr><th>#</th><th>ハンド</th><th>位置</th>${STREETS.map((st) => `<th>${STREET_SHORT[st]}</th>`).join('')}<th>平均</th><th>収支</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td></td><td colspan="2">平均</td>${STREETS.map((st) => scoreCell(avg[st])).join('')}${scoreCell(session.summary.total)}<td></td></tr></tfoot>
</table>
</div>`;
}
$('tab-body').addEventListener('click', (e) => {
const r = e.target.closest('[data-hand]');
if (r) go({ s: session.id, h: r.dataset.hand });
});
$('tab-body').addEventListener('keydown', (e) => {
const r = e.target.closest('[data-hand]');
if (r && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
go({ s: session.id, h: r.dataset.hand });
}
});
// 成績
function renderStats() {
const st = session.summary.stats;
const tiles = [
['VPIP', pctText(st.vpip), '自分から参加した割合'],
['PFR', pctText(st.pfr), 'プリフロップでレイズした割合'],
['3bet', pctText(st.threeBet), `レイズに対してレイズし返した割合(機会 ${st.threeBetOpp}回)`],
['Cベット', pctText(st.cbet), `プリフロップのレイザーがフロップでベットした割合(機会 ${st.cbetOpp}回)`],
['WTSD', pctText(st.wtsd), 'フロップを見てショーダウンまで行った割合'],
['W$SD', pctText(st.wsd), 'ショーダウンで勝った割合'],
['AF', st.af == null ? '∞' : st.af, 'フロップ以降の (ベット+レイズ) ÷ コール'],
];
const avg = session.summary.streets;
$('tab-body').innerHTML = `
<div class="rv-tiles">${tiles.map(([k, v, d]) => `<div class="rv-tile"><span>${k}</span><b>${v}</b><small>${d}</small></div>`).join('')}</div>
<div class="rv-section">ストリートごとの平均点</div>
<div class="rv-streets">${STREETS.map((s) => `<div class="rv-street"><span>${STREET_SHORT[s]}</span><b class="score-${scoreClass(avg[s])}">${avg[s] == null ? '—' : avg[s]}</b></div>`).join('')}</div>
<p class="hint">目安: VPIP 20〜28%・PFR 15〜22%・WTSD 25〜32%・AF 2〜3(ノーリミットのリングゲームで一般的な範囲)。</p>`;
}
// 色・ポジション・クセ
function renderRange() {
const s = session.summary;
const colorRows = s.colors
.map(
(c) => `
<tr>
<td><span class="rv-color cat-${esc(c.id)}">${esc(c.name)}</span> <span class="hint">${esc(c.label)}</span></td>
<td class="num">${c.dealt}</td>
<td class="num">${c.dealt ? pctText(Math.round((c.played / c.dealt) * 1000) / 10) : '—'}</td>
<td class="num">${c.judged ? pctText(Math.round((c.good / c.judged) * 1000) / 10) : '—'}</td>
</tr>`
)
.join('');
const VERDICT = { loose: '参加しすぎ', tight: '降りすぎ', passive: '消極的', aggressive: '攻めすぎ', needless: '不要なフォールド' };
const posRows = s.positions
.map((p) => {
const mistakes = Object.entries(p.verdicts)
.sort((a, b) => b[1] - a[1])
.map(([v, n]) => `${VERDICT[v]} ${n}`)
.join('・');
return `
<tr>
<td>${esc(p.position)}</td>
<td class="num">${p.hands}</td>
<td class="num">${pctText(Math.round((p.played / p.hands) * 1000) / 10)}</td>
<td class="num">${p.judged ? pctText(Math.round((p.good / p.judged) * 1000) / 10) : '—'}</td>
<td>${mistakes || '—'}</td>
</tr>`;
})
.join('');
const leaks = s.leaks.length
? `<ul class="rv-leaks">${s.leaks.map((l) => `<li>${esc(l.text)} <b>${l.count}回</b>${l.of ? `<span class="hint">(${l.of}回中)</span>` : ''}</li>`).join('')}</ul>`
: '<div class="hint">目立つクセは見つかりませんでした。</div>';
$('tab-body').innerHTML = `
<div class="rv-section">気をつけたいクセ</div>${leaks}
<div class="rv-section">色ごと</div>
<div class="rv-table-wrap"><table class="rv-table">
<thead><tr><th>色</th><th>配られた</th><th>参加率</th><th>レンジどおり</th></tr></thead>
<tbody>${colorRows}</tbody>
</table></div>
<div class="rv-section">ポジションごと</div>
<div class="rv-table-wrap"><table class="rv-table">
<thead><tr><th>位置</th><th>ハンド</th><th>参加率</th><th>レンジどおり</th><th>主な間違い</th></tr></thead>
<tbody>${posRows}</tbody>
</table></div>`;
}
// 見直したいハンド
function renderWorst() {
const w = session.summary.worst;
if (!w.length) {
$('tab-body').innerHTML = '<div class="empty">見直したいハンドはありません。すべて満点です。</div>';
return;
}
$('tab-body').innerHTML = `
<p class="hint">フロップ以降で失った期待値(BB)が大きい順。同じならプリフロップの判定が悪い順。</p>
<div class="rv-worst">${w
.map(
(h, i) => `
<button type="button" class="rv-worst-item" data-hand="${h.number}">
<span class="rv-rank">${i + 1}</span>
<span><b>#${h.number} ${esc(h.key)}</b> <span class="rv-color cat-${esc(h.color)}"></span> ${esc(h.position)}</span>
<span class="rv-worst-meta">−${h.lossBB}BB ・ ${h.handScore == null ? '—' : h.handScore}点 ・ <span class="${signClass(h.net)}">${signed(h.net)}</span></span>
</button>`
)
.join('')}</div>`;
}
// 収支の推移: 実際の収支と、オールインの期待値で運を除いた収支
function renderGraph() {
const g = session.summary.graph;
if (g.length < 2) {
$('tab-body').innerHTML = '<div class="empty">2ハンド以上でグラフを表示します。</div>';
return;
}
const W = 360;
const H = 220;
const pad = { l: 48, r: 10, t: 12, b: 22 };
const values = g.flatMap((p) => [p.net, p.evNet]).concat([0]);
// 目盛りは 1・2・5 × 10^n 刻み
const raw = (Math.max(...values) - Math.min(...values)) / 4 || 1;
const mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 5, 10].map((m) => m * mag).find((x) => x >= raw);
const min = Math.floor(Math.min(...values) / step) * step;
const max = Math.ceil(Math.max(...values) / step) * step || step;
const money = (v) => (v < 0 ? '−' : '') + chips(Math.abs(v));
const x = (i) => pad.l + (i / (g.length - 1)) * (W - pad.l - pad.r);
const y = (v) => pad.t + ((max - v) / (max - min)) * (H - pad.t - pad.b);
const path = (key) => g.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p[key]).toFixed(1)}`).join('');
let grid = '';
for (let v = min; v <= max + step / 2; v += step) {
const yy = y(v).toFixed(1);
grid += `<line class="gl" x1="${pad.l}" x2="${W - pad.r}" y1="${yy}" y2="${yy}"/><text class="tick" x="${pad.l - 6}" y="${yy}" dy="4" text-anchor="end">${esc(money(Math.round(v)))}</text>`;
}
const last = g[g.length - 1];
$('tab-body').innerHTML = `
<div class="rv-legend">
<span><i class="sw actual"></i>実際の収支 <b class="${signClass(last.net)}">${signed(last.net)}</b></span>
<span><i class="sw ev"></i>運を除いた収支 <b class="${signClass(last.evNet)}">${signed(last.evNet)}</b></span>
</div>
<div class="rv-chart" id="chart">
<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="ハンドごとの累計収支">
${grid}
<line class="zero" x1="${pad.l}" x2="${W - pad.r}" y1="${y(0).toFixed(1)}" y2="${y(0).toFixed(1)}"/>
<text class="tick" x="${pad.l}" y="${H - 6}">#${g[0].number}</text>
<text class="tick" x="${W - pad.r}" y="${H - 6}" text-anchor="end">#${last.number}</text>
<path class="line actual" d="${path('net')}"/>
<path class="line ev" d="${path('evNet')}"/>
<line class="cross" id="cross" y1="${pad.t}" y2="${H - pad.b}" visibility="hidden"/>
<circle class="dot actual" id="dot-net" r="4" visibility="hidden"/>
<circle class="dot ev" id="dot-ev" r="4" visibility="hidden"/>
<rect class="hit" x="${pad.l}" y="${pad.t}" width="${W - pad.l - pad.r}" height="${H - pad.t - pad.b}"/>
</svg>
<div class="rv-tip" id="tip" hidden></div>
</div>
<p class="hint">運を除いた収支は、オールインで決着したハンドを「オールインした時点の勝率どおりにポットを受け取った」とみなした収支です。実際の収支がこれより上なら運が良かった、下なら運が悪かったことになります。</p>`;
const svg = $('chart').querySelector('svg');
const tip = $('tip');
const move = (clientX) => {
const rect = svg.getBoundingClientRect();
const sx = ((clientX - rect.left) / rect.width) * W;
const i = Math.max(0, Math.min(g.length - 1, Math.round(((sx - pad.l) / (W - pad.l - pad.r)) * (g.length - 1))));
const p = g[i];
const cx = x(i).toFixed(1);
$('cross').setAttribute('x1', cx);
$('cross').setAttribute('x2', cx);
$('cross').setAttribute('visibility', 'visible');
for (const [id, key] of [['dot-net', 'net'], ['dot-ev', 'evNet']]) {
$(id).setAttribute('cx', cx);
$(id).setAttribute('cy', y(p[key]).toFixed(1));
$(id).setAttribute('visibility', 'visible');
}
tip.innerHTML = `<b>ハンド #${p.number}</b><div><i class="sw actual"></i>実際 ${signed(p.net)}</div><div><i class="sw ev"></i>運を除く ${signed(p.evNet)}</div>`;
tip.hidden = false;
const left = (x(i) / W) * rect.width;
tip.style.left = `${Math.min(rect.width - tip.offsetWidth, Math.max(0, left - tip.offsetWidth / 2))}px`;
};
const hide = () => {
tip.hidden = true;
for (const id of ['cross', 'dot-net', 'dot-ev']) $(id).setAttribute('visibility', 'hidden');
};
svg.addEventListener('pointermove', (e) => move(e.clientX));
svg.addEventListener('pointerdown', (e) => move(e.clientX));
svg.addEventListener('pointerleave', hide);
}
// ---------- リプレイ ----------
function openReplay(h) {
replay = { hand: h, step: firstHeroStep(h) };
show('replay');
$('replay-head').innerHTML = `
<div class="rv-head">
<div class="rv-cards">${h.hero.cards.map((c) => cardHtml(c)).join('')}</div>
<div>
<div class="rv-hand"><b>#${h.number} ${esc(h.hero.key)}</b> ${colorChip(h.hero)}</div>
<div class="rv-sub">${esc(h.hero.colorLabel)} ・ ${esc(h.hero.position)} ・ ブラインド ${chips(h.sb)}/${chips(h.bb)} ・ 収支 <span class="${signClass(h.hero.net)}">${signed(h.hero.net)}</span></div>
</div>
</div>
<div class="rv-streets">${STREETS.map((st) => `<div class="rv-street"><span>${STREET_SHORT[st]}</span><b class="score-${scoreClass(h.streetScores[st])}">${h.streetScores[st] == null ? '—' : h.streetScores[st]}</b></div>`).join('')}</div>
${allInHtml(h)}`;
$('replay-log').innerHTML = logHtml(h);
renderStep();
}
function allInHtml(h) {
const a = h.hero.allIn;
if (!a) return '';
return `<div class="rv-note">${STREET_LABEL[a.street]}でオールインした時点の勝率 <b>${a.equity}%</b> → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 <span class="${signClass(a.won - a.ev)}">${signed(a.won - a.ev)}</span>)</div>`;
}
// 最初に見せる場面: hero の最初の判断(無ければ最初)
function firstHeroStep(h) {
const i = h.actions.findIndex((a) => a.seat === h.heroSeat && !a.auto);
return i >= 0 ? i : -1;
}
const nameOf = (h, seat) => {
const p = h.players.find((x) => x.seat === seat);
return p ? p.name : `席${seat + 1}`;
};
const posOf = (h, seat) => {
const p = h.players.find((x) => x.seat === seat);
return p ? p.position : '';
};
function actionText(h, a) {
const amount = a.type === 'CALL' || a.type === 'BET' || a.type === 'RAISE' ? ` ${chips(a.amount)}` : '';
return `${TYPE_LABEL[a.type]}${amount}${a.allIn ? '(オールイン)' : ''}${a.auto ? '(自動)' : ''}`;
}
function logHtml(h) {
let html = '';
let street = null;
h.actions.forEach((a, i) => {
if (a.street !== street) {
street = a.street;
html += `<div class="rv-log-street">${STREET_LABEL[street]}</div>`;
}
const mine = a.seat === h.heroSeat;
html += `<button type="button" class="rv-log-item${mine ? ' mine' : ''}" data-step="${i}">${esc(nameOf(h, a.seat))}<span class="hint">${esc(posOf(h, a.seat))}</span> ${esc(actionText(h, a))}</button>`;
});
html += `<button type="button" class="rv-log-item end" data-step="${h.actions.length}">結果</button>`;
return html;
}
// step 番目のアクションの後の状態(-1 = ブラインドを払った直後、actions.length = 結果)
function stateAt(h, step) {
const invested = new Map(h.players.map((p) => [p.seat, 0]));
const post = (seat, amount) => {
const p = h.players.find((x) => x.seat === seat);
if (p) invested.set(seat, invested.get(seat) + Math.min(amount, p.start));
};
post(h.sbSeat, h.sb);
post(h.bbSeat, h.bb);
const folded = new Set();
const last = new Map();
let pot = h.sb + h.bb;
let street = 'PREFLOP';
const upto = Math.min(step, h.actions.length - 1);
for (let i = 0; i <= upto; i++) {
const a = h.actions[i];
if (a.street !== street) {
street = a.street;
last.clear();
}
invested.set(a.seat, invested.get(a.seat) + a.added);
if (a.type === 'FOLD') folded.add(a.seat);
last.set(a.seat, a);
pot = a.pot + a.added;
}
return { invested, folded, last, pot, street };
}
function renderStep() {
const h = replay.hand;
const step = replay.step;
const end = step >= h.actions.length;
const st = stateAt(h, step);
const a = step >= 0 && !end ? h.actions[step] : null;
const shown = end ? h.board.length : BOARD_COUNT[a ? a.street : 'PREFLOP'];
let board = '';
for (let i = 0; i < 5; i++) board += i < shown && h.board[i] ? cardHtml(h.board[i]) : '<span class="pcard slot"></span>';
$('replay-board').innerHTML = board;
$('replay-pot').textContent = `POT ${chips(end ? (h.results || []).reduce((x, r) => x + r.amount, 0) : st.pot)}`;
if (end) {
const lines = (h.results || []).map((r, i) => {
const tag = h.results.length > 1 ? (i === 0 ? 'メイン ' : 'サイド ') : '';
return `<div>${tag}<b>${r.winners.map((w) => esc(nameOf(h, w))).join('・')}</b> +${chips(r.amount)}${r.handName ? ` <span class="hint">${esc(r.handName)}</span>` : ''}</div>`;
});
$('replay-step').innerHTML = `<div class="rv-step-title">結果</div>${lines.join('')}`;
} else if (a) {
$('replay-step').innerHTML = `<div class="rv-step-title">${STREET_LABEL[a.street]} ${step + 1}/${h.actions.length}</div>
<div><b>${esc(nameOf(h, a.seat))}</b><span class="hint">(${esc(posOf(h, a.seat))})</span> ${esc(actionText(h, a))}</div>
<div class="hint">ポット ${chips(a.pot)}${a.toCall ? ` ・ コール額 ${chips(a.toCall)}` : ''}</div>`;
} else {
$('replay-step').innerHTML = `<div class="rv-step-title">ハンド開始</div><div class="hint">SB ${esc(nameOf(h, h.sbSeat))} ${chips(h.sb)} ・ BB ${esc(nameOf(h, h.bbSeat))} ${chips(h.bb)}</div>`;
}
$('replay-analysis').innerHTML = a && a.seat === h.heroSeat && !a.auto ? analysisHtml(h, step) : '';
const order = h.players.slice();
$('replay-players').innerHTML = order
.map((p) => {
const folded = st.folded.has(p.seat);
const acting = a && a.seat === p.seat;
const l = st.last.get(p.seat);
const cls = ['rv-player', folded ? 'folded' : '', acting ? 'acting' : '', p.seat === h.heroSeat ? 'hero' : ''].join(' ');
const won = end && p.won > 0 ? `<span class="plus">+${chips(p.won)}</span>` : '';
return `
<div class="${cls}">
<div class="rv-cards">${p.cards.map((c) => cardHtml(c, folded ? 'dim' : '')).join('')}</div>
<div class="rv-player-info">
<div><b>${esc(p.name)}</b>${p.bot ? ' <span class="hint">CPU</span>' : ''} <span class="rv-pos">${esc(p.position)}</span></div>
<div class="hint">残り ${chips(end ? p.end : p.start - st.invested.get(p.seat))} ・ 投入 ${chips(st.invested.get(p.seat))}</div>
</div>
<div class="rv-player-act">${won || (l && !end ? esc(TYPE_LABEL[l.type]) : folded ? 'フォールド' : '')}</div>
</div>`;
})
.join('');
for (const b of $('replay-log').children) b.classList.toggle('current', Number(b.dataset.step) === step);
const atStart = step <= -1;
const atEnd = end;
$('view-replay').querySelector('[data-step="first"]').disabled = atStart;
$('view-replay').querySelector('[data-step="prev"]').disabled = atStart;
$('view-replay').querySelector('[data-step="next"]').disabled = atEnd;
$('view-replay').querySelector('[data-step="last"]').disabled = atEnd;
}
// hero の判断の詳しい判定
function analysisHtml(h, index) {
const j = h.preflop.find((x) => x.index === index);
if (j) {
return `<div class="rv-analysis">
<div class="rv-item"><span class="rv-mark ${MARK_CLASS[j.mark]}">${j.mark}</span><div>
<div><b>ハンドレンジ</b> ${colorChip(h.hero)} <span class="rv-score">${j.score}点</span></div>
<div>${esc(j.situation)}</div>
<div class="rv-sub">推奨 <b>${KIND_LABEL[j.recommended]}</b> / あなた <b>${KIND_LABEL[j.actual]}</b> — ${esc(j.label)}</div>
${j.equity != null ? `<div class="rv-sub">参加者の推定レンジに対する勝率 ${j.equity}%(コールに必要 ${j.need}%)</div>` : ''}
</div></div></div>`;
}
const d = h.decisions.find((x) => x.index === index);
if (!d) return '';
const best = Math.max(...d.options.map((o) => o.evBB));
const rows = d.options
.map((o) => {
const isActual = o.kind === d.actual.kind && o.to === d.actual.to;
const isBest = o.evBB === best;
return `<tr class="${isActual ? 'actual' : ''}"><td>${optionLabel(o)}${isActual ? ' <span class="hint">← あなた</span>' : ''}</td><td class="num ${isBest ? 'best' : ''}">${o.evBB > 0 ? '+' : ''}${o.evBB}BB</td></tr>`;
})
.join('');
return `<div class="rv-analysis">
<div class="rv-item"><span class="rv-mark ${MARK_CLASS[d.mark]}">${d.mark}</span><div>
<div><b>${STREET_LABEL[d.street]}の判断</b> <span class="rv-score">${d.score}点</span>${d.lossBB > 0 ? ` <span class="hint">(${d.lossBB}BB 損)</span>` : ''}</div>
<div class="rv-sub">相手の推定レンジに対する勝率 <b>${d.equity}%</b>${d.need != null ? ` / コールに必要な勝率 <b>${d.need}%</b> → ${d.equity >= d.need ? 'コールしてよい' : 'コールは割に合わない'}` : ''}</div>
</div></div>
<table class="rv-table rv-ev"><thead><tr><th>選択肢</th><th>期待値の目安</th></tr></thead><tbody>${rows}</tbody></table>
</div>`;
}
function stepTo(target) {
if (!replay) return;
replay.step = Math.max(-1, Math.min(replay.hand.actions.length, target));
renderStep();
}
$('view-replay').addEventListener('click', (e) => {
const b = e.target.closest('[data-step]');
if (!b || !replay) return;
const v = b.dataset.step;
const n = replay.hand.actions.length;
if (v === 'first') stepTo(-1);
else if (v === 'prev') stepTo(replay.step - 1);
else if (v === 'next') stepTo(replay.step + 1);
else if (v === 'last') stepTo(n);
else stepTo(Number(v));
});
document.addEventListener('keydown', (e) => {
if ($('view-replay').hidden || e.target.closest('input, textarea')) return;
if (e.key === 'ArrowLeft') stepTo(replay.step - 1);
if (e.key === 'ArrowRight') stepTo(replay.step + 1);
});
route();
})();