From 3f2515a9faa7ecf6f8da4b7acb3a8b1a9c40809a Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Mon, 19 Oct 2026 22:11:16 +0900 Subject: [PATCH] Add admin-only CPU tables with per-hand reviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 21 +- admin/range-data.js | 75 ++++++ admin/range.html | 1 + admin/range.js | 34 +-- admin/review.css | 145 ++++++++++ admin/review.html | 64 +++++ admin/review.js | 559 +++++++++++++++++++++++++++++++++++++++ public/assets/create.js | 8 +- public/assets/lobby.js | 9 +- public/assets/style.css | 35 +++ public/assets/table.js | 107 +++++++- public/create.html | 2 + public/lobby.html | 1 + public/table.html | 18 ++ server/auth.js | 7 +- server/config.js | 2 + server/hand.js | 41 ++- server/index.js | 51 +++- server/review/analyze.js | 275 +++++++++++++++++++ server/review/equity.js | 141 ++++++++++ server/review/preflop.js | 116 ++++++++ server/review/store.js | 128 +++++++++ server/review/summary.js | 132 +++++++++ server/tables.js | 94 ++++++- test/review.test.js | 256 ++++++++++++++++++ 25 files changed, 2262 insertions(+), 60 deletions(-) create mode 100644 admin/range-data.js create mode 100644 admin/review.css create mode 100644 admin/review.html create mode 100644 admin/review.js create mode 100644 server/review/analyze.js create mode 100644 server/review/equity.js create mode 100644 server/review/preflop.js create mode 100644 server/review/store.js create mode 100644 server/review/summary.js create mode 100644 test/review.test.js diff --git a/README.md b/README.md index 37a3ac4..cfaa0c5 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ | キャッシュオンリー | カードは実物で配り、サイトはチップの代わりに使う(ベット・ポット・スタックの管理)。ショーダウンの勝者はホストが選ぶ | | 友人戦 | 実物のカードがなくても遊べる。サイトがカードを配り、勝敗とポットの分配まで自動で判定する | | CPU戦 | 友人戦に CPU プレイヤー(1〜8人)が加わる。空いている席には友達も参加できる | +| レビュー付きCPU戦 | 管理者だけが作れる。作成者と CPU だけで遊び(友達は入れない・ロビーにも出ない)、ハンドごとにレビューが出る(下の「管理者」を参照) | - ポーカーのルール(ブラインド・手番・最低レイズ額・再レイズ権・返金・サイドポット・ショーダウンの判定と分配)と役の判定は、公開ライブラリ [@pokertools/engine](https://www.npmjs.com/package/@pokertools/engine) / [@pokertools/evaluator](https://www.npmjs.com/package/@pokertools/evaluator)(MIT、バージョン固定)に任せている。 - ノーリミット・テキサスホールデムのリングゲーム(最大9人)。ホストが START を押すと、以降は2人以上いる限り自動でハンドが続く。 @@ -61,9 +62,22 @@ http://localhost:4671 を開く。 | `PUBLIC_ORIGIN` | なし | 本番の公開URL `https://poker.kanawo.net`。設定するとこのドメイン以外(IP直打ちや別ドメイン)へのアクセスと、他オリジンからの操作を拒否する。ローカル開発では設定しない | | `SESSION_SECRET` | 自動生成 | Cookie 署名用。未指定なら `data/session-secret` に生成・保存 | -## 管理者(ハンドレンジ学習) +## 管理者(ハンドレンジ学習・レビュー付きCPU戦) -`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「♠ ハンドレンジ学習」が表示される(`/range`:レンジ表と色・分類クイズ)。管理者は通常のプレイもそのままできる。 +`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「♠ ハンドレンジ学習」(`/range`:レンジ表と色・分類クイズ)と「📝 CPU戦のレビュー」(`/review`)が表示され、テーブル作成で「レビュー付きCPU戦」を選べる。管理者は通常のプレイもそのままできる。 + +### レビュー付きCPU戦 + +ハンドが終わるたびにレビューが開き、「次のハンドへ」を押すまで次のハンドは始まらない。 + +- **ハンドレンジ**: 配られた手が登録してあるハンドレンジ(`admin/range-data.js`。レンジ学習ページと共用)の何色か、そのとおりに行動できたかを判定する。 + - 自分より前に誰もコール・レイズしていない: 後ろの人数(自分を含めず、まだ行動していない人)の色以上ならレイズ、それより弱ければフォールド。8人以上は赤(紺も可)、6〜7人は黄、4〜5人は緑、3人は青、2人は白、1人(SB)は紫枠。紫枠は白の一つ下の段階として扱う。 + - 前にコール・レイズした人がいる: その人のレンジを、その人の後ろの人数に対応する色と推定し、自分の手がその一つ上ならコール、二つ上ならレイズ、それより弱ければフォールド。2人目以降に参加した人は、コールなら直前までで一番強いレンジの一つ上、レイズなら二つ上と推定する。紺はどんな相手にもコールまではできる。 + - チェックできるときのフォールド・コールの推奨はチェックになる。判定は ◎(100点)・△(消極的 50点/攻めすぎ 40点)・✕(参加しすぎ・降りすぎ 0点)。 +- **フロップ以降の点数**: 相手のレンジ(上の色の推定から、ベット・レイズ・コールのたびに、いまの場札で強い手に絞る)に対する勝率をモンテカルロ法で求め、選択肢(フォールド・チェック・コール・標準サイズと実際のサイズのベット/レイズ)ごとの期待値を見積もる。一番良い選択肢との差(失った期待値)がポットの3%までは満点で、それを超えると1%ごとに2.5点減る(◎90〜 ○70〜 △40〜 ✕)。ソルバー(GTO)ではなく目安。 +- **ラウンドごとの点数表**: ハンド × PF/Flop/Turn/River の点数と平均。行を押すとリプレイ(1手ずつ再生し、自分の判断の場面で判定・勝率・選択肢ごとの期待値を表示)。 +- **その他**: コールに必要な勝率と推定勝率の比較、オールイン時の勝率と期待値(運の影響)、成績(VPIP・PFR・3bet・Cベット・WTSD・W$SD・AF)、色ごと・ポジションごとの参加率とレンジどおりの割合、気をつけたいクセ、見直したいハンド(失った期待値の大きい順に5つ)、実際の収支と運を除いた収支の推移グラフ。 +- レビューは `data/reviews/`(git 管理外)に対局ごとに保存し、テーブルを閉じた後も `/review` で見返せる(新しい50対局まで。1対局1000ハンドまで)。 - 初めて開くときは鍵入力画面で `ADMIN_KEY` を入れる。すでに参加キーで入室済みのブラウザは `https://poker.kanawo.net/key?admin` を開いて管理者キーを入れる。 - 管理者以外には `/range` とその画像・スクリプトは 404 を返す(ページがあること自体を見せない)。ファイルは `public/` の外の `admin/` に置いている。 @@ -92,12 +106,13 @@ http://localhost:4671 を開く。 | `server/hand.js` | 1ハンドの進行。ルールは @pokertools/engine に任せ、テーブルとの橋渡し(席番号・スタック・退席・離席)、オールイン後に場札を順にめくる表示、キャッシュオンリーでホストが選んだ勝者への分配を行う | | `server/cards.js` | 役の名前と最強の5枚(@pokertools/evaluator) | | `server/ai.js` | CPU の思考の割り当て(種類と性格)と呼び出し | +| `server/review/*.js` | レビュー付きCPU戦のレビュー(`preflop` ハンドレンジの判定 / `equity` 勝率 / `analyze` 1ハンドの点数 / `summary` 対局のまとめ / `store` 保存) | | `server/bots/*.js` | CPU の思考の種類ごとの実装(`montecarlo` / `calculator` / `range` / `madehand`)と共通の道具(`common.js`) | | `test/` | チップ処理・役の判定・CPU のテスト(`npm test`) | | `server/config.js` | `.env` の読み込みと各種上限値 | | `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) | | `public/*.html`, `public/assets/*.js` | 各画面 | -| `admin/` | 管理者専用ページ(ハンドレンジ学習・カード画像) | +| `admin/` | 管理者専用ページ(ハンドレンジ学習・CPU戦のレビュー・登録してあるハンドレンジ・カード画像) | - テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。 - 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。 diff --git a/admin/range-data.js b/admin/range-data.js new file mode 100644 index 0000000..59b5227 --- /dev/null +++ b/admin/range-data.js @@ -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 }; +}); diff --git a/admin/range.html b/admin/range.html index aca7443..0db752c 100644 --- a/admin/range.html +++ b/admin/range.html @@ -70,6 +70,7 @@ + diff --git a/admin/range.js b/admin/range.js index 194cb66..f459bd6 100644 --- a/admin/range.js +++ b/admin/range.js @@ -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'); diff --git a/admin/review.css b/admin/review.css new file mode 100644 index 0000000..a5e448a --- /dev/null +++ b/admin/review.css @@ -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; } diff --git a/admin/review.html b/admin/review.html new file mode 100644 index 0000000..d2ccbf9 --- /dev/null +++ b/admin/review.html @@ -0,0 +1,64 @@ + + + + + + + + CPU戦のレビュー - Hold'em Night + + + + + + +
+
+ ← +

CPU戦のレビュー

+
+ + +
+

レビュー付きCPU戦の対局です(新しい順・最大50件)。

+
+
+ + + + + + +
+ + + + + diff --git a/admin/review.js b/admin/review.js new file mode 100644 index 0000000..bf33cdf --- /dev/null +++ b/admin/review.js @@ -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) => `${esc(h.colorName)}`; + + function cardHtml(c, extra = '') { + if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return ``; + const rank = c[0] === 'T' ? '10' : c[0]; + return `${rank}${SUIT_SYMBOL[c[1]]}`; + } + + // ---------- 画面の切り替え(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 = `
${esc(err.message)}
`; + return; + } + if (!list.length) { + $('sessions').innerHTML = '
まだ対局がありません。
ロビーの「テーブルを作成」で「レビュー付きCPU戦」を選んでください。
'; + return; + } + $('sessions').innerHTML = list + .map( + (s) => ` + ` + ) + .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 = ` +
ハンド数${s.stats.hands}
+
平均点${s.total == null ? '—' : s.total}
+
収支${signed(net)}
`; + 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 `${v == null ? '—' : v}`; + } + + // 点数表: ハンドごと・ストリートごとの点数 + function renderScores() { + const hands = session.hands; + if (!hands.length) { + $('tab-body').innerHTML = '
まだハンドがありません。
'; + return; + } + const rows = hands + .slice() + .reverse() + .map( + (h) => ` + + ${h.number} + ${esc(h.hero.key)} ${colorChip(h.hero)} + ${esc(h.hero.position)} + ${STREETS.map((st) => scoreCell(h.streetScores[st])).join('')} + ${scoreCell(h.handScore)} + ${signed(h.hero.net)} + ` + ) + .join(''); + const avg = session.summary.streets; + $('tab-body').innerHTML = ` +

行をタップするとリプレイと詳しい判定を表示します。PF はハンドレンジどおりか、Flop 以降は推定レンジに対する期待値の目安(100点 = 最善)。

+
+ + ${STREETS.map((st) => ``).join('')} + ${rows} + ${STREETS.map((st) => scoreCell(avg[st])).join('')}${scoreCell(session.summary.total)} +
#ハンド位置${STREET_SHORT[st]}平均収支
平均
+
`; + } + + $('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 = ` +
${tiles.map(([k, v, d]) => `
${k}${v}${d}
`).join('')}
+
ストリートごとの平均点
+
${STREETS.map((s) => `
${STREET_SHORT[s]}${avg[s] == null ? '—' : avg[s]}
`).join('')}
+

目安: VPIP 20〜28%・PFR 15〜22%・WTSD 25〜32%・AF 2〜3(ノーリミットのリングゲームで一般的な範囲)。

`; + } + + // 色・ポジション・クセ + function renderRange() { + const s = session.summary; + const colorRows = s.colors + .map( + (c) => ` + + ${esc(c.name)} ${esc(c.label)} + ${c.dealt} + ${c.dealt ? pctText(Math.round((c.played / c.dealt) * 1000) / 10) : '—'} + ${c.judged ? pctText(Math.round((c.good / c.judged) * 1000) / 10) : '—'} + ` + ) + .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 ` + + ${esc(p.position)} + ${p.hands} + ${pctText(Math.round((p.played / p.hands) * 1000) / 10)} + ${p.judged ? pctText(Math.round((p.good / p.judged) * 1000) / 10) : '—'} + ${mistakes || '—'} + `; + }) + .join(''); + const leaks = s.leaks.length + ? `` + : '
目立つクセは見つかりませんでした。
'; + $('tab-body').innerHTML = ` +
気をつけたいクセ
${leaks} +
色ごと
+
+ + ${colorRows} +
色配られた参加率レンジどおり
+
ポジションごと
+
+ + ${posRows} +
位置ハンド参加率レンジどおり主な間違い
`; + } + + // 見直したいハンド + function renderWorst() { + const w = session.summary.worst; + if (!w.length) { + $('tab-body').innerHTML = '
見直したいハンドはありません。すべて満点です。
'; + return; + } + $('tab-body').innerHTML = ` +

フロップ以降で失った期待値(BB)が大きい順。同じならプリフロップの判定が悪い順。

+
${w + .map( + (h, i) => ` + ` + ) + .join('')}
`; + } + + // 収支の推移: 実際の収支と、オールインの期待値で運を除いた収支 + function renderGraph() { + const g = session.summary.graph; + if (g.length < 2) { + $('tab-body').innerHTML = '
2ハンド以上でグラフを表示します。
'; + 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 += `${esc(money(Math.round(v)))}`; + } + const last = g[g.length - 1]; + $('tab-body').innerHTML = ` +
+ 実際の収支 ${signed(last.net)} + 運を除いた収支 ${signed(last.evNet)} +
+
+ + ${grid} + + #${g[0].number} + #${last.number} + + + + + + + + +
+

運を除いた収支は、オールインで決着したハンドを「オールインした時点の勝率どおりにポットを受け取った」とみなした収支です。実際の収支がこれより上なら運が良かった、下なら運が悪かったことになります。

`; + + 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 = `ハンド #${p.number}
実際 ${signed(p.net)}
運を除く ${signed(p.evNet)}
`; + 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 = ` +
+
${h.hero.cards.map((c) => cardHtml(c)).join('')}
+
+
#${h.number} ${esc(h.hero.key)} ${colorChip(h.hero)}
+
${esc(h.hero.colorLabel)} ・ ${esc(h.hero.position)} ・ ブラインド ${chips(h.sb)}/${chips(h.bb)} ・ 収支 ${signed(h.hero.net)}
+
+
+
${STREETS.map((st) => `
${STREET_SHORT[st]}${h.streetScores[st] == null ? '—' : h.streetScores[st]}
`).join('')}
+ ${allInHtml(h)}`; + $('replay-log').innerHTML = logHtml(h); + renderStep(); + } + + function allInHtml(h) { + const a = h.hero.allIn; + if (!a) return ''; + return `
${STREET_LABEL[a.street]}でオールインした時点の勝率 ${a.equity}% → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 ${signed(a.won - a.ev)})
`; + } + + // 最初に見せる場面: 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 += `
${STREET_LABEL[street]}
`; + } + const mine = a.seat === h.heroSeat; + html += ``; + }); + html += ``; + 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]) : ''; + $('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 `
${tag}${r.winners.map((w) => esc(nameOf(h, w))).join('・')} +${chips(r.amount)}${r.handName ? ` ${esc(r.handName)}` : ''}
`; + }); + $('replay-step').innerHTML = `
結果
${lines.join('')}`; + } else if (a) { + $('replay-step').innerHTML = `
${STREET_LABEL[a.street]} ${step + 1}/${h.actions.length}
+
${esc(nameOf(h, a.seat))}(${esc(posOf(h, a.seat))}) ${esc(actionText(h, a))}
+
ポット ${chips(a.pot)}${a.toCall ? ` ・ コール額 ${chips(a.toCall)}` : ''}
`; + } else { + $('replay-step').innerHTML = `
ハンド開始
SB ${esc(nameOf(h, h.sbSeat))} ${chips(h.sb)} ・ BB ${esc(nameOf(h, h.bbSeat))} ${chips(h.bb)}
`; + } + + $('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 ? `+${chips(p.won)}` : ''; + return ` +
+
${p.cards.map((c) => cardHtml(c, folded ? 'dim' : '')).join('')}
+
+
${esc(p.name)}${p.bot ? ' CPU' : ''} ${esc(p.position)}
+
残り ${chips(end ? p.end : p.start - st.invested.get(p.seat))} ・ 投入 ${chips(st.invested.get(p.seat))}
+
+
${won || (l && !end ? esc(TYPE_LABEL[l.type]) : folded ? 'フォールド' : '')}
+
`; + }) + .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 `
+
${j.mark}
+
ハンドレンジ ${colorChip(h.hero)} ${j.score}点
+
${esc(j.situation)}
+
推奨 ${KIND_LABEL[j.recommended]} / あなた ${KIND_LABEL[j.actual]} — ${esc(j.label)}
+ ${j.equity != null ? `
参加者の推定レンジに対する勝率 ${j.equity}%(コールに必要 ${j.need}%)
` : ''} +
`; + } + 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 `${optionLabel(o)}${isActual ? ' ← あなた' : ''}${o.evBB > 0 ? '+' : ''}${o.evBB}BB`; + }) + .join(''); + return `
+
${d.mark}
+
${STREET_LABEL[d.street]}の判断 ${d.score}点${d.lossBB > 0 ? ` (${d.lossBB}BB 損)` : ''}
+
相手の推定レンジに対する勝率 ${d.equity}%${d.need != null ? ` / コールに必要な勝率 ${d.need}% → ${d.equity >= d.need ? 'コールしてよい' : 'コールは割に合わない'}` : ''}
+
+ ${rows}
選択肢期待値の目安
+
`; + } + + 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(); +})(); diff --git a/public/assets/create.js b/public/assets/create.js index a9c6632..36ca963 100644 --- a/public/assets/create.js +++ b/public/assets/create.js @@ -72,13 +72,14 @@ chips: '実物のカードで遊び、サイトはチップだけを管理。ショーダウンの勝者はホストが選ぶ', friends: '各自のスマホに手札が配られ、勝敗とポットの分配は自動', cpu: 'CPU と対戦(友達も空いている席に参加できる)。カードの配布・勝敗の判定は自動', + review: 'あなたと CPU だけで対戦(友達は入れない)。ハンドごとにハンドレンジの色・行動の点数を表示し、対局全体の成績も振り返れる', }; const radioValue = (name) => (form.querySelector(`input[name="${name}"]:checked`) || {}).value; function updateMode() { const mode = radioValue('mode'); modeHint.textContent = MODE_HINTS[mode] || ''; - cpuOptions.hidden = mode !== 'cpu'; + cpuOptions.hidden = mode !== 'cpu' && mode !== 'review'; } function refresh() { @@ -156,6 +157,11 @@ } }); + // レビュー付きCPU戦は管理者だけが作れる(サーバでも確認する) + api('GET', '/api/me') + .then((me) => { $('review-mode').hidden = !me.admin; }) + .catch(() => {}); + applyDefaultBlinds(); refresh(); suggestName(); diff --git a/public/assets/lobby.js b/public/assets/lobby.js index 3eda85f..1b19938 100644 --- a/public/assets/lobby.js +++ b/public/assets/lobby.js @@ -22,9 +22,12 @@ return false; } - // 管理者だけにハンドレンジ学習へのリンクを出す(ページ自体もサーバ側で管理者以外は 404) + // 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクを出す(ページ自体もサーバ側で管理者以外は 404) App.api('GET', '/api/me') - .then((me) => { document.getElementById('range-link').hidden = !me.admin; }) + .then((me) => { + document.getElementById('range-link').hidden = !me.admin; + document.getElementById('review-link').hidden = !me.admin; + }) .catch(() => {}); document.getElementById('create').addEventListener('click', () => { @@ -36,7 +39,7 @@ if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join); }); - const MODE_LABEL = { chips: '💰 キャッシュオンリー', friends: '🃏 友人戦', cpu: '🤖 CPU戦' }; + const MODE_LABEL = { chips: '💰 キャッシュオンリー', friends: '🃏 友人戦', cpu: '🤖 CPU戦', review: '📝 レビュー付きCPU戦' }; function phaseBadge(t) { if (t.paused) return 'PAUSED'; diff --git a/public/assets/style.css b/public/assets/style.css index e5712fa..93a723e 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -258,6 +258,7 @@ input.invalid { border-color: var(--red); } /* 選択肢を横に並べたラジオボタン(テーブルの種類・CPU の人数) */ .segmented { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 6px; } .segmented label { margin: 0; display: block; color: var(--text); } +.segmented label[hidden] { display: none; } .segmented input { position: absolute; opacity: 0; width: 0; height: 0; } .segmented span { display: flex; @@ -790,3 +791,37 @@ body.table-page { .slash { flex: none !important; color: var(--muted); font-weight: 800; } label.switch { margin: 0; display: inline-block; } .link-gold { color: var(--gold); } + +/* ---------- レビュー付きCPU戦(ハンドごとのレビュー) ---------- */ +.review-modal { max-width: 440px; max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; } +.review-buttons { display: grid; grid-template-columns: 1fr 2fr; gap: 8px; margin-top: 16px; } +.review-buttons:has(> [hidden]) { grid-template-columns: 1fr; } +.review-link { display: block; margin-top: 12px; font-size: 13px; text-align: center; } +.rv-head { display: flex; align-items: center; gap: 12px; margin-top: 10px; } +.rv-cards { display: flex; gap: 3px; } +.rv-hand { font-size: 20px; } +.rv-sub { color: var(--muted); font-size: 12px; margin-top: 2px; } +.rv-sub b { color: var(--text); } +.rv-section { font-size: 12px; font-weight: 800; color: var(--gold); margin: 14px 0 6px; } +.rv-item { display: flex; gap: 8px; align-items: flex-start; font-size: 14px; padding: 6px 0; border-top: 1px solid var(--line); } +.rv-mark { flex: none; width: 24px; text-align: center; font-weight: 900; font-size: 16px; } +.rv-mark.great { color: var(--green); } +.rv-mark.good { color: #8fd18f; } +.rv-mark.warn { color: var(--orange); } +.rv-mark.bad { color: var(--red); } +.rv-score { color: var(--gold); font-weight: 800; font-size: 13px; } +.rv-streets { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 12px; } +.rv-street { background: var(--panel); border-radius: 10px; padding: 6px 4px; text-align: center; } +.rv-street span { display: block; font-size: 11px; color: var(--muted); } +.rv-street b { font-size: 18px; } +.rv-foot { margin-top: 14px; font-size: 13px; color: var(--muted); } +/* 登録してあるハンドレンジの色(レンジ表のデータとしての色なのでサイトの配色とは別) */ +.rv-color { display: inline-block; font-size: 13px; font-weight: 800; padding: 2px 8px; border-radius: 6px; vertical-align: middle; background: var(--c); color: var(--fg); } +.rv-color.cat-purple { box-shadow: inset 0 0 0 2px #9b59d0; } +.rv-color.cat-navy { --c: #20365d; --fg: #fff; box-shadow: inset 0 0 0 1px #4d6a9c; } +.rv-color.cat-red { --c: #9e342e; --fg: #fff; } +.rv-color.cat-yellow { --c: #ddb356; --fg: #14191f; } +.rv-color.cat-green { --c: #4e8051; --fg: #101910; } +.rv-color.cat-blue { --c: #6aa5c9; --fg: #14191f; } +.rv-color.cat-white { --c: #f7f7f7; --fg: #222; } +.rv-color.cat-gray, .rv-color.cat-purple { --c: #929292; --fg: #222; } diff --git a/public/assets/table.js b/public/assets/table.js index 55f29a6..7d175ea 100644 --- a/public/assets/table.js +++ b/public/assets/table.js @@ -27,6 +27,8 @@ // ショーダウンの勝者選択(ポットごとの席番号の Set) let awardKey = ''; let awardSel = []; + let seenReview = 0; // 自動で開いたレビューのハンド番号(レビュー付きCPU戦) + let reviewKey = ''; // ---------- 名前 ---------- function ensureName() { @@ -199,6 +201,7 @@ renderSeats(s); if (me) renderActionBar(s, me); + renderReview(s); renderTimer(); // 自分の番になったら知らせる(タップ前は振動できない) @@ -335,6 +338,7 @@ let status = ''; if (!s.started) status = s.isHost ? '2人以上揃ったら START を押してください' : 'ホストの開始を待っています'; else if (s.paused) status = '一時停止中'; + else if (s.review && s.review.waiting) status = 'レビューを確認したら「次のハンドへ」を押してください'; else if (m.sittingOut) status = '離席中です。復帰すると次のハンドから参加します'; else if (m.canRebuy) status = 'チップがなくなりました'; else if (o) status = o.canCheck ? 'あなたの番です' : `あなたの番です(コール ${chips(o.toCall)})`; @@ -356,13 +360,15 @@ if (awarding) renderAwardPanel(h.pots); // ボタン - const alt = (m.sittingOut || m.canRebuy) && !awarding; + const reviewWaiting = Boolean(s.review && s.review.waiting); + const alt = (m.sittingOut || m.canRebuy) && !awarding && !reviewWaiting; if (!o) raising = false; + $('review-actions').hidden = !reviewWaiting; $('alt-actions').hidden = !alt; $('sit-in').hidden = !m.sittingOut; $('rebuy').hidden = !m.canRebuy; $('rebuy').textContent = `リバイ(${chips(s.startingStack)})`; - $('actions').hidden = alt || raising || awarding; + $('actions').hidden = alt || raising || awarding || reviewWaiting; $('raise-actions').hidden = alt || !raising; $('raise-panel').hidden = alt || !raising; $('status-line').hidden = raising; @@ -387,6 +393,101 @@ if (raising) renderRaisePanel(o); } + // ---------- レビュー(レビュー付きCPU戦の作成者のみ) ---------- + 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 KIND_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' }; + const MARK_CLASS = { '◎': 'great', '○': 'good', '△': 'warn', '✕': 'bad' }; + const optionLabel = (o) => (o.kind === 'raise' ? `ベット/レイズ ${chips(o.to)}` : KIND_LABEL[o.kind]); + const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '±') + chips(Math.abs(n)); + const markHtml = (m) => `${m}`; + + function reviewHtml(r) { + const l = r.last; + const h = l.hero; + const pre = l.preflop.length + ? l.preflop + .map( + (j) => ` +
${markHtml(j.mark)}
+
${esc(j.situation)}
+
推奨 ${KIND_LABEL[j.recommended]} / あなた ${KIND_LABEL[j.actual]} — ${esc(j.label)}
+ ${j.equity != null ? `
推定勝率 ${j.equity}%(コールに必要 ${j.need}%)
` : ''} +
` + ) + .join('') + : '
プリフロップで判断する場面はありませんでした
'; + const post = l.decisions + .map( + (d) => ` +
${markHtml(d.mark)}
+
${STREET_LABEL[d.street]} ${optionLabel(d.actual)} ${d.score}点
+
推定勝率 ${d.equity}%${d.need != null ? `(コールに必要 ${d.need}%)` : ''}${ + d.lossBB > 0 ? ` ・ 最善は ${optionLabel(d.best)}(${d.lossBB}BB 損)` : ' ・ 最善の選択' + }
+
` + ) + .join(''); + const scores = STREETS.map((st) => { + const v = l.streetScores[st]; + return `
${STREET_SHORT[st]}${v == null ? '—' : v}
`; + }).join(''); + const a = h.allIn; + const allIn = a + ? `
オールイン
+
${STREET_LABEL[a.street]}でオールインした時点の勝率 ${a.equity}% → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 ${signed(a.won - a.ev)})
` + : ''; + return ` +
+
${h.cards.map((c) => cardHtml(c)).join('')}
+
+
${esc(h.key)} ${esc(h.colorName)}
+
${esc(h.colorLabel)} ・ ${esc(h.position)} ・ ハンド #${l.number}
+
+
+
${scores}
+
プリフロップ(ハンドレンジ)
${pre} + ${post ? `
フロップ以降(期待値の目安)
${post}` : ''} + ${allIn} +
このハンドの収支 ${signed(h.net)} ・ 平均 ${r.average == null ? '—' : r.average}点(${r.hands}ハンド)
`; + } + + function renderReview(s) { + const r = s.review; + $('review-open').hidden = !(r && r.last); + if (!r || !r.last) { + $('review-modal').hidden = true; + return; + } + $('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}` : '/review'; + $('review-modal-next').hidden = !r.waiting; + const key = JSON.stringify(r); + if (key !== reviewKey) { + reviewKey = key; + $('review-body').innerHTML = reviewHtml(r); + } + // 新しいハンドのレビューが届いたら自動で開く + if (r.last.number !== seenReview) { + seenReview = r.last.number; + if (r.waiting) $('review-modal').hidden = false; + } + } + + const openReview = () => { $('review-modal').hidden = false; }; + $('review-open').addEventListener('click', openReview); + $('review-show').addEventListener('click', openReview); + $('review-close').addEventListener('click', () => { $('review-modal').hidden = true; }); + $('review-modal').addEventListener('click', (e) => { + if (e.target === $('review-modal')) $('review-modal').hidden = true; + }); + const nextHand = () => { + $('review-modal').hidden = true; + emit('table:next'); + }; + $('review-next').addEventListener('click', nextHand); + $('review-modal-next').addEventListener('click', nextHand); + // ---------- 勝者選択 ---------- function renderAwardPanel(pots) { const key = `${state.handNumber}:${JSON.stringify(pots)}`; @@ -605,7 +706,7 @@ if (e.touches.length !== 1) return; const t = e.target; if (t.closest && t.closest('input[type="range"]')) return; - const bar = t.closest && t.closest('.action-bar'); + const bar = t.closest && (t.closest('.action-bar') || t.closest('.review-modal')); if (bar && bar.scrollHeight > bar.clientHeight) return; if (e.cancelable) e.preventDefault(); }, diff --git a/public/create.html b/public/create.html index dba0904..e612f6d 100644 --- a/public/create.html +++ b/public/create.html @@ -28,6 +28,8 @@ + +
diff --git a/public/lobby.html b/public/lobby.html index 366142f..c0aa626 100644 --- a/public/lobby.html +++ b/public/lobby.html @@ -19,6 +19,7 @@ +
開催中のテーブル diff --git a/public/table.html b/public/table.html index e9fec5b..cf61d34 100644 --- a/public/table.html +++ b/public/table.html @@ -21,6 +21,8 @@ + +
@@ -92,6 +94,10 @@ + + +