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 @@ + + +