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