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 };
});