Compare commits

...
2 Commits
Author SHA1 Message Date
agomizi1cmandClaude Opus 5.5 0318ee2b5e Let admins delete tables from the lobby
Admins see a "削除" button on every table in the lobby. It calls the new
admin-only DELETE /api/tables/:id (404 for non-admins, same-origin only),
which closes the table the same way as when every human leaves. Players
and spectators still at the table are told "管理者がテーブルを削除しました。"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:03:24 +09:00
agomizi1cmandClaude Opus 5.5 3f2515a9fa Add admin-only CPU tables with per-hand reviews
A new table type, "review" (レビュー付きCPU戦), can only be created by
admins. Only the creator and CPUs sit there: friends cannot join, and the
table is hidden from non-admin lobbies. After every hand a review opens,
and the next hand waits until the creator presses "次のハンドへ".

- Preflop: shows which color of the registered hand range the hole cards
  are and whether the action followed it. With no entrant before you,
  open with the color for the number of players still to act; facing an
  entrant, estimate their range from their position, call one color
  above it and raise two above. Purple ranks just below white (SB only).
- Flop onward: equity vs ranges estimated from those colors (narrowed by
  later bets/calls) gives a rough EV per option. The score is the EV lost
  against the best option, relative to the pot. It is a guide, not GTO.
- Also: pot-odds call check, all-in equity and luck-adjusted P&L,
  VPIP/PFR/3bet/Cbet/WTSD/W$SD/AF, per-color and per-position compliance,
  habits to watch, worst-5 hands, a P&L chart and a step-by-step replay
  on the admin-only /review page.

Hand now logs every action for the review. The range data moves to
admin/range-data.js, shared by the range page and the server. Reviews are
saved under data/reviews/ (newest 50 sessions). Admin scripts and CSS are
no longer cached for a day, so updates show up immediately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:11:16 +09:00
25 changed files with 2308 additions and 66 deletions

No files matched your search

+18 -3
View File
@@ -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 に保存。リロードしても同じ席に戻れる。
+75
View File
@@ -0,0 +1,75 @@
// 登録してあるハンドレンジ(ハンドレンジ学習ページとレビュー付きCPU戦で共用・管理者専用)
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.HandRange = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const RANKS = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
// 強い順。tier が大きいほど強い(レビューでは「一つ上」「二つ上」をこの段階で数える)
// 人数は「自分を含めず、まだ行動していない人の数」(自分より前に誰もコール・レイズしていないとき)。
// 紫枠は後ろが1人(SB で全員が降りて回ってきた)ときだけオープンできる手として、白の一つ下に置く
const CATEGORIES = [
{ id: 'navy', name: '紺', label: '8人(強)', tier: 7 },
{ id: 'red', name: '赤', label: '8人(弱)', tier: 6 },
{ id: 'yellow', name: '黄', label: '6〜7人', tier: 5 },
{ id: 'green', name: '緑', label: '4〜5人', tier: 4 },
{ id: 'blue', name: '青', label: '3人', tier: 3 },
{ id: 'white', name: '白', label: '2人', tier: 2 },
{ id: 'purple', name: '紫枠', label: '紫枠', tier: 1 },
{ id: 'gray', name: 'グレー', label: 'フォールド', tier: 0 },
];
// 全169ハンドの分類(載っていないハンドはフォールド)。紫枠はクイズ上の独立した回答
const RANGE = {
AA: 'navy', AKs: 'navy', AQs: 'red', AJs: 'red', ATs: 'red', A9s: 'green', A8s: 'green', A7s: 'green', A6s: 'green', A5s: 'green', A4s: 'green', A3s: 'green', A2s: 'green',
AKo: 'navy', KK: 'navy', KQs: 'red', KJs: 'yellow', KTs: 'green', K9s: 'green', K8s: 'white', K7s: 'white', K6s: 'white', K5s: 'white', K4s: 'white', K3s: 'white', K2s: 'white',
AQo: 'red', KQo: 'yellow', QQ: 'navy', QJs: 'yellow', QTs: 'green', Q9s: 'blue', Q8s: 'white', Q7s: 'white', Q6s: 'white', Q5s: 'purple', Q4s: 'purple', Q3s: 'purple', Q2s: 'purple',
AJo: 'yellow', KJo: 'green', QJo: 'blue', JJ: 'red', JTs: 'yellow', J9s: 'blue', J8s: 'white', J7s: 'white', J6s: 'purple',
ATo: 'green', KTo: 'blue', QTo: 'white', JTo: 'blue', TT: 'red', T9s: 'green', T8s: 'blue', T7s: 'purple',
A9o: 'blue', K9o: 'white', Q9o: 'white', J9o: 'white', T9o: 'white', 99: 'red', '98s': 'blue', '97s': 'white', '96s': 'purple',
A8o: 'white', '98o': 'purple', 88: 'yellow', '87s': 'white', '86s': 'purple',
A7o: 'white', 77: 'yellow', '76s': 'white', '75s': 'purple',
A6o: 'purple', 66: 'green', '65s': 'white', '64s': 'purple',
55: 'green', '54s': 'purple',
44: 'blue',
33: 'blue',
22: 'blue',
};
const BY_ID = new Map(CATEGORIES.map((c) => [c.id, c]));
// 13×13 の表の (行, 列) のハンド。右上がスーテッド、左下がオフスート
const handAt = (r, c) => (r === c ? RANKS[r] + RANKS[c] : r < c ? RANKS[r] + RANKS[c] + 's' : RANKS[c] + RANKS[r] + 'o');
const categoryOf = (hand) => RANGE[hand] || 'gray';
const category = (id) => BY_ID.get(id) || BY_ID.get('gray');
const labelOf = (id) => category(id).label;
const tierOf = (hand) => category(categoryOf(hand)).tier;
const categoryOfTier = (tier) => CATEGORIES.find((c) => c.tier === tier) || BY_ID.get('gray');
// ["As", "Kd"] → "AKo"
function handKey(cards) {
const [a, b] = cards;
const ia = RANKS.indexOf(a[0]);
const ib = RANKS.indexOf(b[0]);
const hi = ia <= ib ? a : b;
const lo = ia <= ib ? b : a;
if (hi[0] === lo[0]) return hi[0] + lo[0];
return hi[0] + lo[0] + (hi[1] === lo[1] ? 's' : 'o');
}
// 自分より前に誰もコール・レイズしていないとき、後ろの人数(自分を含めず、まだ行動していない人)で
// 参加してよい一番弱い段階。8人以上は赤(紺は赤の一つ上)。0人(全員降りてBBに回った)は無し
function requiredTier(behind) {
if (behind >= 8) return 6;
if (behind >= 6) return 5;
if (behind >= 4) return 4;
if (behind === 3) return 3;
if (behind === 2) return 2;
if (behind === 1) return 1;
return 0;
}
return { RANKS, CATEGORIES, RANGE, handAt, handKey, categoryOf, category, labelOf, tierOf, categoryOfTier, requiredTier };
});
+1
View File
@@ -70,6 +70,7 @@
</section>
</main>
<script src="/range/files/range-data.js"></script>
<script src="/range/files/range.js"></script>
</body>
</html>
+1 -33
View File
@@ -3,39 +3,7 @@
'use strict';
const $ = (id) => document.getElementById(id);
const RANKS = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
const CATEGORIES = [
{ id: 'navy', label: '8人(強)' },
{ id: 'red', label: '8人(弱)' },
{ id: 'yellow', label: '6〜7人' },
{ id: 'green', label: '4〜5人' },
{ id: 'blue', label: '3人' },
{ id: 'white', label: '2人' },
{ id: 'purple', label: '紫枠' },
{ id: 'gray', label: 'フォールド' },
];
// 全169ハンドの分類(載っていないハンドはフォールド)。紫枠はクイズ上の独立した回答
const RANGE = {
AA: 'navy', AKs: 'navy', AQs: 'red', AJs: 'red', ATs: 'red', A9s: 'green', A8s: 'green', A7s: 'green', A6s: 'green', A5s: 'green', A4s: 'green', A3s: 'green', A2s: 'green',
AKo: 'navy', KK: 'navy', KQs: 'red', KJs: 'yellow', KTs: 'green', K9s: 'green', K8s: 'white', K7s: 'white', K6s: 'white', K5s: 'white', K4s: 'white', K3s: 'white', K2s: 'white',
AQo: 'red', KQo: 'yellow', QQ: 'navy', QJs: 'yellow', QTs: 'green', Q9s: 'blue', Q8s: 'white', Q7s: 'white', Q6s: 'white', Q5s: 'purple', Q4s: 'purple', Q3s: 'purple', Q2s: 'purple',
AJo: 'yellow', KJo: 'green', QJo: 'blue', JJ: 'red', JTs: 'yellow', J9s: 'blue', J8s: 'white', J7s: 'white', J6s: 'purple',
ATo: 'green', KTo: 'blue', QTo: 'white', JTo: 'blue', TT: 'red', T9s: 'green', T8s: 'blue', T7s: 'purple',
A9o: 'blue', K9o: 'white', Q9o: 'white', J9o: 'white', T9o: 'white', 99: 'red', '98s': 'blue', '97s': 'white', '96s': 'purple',
A8o: 'white', '98o': 'purple', 88: 'yellow', '87s': 'white', '86s': 'purple',
A7o: 'white', 77: 'yellow', '76s': 'white', '75s': 'purple',
A6o: 'purple', 66: 'green', '65s': 'white', '64s': 'purple',
55: 'green', '54s': 'purple',
44: 'blue',
33: 'blue',
22: 'blue',
};
const handAt = (r, c) => (r === c ? RANKS[r] + RANKS[c] : r < c ? RANKS[r] + RANKS[c] + 's' : RANKS[c] + RANKS[r] + 'o');
const categoryOf = (hand) => RANGE[hand] || 'gray';
const labelOf = (id) => (CATEGORIES.find((c) => c.id === id) || CATEGORIES[CATEGORIES.length - 1]).label;
const { RANKS, CATEGORIES, handAt, categoryOf, labelOf } = window.HandRange;
function swatch(id) {
const s = document.createElement('span');
+145
View File
@@ -0,0 +1,145 @@
/* CPU戦のレビュー(管理者専用)。基本のデザインは /assets/style.css、タブは range.css、
ハンドレンジの色・点数の印(.rv-color / .rv-mark など)は style.css の「レビュー付きCPU戦」を使う */
.review-wrap { padding-bottom: 40px; }
.plus { color: var(--green); }
.minus { color: var(--red); }
/* 点数の色(◎ ○ △ ✕ と同じ段階) */
.score-great { color: var(--green); }
.score-good { color: #8fd18f; }
.score-warn { color: var(--orange); }
.score-bad { color: var(--red); }
.score-none { color: var(--muted); }
/* 対局の一覧 */
.rv-sessions { display: grid; gap: 8px; margin-top: 12px; }
.rv-session {
display: grid;
gap: 4px;
text-align: left;
font: inherit;
color: var(--text);
background: var(--panel);
border: 0;
border-radius: var(--radius);
padding: 14px 16px;
cursor: pointer;
}
.rv-session:hover, .rv-session:focus-visible { background: var(--panel-2); }
.rv-session-name { font-weight: 800; font-size: 16px; display: flex; align-items: center; gap: 8px; }
.rv-session-meta { color: var(--muted); font-size: 12px; }
/* 概要とタイル */
.rv-overview { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; }
.rv-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.rv-tile { background: var(--panel); border-radius: 12px; padding: 10px 12px; display: grid; gap: 2px; min-width: 0; }
.rv-tile span { font-size: 12px; color: var(--muted); font-weight: 700; }
.rv-tile b { font-size: 22px; font-variant-numeric: tabular-nums; }
.rv-tile small { font-size: 11px; color: var(--muted); line-height: 1.4; }
.rv-tabs .seg-btn { font-size: 13px; padding: 9px 2px; }
/* 表 */
.rv-table-wrap { overflow-x: auto; margin-top: 8px; border-radius: 12px; background: var(--panel); }
.rv-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.rv-table th, .rv-table td { padding: 8px 6px; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--line); }
.rv-table th { color: var(--muted); font-size: 11px; font-weight: 700; }
.rv-table .num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; }
.rv-table tbody tr[data-hand] { cursor: pointer; }
.rv-table tbody tr[data-hand]:hover, .rv-table tbody tr[data-hand]:focus-visible { background: var(--panel-2); outline: none; }
.rv-table tfoot td { font-weight: 800; border-bottom: 0; }
.rv-hand-cell b { margin-right: 2px; }
.rv-table .rv-color { font-size: 11px; padding: 1px 6px; }
.rv-leaks { margin: 0; padding-left: 20px; display: grid; gap: 4px; font-size: 14px; }
.rv-leaks b { color: var(--gold); }
/* 見直したいハンド */
.rv-worst { display: grid; gap: 8px; }
.rv-worst-item {
display: grid;
grid-template-columns: 28px 1fr;
gap: 2px 8px;
align-items: center;
text-align: left;
font: inherit;
color: var(--text);
background: var(--panel);
border: 0;
border-radius: 12px;
padding: 10px 12px;
cursor: pointer;
}
.rv-worst-item:hover, .rv-worst-item:focus-visible { background: var(--panel-2); }
.rv-rank { grid-row: span 2; font-size: 20px; font-weight: 900; color: var(--gold); text-align: center; }
.rv-worst-meta { font-size: 12px; color: var(--muted); }
.rv-worst-item .rv-color { width: 14px; height: 14px; padding: 0; vertical-align: -2px; }
/* 収支のグラフ(実際 = 実線、運を除く = 破線。色だけに頼らない) */
.rv-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 13px; margin-bottom: 6px; }
.sw { display: inline-block; width: 16px; height: 0; border-top: 2px solid; vertical-align: middle; margin-right: 6px; }
.sw.actual { border-color: #b8891c; }
.sw.ev { border-color: #4a90e2; border-top-style: dashed; }
.rv-chart { position: relative; background: var(--panel); border-radius: 12px; padding: 8px; }
.rv-chart svg { display: block; width: 100%; height: auto; touch-action: pan-y; }
.rv-chart .gl { stroke: var(--line); stroke-width: 1; }
.rv-chart .zero { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 2 3; }
.rv-chart .tick { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.rv-chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.rv-chart .line.actual { stroke: #b8891c; }
.rv-chart .line.ev { stroke: #4a90e2; stroke-dasharray: 6 4; }
.rv-chart .cross { stroke: var(--muted); stroke-width: 1; }
.rv-chart .dot { stroke: var(--panel); stroke-width: 2; }
.rv-chart .dot.actual { fill: #b8891c; }
.rv-chart .dot.ev { fill: #4a90e2; }
.rv-chart .hit { fill: transparent; cursor: crosshair; }
.rv-tip {
position: absolute;
top: 8px;
background: var(--bg-deep);
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
pointer-events: none;
white-space: nowrap;
}
/* リプレイ */
.rv-note { margin-top: 10px; font-size: 13px; background: var(--panel); border-radius: 10px; padding: 8px 10px; }
.rv-board-box { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 14px; background: var(--felt-dark); border-radius: 12px; padding: 10px; }
.rv-board { display: flex; gap: 3px; }
.rv-pot { font-weight: 900; color: #fff; font-variant-numeric: tabular-nums; }
.rv-step { margin-top: 10px; background: var(--panel); border-radius: 12px; padding: 10px 12px; font-size: 14px; min-height: 64px; }
.rv-step-title { font-size: 12px; font-weight: 800; color: var(--gold); margin-bottom: 2px; }
.rv-controls { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 8px; }
.rv-controls .btn { padding: 10px 0; font-size: 16px; }
.rv-analysis { margin-top: 10px; background: var(--panel); border: 1px solid var(--gold); border-radius: 12px; padding: 6px 12px 10px; }
.rv-analysis .rv-item:first-child { border-top: 0; }
.rv-ev { margin-top: 6px; }
.rv-ev tr.actual td { background: rgba(246, 199, 68, 0.08); }
.rv-ev .best { color: var(--green); }
.rv-players { display: grid; gap: 6px; margin-top: 12px; }
.rv-player { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; background: var(--panel); border-radius: 10px; padding: 6px 10px; border: 2px solid transparent; }
.rv-player.hero { border-color: rgba(246, 199, 68, 0.35); }
.rv-player.acting { border-color: var(--gold); }
.rv-player.folded { opacity: 0.55; }
.rv-player-info { min-width: 0; font-size: 14px; }
.rv-player-act { font-size: 12px; font-weight: 800; color: var(--muted); text-align: right; }
.rv-pos { font-size: 11px; font-weight: 800; color: var(--gold); margin-left: 4px; }
.rv-log { display: flex; flex-wrap: wrap; gap: 4px; }
.rv-log-street { width: 100%; font-size: 11px; color: var(--muted); font-weight: 800; margin-top: 6px; }
.rv-log-item {
font: inherit;
font-size: 12px;
color: var(--text);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
}
.rv-log-item .hint { margin: 0 4px; font-size: 11px; }
.rv-log-item.mine { border-color: rgba(246, 199, 68, 0.6); }
.rv-log-item.current { background: var(--gold); color: #1a1a1a; }
.rv-log-item.current .hint { color: #333; }
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<meta name="robots" content="noindex, nofollow">
<title>CPU戦のレビュー - Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/range/files/range.css">
<link rel="stylesheet" href="/range/files/review.css">
</head>
<body>
<main class="wrap review-wrap">
<div class="topbar">
<a class="icon-btn" id="back" href="/" aria-label="戻る">←</a>
<h1 id="title">CPU戦のレビュー</h1>
</div>
<!-- 対局の一覧 -->
<section id="view-list">
<p class="hint">レビュー付きCPU戦の対局です(新しい順・最大50件)。</p>
<div id="sessions" class="rv-sessions"></div>
</section>
<!-- 1回の対局 -->
<section id="view-session" hidden>
<div class="rv-overview" id="overview"></div>
<div class="seg rv-tabs" id="tabs">
<button type="button" class="seg-btn active" data-tab="scores">点数表</button>
<button type="button" class="seg-btn" data-tab="stats">成績</button>
<button type="button" class="seg-btn" data-tab="range">色・位置</button>
<button type="button" class="seg-btn" data-tab="worst">見直し</button>
<button type="button" class="seg-btn" data-tab="graph">収支</button>
</div>
<div id="tab-body"></div>
</section>
<!-- 1ハンドのリプレイ -->
<section id="view-replay" hidden>
<div class="rv-replay-head" id="replay-head"></div>
<div class="rv-board-box">
<div class="rv-board" id="replay-board"></div>
<div class="rv-pot" id="replay-pot"></div>
</div>
<div class="rv-step" id="replay-step" aria-live="polite"></div>
<div class="rv-controls">
<button type="button" class="btn btn-dark" data-step="first" aria-label="最初へ">⏮</button>
<button type="button" class="btn btn-dark" data-step="prev" aria-label="前へ">◀</button>
<button type="button" class="btn btn-blue" data-step="next" aria-label="次へ">▶</button>
<button type="button" class="btn btn-dark" data-step="last" aria-label="最後へ">⏭</button>
</div>
<div id="replay-analysis"></div>
<div class="rv-players" id="replay-players"></div>
<div class="rv-section">アクション一覧</div>
<div class="rv-log" id="replay-log"></div>
</section>
</main>
<script src="/assets/common.js"></script>
<script src="/range/files/review.js"></script>
</body>
</html>
+559
View File
@@ -0,0 +1,559 @@
// CPU戦のレビュー(管理者専用): 対局ごとの点数表・成績・色とポジションの傾向・見直したいハンド・収支の推移と、
// 1ハンドずつのリプレイ
(function () {
'use strict';
const { esc, chips, api, toast } = App;
const $ = (id) => document.getElementById(id);
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
const STREET_LABEL = { PREFLOP: 'プリフロップ', FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
const STREET_SHORT = { PREFLOP: 'PF', FLOP: 'Flop', TURN: 'Turn', RIVER: 'River' };
const BOARD_COUNT = { PREFLOP: 0, FLOP: 3, TURN: 4, RIVER: 5 };
const KIND_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
const TYPE_LABEL = { FOLD: 'フォールド', CHECK: 'チェック', CALL: 'コール', BET: 'ベット', RAISE: 'レイズ' };
const MARK_CLASS = { '◎': 'great', '○': 'good', '△': 'warn', '✕': 'bad' };
const SUIT_SYMBOL = { s: '♠︎', h: '♥︎', d: '♦︎', c: '♣︎' };
let session = null;
let tab = 'scores';
let replay = null; // { hand, step }
const markOf = (score) => (score == null ? '' : score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕');
const scoreClass = (score) => (score == null ? 'none' : MARK_CLASS[markOf(score)]);
const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '±') + chips(Math.abs(n));
const signClass = (n) => (n > 0 ? 'plus' : n < 0 ? 'minus' : '');
const pctText = (v) => (v == null ? '—' : `${v}%`);
const dateText = (t) => new Date(t).toLocaleString('ja-JP', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
const optionLabel = (o) => (o.kind === 'raise' ? `ベット/レイズ ${chips(o.to)}` : KIND_LABEL[o.kind]);
const colorChip = (h) => `<span class="rv-color cat-${esc(h.color)}">${esc(h.colorName)}</span>`;
function cardHtml(c, extra = '') {
if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return `<span class="pcard slot ${extra}"></span>`;
const rank = c[0] === 'T' ? '10' : c[0];
return `<span class="pcard suit-${c[1]} ${extra}"><b>${rank}</b><i>${SUIT_SYMBOL[c[1]]}</i></span>`;
}
// ---------- 画面の切り替え(URL: ?s=対局ID&h=ハンド番号) ----------
function show(view) {
for (const v of ['list', 'session', 'replay']) $(`view-${v}`).hidden = v !== view;
window.scrollTo(0, 0);
}
function go(params, push = true) {
const q = new URLSearchParams(params).toString();
const url = location.pathname + (q ? `?${q}` : '');
if (push) history.pushState(null, '', url);
else history.replaceState(null, '', url);
route();
}
async function route() {
const p = new URLSearchParams(location.search);
const id = p.get('s');
const hand = Number(p.get('h'));
if (!id) {
$('title').textContent = 'CPU戦のレビュー';
$('back').href = '/';
show('list');
return loadList();
}
if (!session || session.id !== id || !hand) {
try {
session = await api('GET', `/api/reviews/${encodeURIComponent(id)}`);
} catch (err) {
toast(err.message);
return go({}, false);
}
}
$('title').textContent = session.tableName;
if (hand) {
const h = session.hands.find((x) => x.number === hand);
if (h) {
$('back').href = `?s=${encodeURIComponent(id)}`;
return openReplay(h);
}
}
$('back').href = '/review';
show('session');
renderSession();
}
window.addEventListener('popstate', route);
// 戻るボタンは履歴を積まずに一つ上へ
$('back').addEventListener('click', (e) => {
const href = $('back').getAttribute('href');
if (href === '/') return;
e.preventDefault();
const p = new URLSearchParams(location.search);
go(p.get('h') ? { s: p.get('s') } : {});
});
// ---------- 対局の一覧 ----------
async function loadList() {
let list;
try {
list = await api('GET', '/api/reviews');
} catch (err) {
$('sessions').innerHTML = `<div class="empty">${esc(err.message)}</div>`;
return;
}
if (!list.length) {
$('sessions').innerHTML = '<div class="empty">まだ対局がありません。<br>ロビーの「テーブルを作成」で「レビュー付きCPU戦」を選んでください。</div>';
return;
}
$('sessions').innerHTML = list
.map(
(s) => `
<button type="button" class="rv-session" data-id="${esc(s.id)}">
<span class="rv-session-name">${esc(s.tableName)}${s.active ? '<span class="badge live">PLAYING</span>' : ''}</span>
<span class="rv-session-meta">${dateText(s.createdAt)} ・ ${s.hands}ハンド ・ ${esc(s.heroName)}</span>
</button>`
)
.join('');
}
$('sessions').addEventListener('click', (e) => {
const b = e.target.closest('[data-id]');
if (b) go({ s: b.dataset.id });
});
// ---------- 1回の対局 ----------
function renderSession() {
const s = session.summary;
const net = s.graph.length ? s.graph[s.graph.length - 1].net : 0;
$('overview').innerHTML = `
<div class="rv-tile"><span>ハンド数</span><b>${s.stats.hands}</b></div>
<div class="rv-tile"><span>平均点</span><b class="score-${scoreClass(s.total)}">${s.total == null ? '—' : s.total}</b></div>
<div class="rv-tile"><span>収支</span><b class="${signClass(net)}">${signed(net)}</b></div>`;
for (const b of $('tabs').children) b.classList.toggle('active', b.dataset.tab === tab);
const render = { scores: renderScores, stats: renderStats, range: renderRange, worst: renderWorst, graph: renderGraph }[tab];
$('tab-body').innerHTML = '';
render();
}
$('tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-tab]');
if (!b) return;
tab = b.dataset.tab;
renderSession();
});
function scoreCell(v) {
return `<td class="num score-${scoreClass(v)}">${v == null ? '—' : v}</td>`;
}
// 点数表: ハンドごと・ストリートごとの点数
function renderScores() {
const hands = session.hands;
if (!hands.length) {
$('tab-body').innerHTML = '<div class="empty">まだハンドがありません。</div>';
return;
}
const rows = hands
.slice()
.reverse()
.map(
(h) => `
<tr data-hand="${h.number}" tabindex="0">
<td class="num">${h.number}</td>
<td class="rv-hand-cell"><b>${esc(h.hero.key)}</b> ${colorChip(h.hero)}</td>
<td>${esc(h.hero.position)}</td>
${STREETS.map((st) => scoreCell(h.streetScores[st])).join('')}
${scoreCell(h.handScore)}
<td class="num ${signClass(h.hero.net)}">${signed(h.hero.net)}</td>
</tr>`
)
.join('');
const avg = session.summary.streets;
$('tab-body').innerHTML = `
<p class="hint">行をタップするとリプレイと詳しい判定を表示します。PF はハンドレンジどおりか、Flop 以降は推定レンジに対する期待値の目安(100点 = 最善)。</p>
<div class="rv-table-wrap">
<table class="rv-table">
<thead><tr><th>#</th><th>ハンド</th><th>位置</th>${STREETS.map((st) => `<th>${STREET_SHORT[st]}</th>`).join('')}<th>平均</th><th>収支</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td></td><td colspan="2">平均</td>${STREETS.map((st) => scoreCell(avg[st])).join('')}${scoreCell(session.summary.total)}<td></td></tr></tfoot>
</table>
</div>`;
}
$('tab-body').addEventListener('click', (e) => {
const r = e.target.closest('[data-hand]');
if (r) go({ s: session.id, h: r.dataset.hand });
});
$('tab-body').addEventListener('keydown', (e) => {
const r = e.target.closest('[data-hand]');
if (r && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
go({ s: session.id, h: r.dataset.hand });
}
});
// 成績
function renderStats() {
const st = session.summary.stats;
const tiles = [
['VPIP', pctText(st.vpip), '自分から参加した割合'],
['PFR', pctText(st.pfr), 'プリフロップでレイズした割合'],
['3bet', pctText(st.threeBet), `レイズに対してレイズし返した割合(機会 ${st.threeBetOpp}回)`],
['Cベット', pctText(st.cbet), `プリフロップのレイザーがフロップでベットした割合(機会 ${st.cbetOpp}回)`],
['WTSD', pctText(st.wtsd), 'フロップを見てショーダウンまで行った割合'],
['W$SD', pctText(st.wsd), 'ショーダウンで勝った割合'],
['AF', st.af == null ? '∞' : st.af, 'フロップ以降の (ベット+レイズ) ÷ コール'],
];
const avg = session.summary.streets;
$('tab-body').innerHTML = `
<div class="rv-tiles">${tiles.map(([k, v, d]) => `<div class="rv-tile"><span>${k}</span><b>${v}</b><small>${d}</small></div>`).join('')}</div>
<div class="rv-section">ストリートごとの平均点</div>
<div class="rv-streets">${STREETS.map((s) => `<div class="rv-street"><span>${STREET_SHORT[s]}</span><b class="score-${scoreClass(avg[s])}">${avg[s] == null ? '—' : avg[s]}</b></div>`).join('')}</div>
<p class="hint">目安: VPIP 20〜28%・PFR 15〜22%・WTSD 25〜32%・AF 2〜3(ノーリミットのリングゲームで一般的な範囲)。</p>`;
}
// 色・ポジション・クセ
function renderRange() {
const s = session.summary;
const colorRows = s.colors
.map(
(c) => `
<tr>
<td><span class="rv-color cat-${esc(c.id)}">${esc(c.name)}</span> <span class="hint">${esc(c.label)}</span></td>
<td class="num">${c.dealt}</td>
<td class="num">${c.dealt ? pctText(Math.round((c.played / c.dealt) * 1000) / 10) : '—'}</td>
<td class="num">${c.judged ? pctText(Math.round((c.good / c.judged) * 1000) / 10) : '—'}</td>
</tr>`
)
.join('');
const VERDICT = { loose: '参加しすぎ', tight: '降りすぎ', passive: '消極的', aggressive: '攻めすぎ', needless: '不要なフォールド' };
const posRows = s.positions
.map((p) => {
const mistakes = Object.entries(p.verdicts)
.sort((a, b) => b[1] - a[1])
.map(([v, n]) => `${VERDICT[v]} ${n}`)
.join('・');
return `
<tr>
<td>${esc(p.position)}</td>
<td class="num">${p.hands}</td>
<td class="num">${pctText(Math.round((p.played / p.hands) * 1000) / 10)}</td>
<td class="num">${p.judged ? pctText(Math.round((p.good / p.judged) * 1000) / 10) : '—'}</td>
<td>${mistakes || '—'}</td>
</tr>`;
})
.join('');
const leaks = s.leaks.length
? `<ul class="rv-leaks">${s.leaks.map((l) => `<li>${esc(l.text)} <b>${l.count}回</b>${l.of ? `<span class="hint">(${l.of}回中)</span>` : ''}</li>`).join('')}</ul>`
: '<div class="hint">目立つクセは見つかりませんでした。</div>';
$('tab-body').innerHTML = `
<div class="rv-section">気をつけたいクセ</div>${leaks}
<div class="rv-section">色ごと</div>
<div class="rv-table-wrap"><table class="rv-table">
<thead><tr><th>色</th><th>配られた</th><th>参加率</th><th>レンジどおり</th></tr></thead>
<tbody>${colorRows}</tbody>
</table></div>
<div class="rv-section">ポジションごと</div>
<div class="rv-table-wrap"><table class="rv-table">
<thead><tr><th>位置</th><th>ハンド</th><th>参加率</th><th>レンジどおり</th><th>主な間違い</th></tr></thead>
<tbody>${posRows}</tbody>
</table></div>`;
}
// 見直したいハンド
function renderWorst() {
const w = session.summary.worst;
if (!w.length) {
$('tab-body').innerHTML = '<div class="empty">見直したいハンドはありません。すべて満点です。</div>';
return;
}
$('tab-body').innerHTML = `
<p class="hint">フロップ以降で失った期待値(BB)が大きい順。同じならプリフロップの判定が悪い順。</p>
<div class="rv-worst">${w
.map(
(h, i) => `
<button type="button" class="rv-worst-item" data-hand="${h.number}">
<span class="rv-rank">${i + 1}</span>
<span><b>#${h.number} ${esc(h.key)}</b> <span class="rv-color cat-${esc(h.color)}"></span> ${esc(h.position)}</span>
<span class="rv-worst-meta">−${h.lossBB}BB ・ ${h.handScore == null ? '—' : h.handScore}点 ・ <span class="${signClass(h.net)}">${signed(h.net)}</span></span>
</button>`
)
.join('')}</div>`;
}
// 収支の推移: 実際の収支と、オールインの期待値で運を除いた収支
function renderGraph() {
const g = session.summary.graph;
if (g.length < 2) {
$('tab-body').innerHTML = '<div class="empty">2ハンド以上でグラフを表示します。</div>';
return;
}
const W = 360;
const H = 220;
const pad = { l: 48, r: 10, t: 12, b: 22 };
const values = g.flatMap((p) => [p.net, p.evNet]).concat([0]);
// 目盛りは 1・2・5 × 10^n 刻み
const raw = (Math.max(...values) - Math.min(...values)) / 4 || 1;
const mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 5, 10].map((m) => m * mag).find((x) => x >= raw);
const min = Math.floor(Math.min(...values) / step) * step;
const max = Math.ceil(Math.max(...values) / step) * step || step;
const money = (v) => (v < 0 ? '−' : '') + chips(Math.abs(v));
const x = (i) => pad.l + (i / (g.length - 1)) * (W - pad.l - pad.r);
const y = (v) => pad.t + ((max - v) / (max - min)) * (H - pad.t - pad.b);
const path = (key) => g.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p[key]).toFixed(1)}`).join('');
let grid = '';
for (let v = min; v <= max + step / 2; v += step) {
const yy = y(v).toFixed(1);
grid += `<line class="gl" x1="${pad.l}" x2="${W - pad.r}" y1="${yy}" y2="${yy}"/><text class="tick" x="${pad.l - 6}" y="${yy}" dy="4" text-anchor="end">${esc(money(Math.round(v)))}</text>`;
}
const last = g[g.length - 1];
$('tab-body').innerHTML = `
<div class="rv-legend">
<span><i class="sw actual"></i>実際の収支 <b class="${signClass(last.net)}">${signed(last.net)}</b></span>
<span><i class="sw ev"></i>運を除いた収支 <b class="${signClass(last.evNet)}">${signed(last.evNet)}</b></span>
</div>
<div class="rv-chart" id="chart">
<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="ハンドごとの累計収支">
${grid}
<line class="zero" x1="${pad.l}" x2="${W - pad.r}" y1="${y(0).toFixed(1)}" y2="${y(0).toFixed(1)}"/>
<text class="tick" x="${pad.l}" y="${H - 6}">#${g[0].number}</text>
<text class="tick" x="${W - pad.r}" y="${H - 6}" text-anchor="end">#${last.number}</text>
<path class="line actual" d="${path('net')}"/>
<path class="line ev" d="${path('evNet')}"/>
<line class="cross" id="cross" y1="${pad.t}" y2="${H - pad.b}" visibility="hidden"/>
<circle class="dot actual" id="dot-net" r="4" visibility="hidden"/>
<circle class="dot ev" id="dot-ev" r="4" visibility="hidden"/>
<rect class="hit" x="${pad.l}" y="${pad.t}" width="${W - pad.l - pad.r}" height="${H - pad.t - pad.b}"/>
</svg>
<div class="rv-tip" id="tip" hidden></div>
</div>
<p class="hint">運を除いた収支は、オールインで決着したハンドを「オールインした時点の勝率どおりにポットを受け取った」とみなした収支です。実際の収支がこれより上なら運が良かった、下なら運が悪かったことになります。</p>`;
const svg = $('chart').querySelector('svg');
const tip = $('tip');
const move = (clientX) => {
const rect = svg.getBoundingClientRect();
const sx = ((clientX - rect.left) / rect.width) * W;
const i = Math.max(0, Math.min(g.length - 1, Math.round(((sx - pad.l) / (W - pad.l - pad.r)) * (g.length - 1))));
const p = g[i];
const cx = x(i).toFixed(1);
$('cross').setAttribute('x1', cx);
$('cross').setAttribute('x2', cx);
$('cross').setAttribute('visibility', 'visible');
for (const [id, key] of [['dot-net', 'net'], ['dot-ev', 'evNet']]) {
$(id).setAttribute('cx', cx);
$(id).setAttribute('cy', y(p[key]).toFixed(1));
$(id).setAttribute('visibility', 'visible');
}
tip.innerHTML = `<b>ハンド #${p.number}</b><div><i class="sw actual"></i>実際 ${signed(p.net)}</div><div><i class="sw ev"></i>運を除く ${signed(p.evNet)}</div>`;
tip.hidden = false;
const left = (x(i) / W) * rect.width;
tip.style.left = `${Math.min(rect.width - tip.offsetWidth, Math.max(0, left - tip.offsetWidth / 2))}px`;
};
const hide = () => {
tip.hidden = true;
for (const id of ['cross', 'dot-net', 'dot-ev']) $(id).setAttribute('visibility', 'hidden');
};
svg.addEventListener('pointermove', (e) => move(e.clientX));
svg.addEventListener('pointerdown', (e) => move(e.clientX));
svg.addEventListener('pointerleave', hide);
}
// ---------- リプレイ ----------
function openReplay(h) {
replay = { hand: h, step: firstHeroStep(h) };
show('replay');
$('replay-head').innerHTML = `
<div class="rv-head">
<div class="rv-cards">${h.hero.cards.map((c) => cardHtml(c)).join('')}</div>
<div>
<div class="rv-hand"><b>#${h.number} ${esc(h.hero.key)}</b> ${colorChip(h.hero)}</div>
<div class="rv-sub">${esc(h.hero.colorLabel)} ・ ${esc(h.hero.position)} ・ ブラインド ${chips(h.sb)}/${chips(h.bb)} ・ 収支 <span class="${signClass(h.hero.net)}">${signed(h.hero.net)}</span></div>
</div>
</div>
<div class="rv-streets">${STREETS.map((st) => `<div class="rv-street"><span>${STREET_SHORT[st]}</span><b class="score-${scoreClass(h.streetScores[st])}">${h.streetScores[st] == null ? '—' : h.streetScores[st]}</b></div>`).join('')}</div>
${allInHtml(h)}`;
$('replay-log').innerHTML = logHtml(h);
renderStep();
}
function allInHtml(h) {
const a = h.hero.allIn;
if (!a) return '';
return `<div class="rv-note">${STREET_LABEL[a.street]}でオールインした時点の勝率 <b>${a.equity}%</b> → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 <span class="${signClass(a.won - a.ev)}">${signed(a.won - a.ev)}</span>)</div>`;
}
// 最初に見せる場面: hero の最初の判断(無ければ最初)
function firstHeroStep(h) {
const i = h.actions.findIndex((a) => a.seat === h.heroSeat && !a.auto);
return i >= 0 ? i : -1;
}
const nameOf = (h, seat) => {
const p = h.players.find((x) => x.seat === seat);
return p ? p.name : `席${seat + 1}`;
};
const posOf = (h, seat) => {
const p = h.players.find((x) => x.seat === seat);
return p ? p.position : '';
};
function actionText(h, a) {
const amount = a.type === 'CALL' || a.type === 'BET' || a.type === 'RAISE' ? ` ${chips(a.amount)}` : '';
return `${TYPE_LABEL[a.type]}${amount}${a.allIn ? '(オールイン)' : ''}${a.auto ? '(自動)' : ''}`;
}
function logHtml(h) {
let html = '';
let street = null;
h.actions.forEach((a, i) => {
if (a.street !== street) {
street = a.street;
html += `<div class="rv-log-street">${STREET_LABEL[street]}</div>`;
}
const mine = a.seat === h.heroSeat;
html += `<button type="button" class="rv-log-item${mine ? ' mine' : ''}" data-step="${i}">${esc(nameOf(h, a.seat))}<span class="hint">${esc(posOf(h, a.seat))}</span> ${esc(actionText(h, a))}</button>`;
});
html += `<button type="button" class="rv-log-item end" data-step="${h.actions.length}">結果</button>`;
return html;
}
// step 番目のアクションの後の状態(-1 = ブラインドを払った直後、actions.length = 結果)
function stateAt(h, step) {
const invested = new Map(h.players.map((p) => [p.seat, 0]));
const post = (seat, amount) => {
const p = h.players.find((x) => x.seat === seat);
if (p) invested.set(seat, invested.get(seat) + Math.min(amount, p.start));
};
post(h.sbSeat, h.sb);
post(h.bbSeat, h.bb);
const folded = new Set();
const last = new Map();
let pot = h.sb + h.bb;
let street = 'PREFLOP';
const upto = Math.min(step, h.actions.length - 1);
for (let i = 0; i <= upto; i++) {
const a = h.actions[i];
if (a.street !== street) {
street = a.street;
last.clear();
}
invested.set(a.seat, invested.get(a.seat) + a.added);
if (a.type === 'FOLD') folded.add(a.seat);
last.set(a.seat, a);
pot = a.pot + a.added;
}
return { invested, folded, last, pot, street };
}
function renderStep() {
const h = replay.hand;
const step = replay.step;
const end = step >= h.actions.length;
const st = stateAt(h, step);
const a = step >= 0 && !end ? h.actions[step] : null;
const shown = end ? h.board.length : BOARD_COUNT[a ? a.street : 'PREFLOP'];
let board = '';
for (let i = 0; i < 5; i++) board += i < shown && h.board[i] ? cardHtml(h.board[i]) : '<span class="pcard slot"></span>';
$('replay-board').innerHTML = board;
$('replay-pot').textContent = `POT ${chips(end ? (h.results || []).reduce((x, r) => x + r.amount, 0) : st.pot)}`;
if (end) {
const lines = (h.results || []).map((r, i) => {
const tag = h.results.length > 1 ? (i === 0 ? 'メイン ' : 'サイド ') : '';
return `<div>${tag}<b>${r.winners.map((w) => esc(nameOf(h, w))).join('・')}</b> +${chips(r.amount)}${r.handName ? ` <span class="hint">${esc(r.handName)}</span>` : ''}</div>`;
});
$('replay-step').innerHTML = `<div class="rv-step-title">結果</div>${lines.join('')}`;
} else if (a) {
$('replay-step').innerHTML = `<div class="rv-step-title">${STREET_LABEL[a.street]} ${step + 1}/${h.actions.length}</div>
<div><b>${esc(nameOf(h, a.seat))}</b><span class="hint">(${esc(posOf(h, a.seat))})</span> ${esc(actionText(h, a))}</div>
<div class="hint">ポット ${chips(a.pot)}${a.toCall ? ` ・ コール額 ${chips(a.toCall)}` : ''}</div>`;
} else {
$('replay-step').innerHTML = `<div class="rv-step-title">ハンド開始</div><div class="hint">SB ${esc(nameOf(h, h.sbSeat))} ${chips(h.sb)} ・ BB ${esc(nameOf(h, h.bbSeat))} ${chips(h.bb)}</div>`;
}
$('replay-analysis').innerHTML = a && a.seat === h.heroSeat && !a.auto ? analysisHtml(h, step) : '';
const order = h.players.slice();
$('replay-players').innerHTML = order
.map((p) => {
const folded = st.folded.has(p.seat);
const acting = a && a.seat === p.seat;
const l = st.last.get(p.seat);
const cls = ['rv-player', folded ? 'folded' : '', acting ? 'acting' : '', p.seat === h.heroSeat ? 'hero' : ''].join(' ');
const won = end && p.won > 0 ? `<span class="plus">+${chips(p.won)}</span>` : '';
return `
<div class="${cls}">
<div class="rv-cards">${p.cards.map((c) => cardHtml(c, folded ? 'dim' : '')).join('')}</div>
<div class="rv-player-info">
<div><b>${esc(p.name)}</b>${p.bot ? ' <span class="hint">CPU</span>' : ''} <span class="rv-pos">${esc(p.position)}</span></div>
<div class="hint">残り ${chips(end ? p.end : p.start - st.invested.get(p.seat))} ・ 投入 ${chips(st.invested.get(p.seat))}</div>
</div>
<div class="rv-player-act">${won || (l && !end ? esc(TYPE_LABEL[l.type]) : folded ? 'フォールド' : '')}</div>
</div>`;
})
.join('');
for (const b of $('replay-log').children) b.classList.toggle('current', Number(b.dataset.step) === step);
const atStart = step <= -1;
const atEnd = end;
$('view-replay').querySelector('[data-step="first"]').disabled = atStart;
$('view-replay').querySelector('[data-step="prev"]').disabled = atStart;
$('view-replay').querySelector('[data-step="next"]').disabled = atEnd;
$('view-replay').querySelector('[data-step="last"]').disabled = atEnd;
}
// hero の判断の詳しい判定
function analysisHtml(h, index) {
const j = h.preflop.find((x) => x.index === index);
if (j) {
return `<div class="rv-analysis">
<div class="rv-item"><span class="rv-mark ${MARK_CLASS[j.mark]}">${j.mark}</span><div>
<div><b>ハンドレンジ</b> ${colorChip(h.hero)} <span class="rv-score">${j.score}点</span></div>
<div>${esc(j.situation)}</div>
<div class="rv-sub">推奨 <b>${KIND_LABEL[j.recommended]}</b> / あなた <b>${KIND_LABEL[j.actual]}</b> — ${esc(j.label)}</div>
${j.equity != null ? `<div class="rv-sub">参加者の推定レンジに対する勝率 ${j.equity}%(コールに必要 ${j.need}%)</div>` : ''}
</div></div></div>`;
}
const d = h.decisions.find((x) => x.index === index);
if (!d) return '';
const best = Math.max(...d.options.map((o) => o.evBB));
const rows = d.options
.map((o) => {
const isActual = o.kind === d.actual.kind && o.to === d.actual.to;
const isBest = o.evBB === best;
return `<tr class="${isActual ? 'actual' : ''}"><td>${optionLabel(o)}${isActual ? ' <span class="hint">← あなた</span>' : ''}</td><td class="num ${isBest ? 'best' : ''}">${o.evBB > 0 ? '+' : ''}${o.evBB}BB</td></tr>`;
})
.join('');
return `<div class="rv-analysis">
<div class="rv-item"><span class="rv-mark ${MARK_CLASS[d.mark]}">${d.mark}</span><div>
<div><b>${STREET_LABEL[d.street]}の判断</b> <span class="rv-score">${d.score}点</span>${d.lossBB > 0 ? ` <span class="hint">(${d.lossBB}BB 損)</span>` : ''}</div>
<div class="rv-sub">相手の推定レンジに対する勝率 <b>${d.equity}%</b>${d.need != null ? ` / コールに必要な勝率 <b>${d.need}%</b> → ${d.equity >= d.need ? 'コールしてよい' : 'コールは割に合わない'}` : ''}</div>
</div></div>
<table class="rv-table rv-ev"><thead><tr><th>選択肢</th><th>期待値の目安</th></tr></thead><tbody>${rows}</tbody></table>
</div>`;
}
function stepTo(target) {
if (!replay) return;
replay.step = Math.max(-1, Math.min(replay.hand.actions.length, target));
renderStep();
}
$('view-replay').addEventListener('click', (e) => {
const b = e.target.closest('[data-step]');
if (!b || !replay) return;
const v = b.dataset.step;
const n = replay.hand.actions.length;
if (v === 'first') stepTo(-1);
else if (v === 'prev') stepTo(replay.step - 1);
else if (v === 'next') stepTo(replay.step + 1);
else if (v === 'last') stepTo(n);
else stepTo(Number(v));
});
document.addEventListener('keydown', (e) => {
if ($('view-replay').hidden || e.target.closest('input, textarea')) return;
if (e.key === 'ArrowLeft') stepTo(replay.step - 1);
if (e.key === 'ArrowRight') stepTo(replay.step + 1);
});
route();
})();
+7 -1
View File
@@ -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();
+34 -4
View File
@@ -22,9 +22,18 @@
return false;
}
// 管理者だけにハンドレンジ学習へのリンクを出す(ページ自体もサーバ側で管理者以外は 404)
let admin = false;
let tables = null;
// 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除ボタンを出す
// (ページ・API 自体もサーバ側で管理者以外は 404)
App.api('GET', '/api/me')
.then((me) => { document.getElementById('range-link').hidden = !me.admin; })
.then((me) => {
admin = Boolean(me.admin);
document.getElementById('range-link').hidden = !admin;
document.getElementById('review-link').hidden = !admin;
if (tables) render(tables);
})
.catch(() => {});
document.getElementById('create').addEventListener('click', () => {
@@ -32,11 +41,28 @@
});
listEl.addEventListener('click', (e) => {
const del = e.target.closest('[data-delete]');
if (del) return deleteTable(del.dataset.delete, del);
const btn = e.target.closest('[data-join]');
if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join);
});
const MODE_LABEL = { chips: '💰 キャッシュオンリー', friends: '🃏 友人戦', cpu: '🤖 CPU戦' };
function deleteTable(id, btn) {
const t = tables && tables.find((x) => x.id === id);
if (!t) return;
const players = t.playerCount ? `
着席中の ${t.playerCount} 人はテーブルから退出させられます。` : '';
if (!confirm(`「${t.name}」を削除しますか?${players}`)) return;
btn.disabled = true;
App.api('DELETE', '/api/tables/' + encodeURIComponent(id))
.then(() => App.toast('テーブルを削除しました'))
.catch((err) => {
btn.disabled = false;
App.toast(err.message);
});
}
const MODE_LABEL = { chips: '💰 キャッシュオンリー', friends: '🃏 友人戦', cpu: '🤖 CPU戦', review: '📝 レビュー付きCPU戦' };
function phaseBadge(t) {
if (t.paused) return '<span class="badge paused">PAUSED</span>';
@@ -44,7 +70,8 @@
return '<span class="badge live">PLAYING</span>';
}
function render(tables) {
function render(list) {
tables = list;
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
if (!tables.length) {
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
@@ -66,9 +93,12 @@
<span>${MODE_LABEL[t.mode] || ''}</span>
</div>
</div>
<div class="buttons">
${admin ? `<button class="btn btn-red btn-sm" data-delete="${esc(t.id)}" title="テーブルを削除">削除</button>` : ''}
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
${full ? '観戦' : '参加'}
</button>
</div>
</div>`;
})
.join('');
+37
View File
@@ -115,6 +115,7 @@ input.invalid { border-color: var(--red); }
.btn-orange { background: var(--orange); }
.btn-blue { background: var(--blue); }
.btn-green { background: var(--green); }
.btn-red { background: var(--red); }
.btn-dark { background: var(--panel-2); color: var(--text); }
.btn-ghost { background: transparent; color: var(--muted); border: 2px solid var(--line); }
.btn-block { width: 100%; }
@@ -193,6 +194,7 @@ input.invalid { border-color: var(--red); }
}
.table-item .meta b { color: var(--text); font-weight: 700; }
.table-item .meta .gold { color: var(--gold); }
.table-item .buttons { display: flex; gap: 8px; flex-shrink: 0; }
.badge {
display: inline-block;
@@ -258,6 +260,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 +793,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; }
+108 -6
View File
@@ -27,6 +27,8 @@
// ショーダウンの勝者選択(ポットごとの席番号の Set)
let awardKey = '';
let awardSel = [];
let seenReview = 0; // 自動で開いたレビューのハンド番号(レビュー付きCPU戦)
let reviewKey = '';
// ---------- 名前 ----------
function ensureName() {
@@ -52,7 +54,7 @@
});
socket.on('disconnect', () => { $('conn-lost').hidden = false; });
socket.on('connect_error', () => { $('conn-lost').hidden = false; });
socket.on('table:closed', () => showNotFound(true));
socket.on('table:closed', (p) => showNotFound(true, p && p.reason));
socket.on('table:state', (s) => {
state = s;
pending = false;
@@ -92,10 +94,11 @@
}
// closed: プレイヤーが全員退席してテーブルが閉じられた
function showNotFound(closed) {
function showNotFound(closed, reason) {
if (closed) {
$('not-found-title').textContent = 'テーブルが終了しました';
$('not-found-text').textContent = 'プレイヤーが全員退席したため、テーブルを閉じました。';
$('not-found-text').textContent =
reason === 'deleted' ? '管理者がテーブルを削除しました。' : 'プレイヤーが全員退席したため、テーブルを閉じました。';
}
$('main').hidden = true;
$('action-bar').hidden = true;
@@ -199,6 +202,7 @@
renderSeats(s);
if (me) renderActionBar(s, me);
renderReview(s);
renderTimer();
// 自分の番になったら知らせる(タップ前は振動できない)
@@ -335,6 +339,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 +361,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 +394,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) => `<span class="rv-mark ${MARK_CLASS[m] || ''}">${m}</span>`;
function reviewHtml(r) {
const l = r.last;
const h = l.hero;
const pre = l.preflop.length
? l.preflop
.map(
(j) => `
<div class="rv-item">${markHtml(j.mark)}<div>
<div>${esc(j.situation)}</div>
<div class="rv-sub">推奨 <b>${KIND_LABEL[j.recommended]}</b> / あなた <b>${KIND_LABEL[j.actual]}</b> — ${esc(j.label)}</div>
${j.equity != null ? `<div class="rv-sub">推定勝率 ${j.equity}%(コールに必要 ${j.need}%)</div>` : ''}
</div></div>`
)
.join('')
: '<div class="rv-sub">プリフロップで判断する場面はありませんでした</div>';
const post = l.decisions
.map(
(d) => `
<div class="rv-item">${markHtml(d.mark)}<div>
<div><b>${STREET_LABEL[d.street]}</b> ${optionLabel(d.actual)} <span class="rv-score">${d.score}点</span></div>
<div class="rv-sub">推定勝率 ${d.equity}%${d.need != null ? `(コールに必要 ${d.need}%)` : ''}${
d.lossBB > 0 ? ` ・ 最善は ${optionLabel(d.best)}(${d.lossBB}BB 損)` : ' ・ 最善の選択'
}</div>
</div></div>`
)
.join('');
const scores = STREETS.map((st) => {
const v = l.streetScores[st];
return `<div class="rv-street"><span>${STREET_SHORT[st]}</span><b>${v == null ? '—' : v}</b></div>`;
}).join('');
const a = h.allIn;
const allIn = a
? `<div class="rv-section">オールイン</div>
<div class="rv-sub">${STREET_LABEL[a.street]}でオールインした時点の勝率 <b>${a.equity}%</b> → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 ${signed(a.won - a.ev)})</div>`
: '';
return `
<div class="rv-head">
<div class="rv-cards">${h.cards.map((c) => cardHtml(c)).join('')}</div>
<div>
<div class="rv-hand"><b>${esc(h.key)}</b> <span class="rv-color cat-${esc(h.color)}">${esc(h.colorName)}</span></div>
<div class="rv-sub">${esc(h.colorLabel)} ・ ${esc(h.position)} ・ ハンド #${l.number}</div>
</div>
</div>
<div class="rv-streets">${scores}</div>
<div class="rv-section">プリフロップ(ハンドレンジ)</div>${pre}
${post ? `<div class="rv-section">フロップ以降(期待値の目安)</div>${post}` : ''}
${allIn}
<div class="rv-foot">このハンドの収支 ${signed(h.net)} ・ 平均 ${r.average == null ? '—' : r.average}点(${r.hands}ハンド)</div>`;
}
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 +707,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();
},
+2
View File
@@ -28,6 +28,8 @@
<label><input type="radio" name="mode" value="chips" checked><span>キャッシュ<br>オンリー</span></label>
<label><input type="radio" name="mode" value="friends"><span>友人戦</span></label>
<label><input type="radio" name="mode" value="cpu"><span>CPU戦</span></label>
<!-- 管理者だけに表示 -->
<label id="review-mode" hidden><input type="radio" name="mode" value="review"><span>レビュー付き<br>CPU戦</span></label>
</div>
<div class="hint" id="mode-hint"></div>
+1
View File
@@ -19,6 +19,7 @@
<button id="create" class="btn btn-orange btn-block mt-4" type="button">+ テーブルを作成</button>
<a id="range-link" class="btn btn-dark btn-block mt-8" href="/range" hidden>♠ ハンドレンジ学習</a>
<a id="review-link" class="btn btn-dark btn-block mt-8" href="/review" hidden>📝 CPU戦のレビュー</a>
<div class="section-title">
<span>開催中のテーブル</span>
+18
View File
@@ -21,6 +21,8 @@
<button class="btn btn-green btn-sm" id="start">START</button>
<button class="icon-btn" id="pause" aria-label="一時停止" title="一時停止">⏸</button>
</span>
<!-- レビュー付きCPU戦のみ -->
<button class="icon-btn" id="review-open" aria-label="直前のハンドのレビュー" title="直前のハンドのレビュー" hidden>📝</button>
<button class="icon-btn" id="share" aria-label="サイトのリンクをコピー" title="サイトのリンクをコピー">🔗</button>
</div>
@@ -92,6 +94,10 @@
<button class="btn btn-dark" id="raise-cancel">戻る</button>
<button class="btn act-raise" id="raise-confirm">Raise</button>
</div>
<div class="actions raising" id="review-actions" hidden>
<button class="btn btn-dark" id="review-show">レビュー</button>
<button class="btn btn-orange" id="review-next">次のハンドへ</button>
</div>
<div class="actions single" id="alt-actions" hidden>
<button class="btn btn-orange" id="sit-in">復帰する</button>
<button class="btn btn-blue" id="rebuy">リバイ</button>
@@ -105,6 +111,18 @@
<a class="btn btn-blue" href="/">ロビーへ戻る</a>
</div>
<div class="modal-backdrop" id="review-modal" hidden>
<div class="modal review-modal" role="dialog" aria-labelledby="review-title">
<h2 id="review-title">ハンドのレビュー</h2>
<div id="review-body"></div>
<div class="review-buttons">
<button type="button" class="btn btn-dark" id="review-close">閉じる</button>
<button type="button" class="btn btn-orange" id="review-modal-next">次のハンドへ</button>
</div>
<a class="link-gold review-link" id="review-link" href="/review" target="_blank" rel="noopener">対局全体のレビュー(点数表・成績・リプレイ)→</a>
</div>
</div>
<div class="modal-backdrop" id="name-modal" hidden>
<form class="modal" id="name-form">
<h2>名前を入力</h2>
+6 -1
View File
@@ -71,8 +71,12 @@ function isAuthed(req) {
return isAuthedCookieHeader(req.headers.cookie);
}
function isAdminCookieHeader(cookieHeader) {
return roleOf(cookieHeader) === 'admin';
}
function isAdmin(req) {
return roleOf(req.headers.cookie) === 'admin';
return isAdminCookieHeader(req.headers.cookie);
}
function setAuthCookie(req, res, role) {
@@ -232,6 +236,7 @@ module.exports = {
isAuthed,
isAdmin,
isAuthedCookieHeader,
isAdminCookieHeader,
isSameOrigin,
isAllowedHost,
requireAllowedHost,
+2
View File
@@ -73,6 +73,8 @@ module.exports = {
BOT_FAST_MS: 400,
// CPU が勝率を見積もるときのシミュレーション回数
BOT_ITERATIONS: 800,
// レビューで勝率を見積もるときのシミュレーション回数(1回の判断・選択肢ごと)
REVIEW_ITERATIONS: 1200,
// 参加者が2人揃ってから次のハンドを始めるまで
NEXT_HAND_DELAY_MS: 2000,
+37 -4
View File
@@ -52,6 +52,10 @@ class Hand {
this.pots = null; // ショーダウンのポット(同じ参加者のポットはまとめる)
this.engineWon = null; // エンジンが分配した額(席番号 → 額)
this.taken = new Map(); // ハンド中に退席した人が持ち出したチップ(エンジンから外れた分)
// アクションの記録(レビュー用)。1手ごとに、そのときの状況と行動を残す
this.log = [];
this.startStacks = new Map(order.map((s) => [s, seats[s].stack])); // ブラインドを払う前
this.allIn = null; // オールインで決着した時点の場札とポット(レビューの期待値用)
this.players = new Map();
for (const i of order) {
@@ -73,6 +77,8 @@ class Hand {
const headsUp = n === 2;
this.sbSeat = headsUp ? button : order[0];
this.bbSeat = headsUp ? order[0] : order[1];
// プリフロップに行動する順(UTG から BB まで)
this.preflopOrder = headsUp ? [button, order[0]] : order.slice(2).concat(order.slice(0, 2));
this.engine.deal();
this.sync();
}
@@ -174,7 +180,10 @@ class Hand {
}
// 手札を公開する。場札が残っていたら Table のタイマーで順にめくる(runoutStep)
this.revealed = true;
if (this.board.length < 5) this.runout = true;
if (this.board.length < 5) {
this.runout = true;
this.allIn = { board: this.board.slice(), pots: this.pots };
}
else this.finishShowdown();
}
@@ -229,7 +238,8 @@ class Hand {
}
// type: fold / check / call / raise(amount = このストリートでの合計額 "raise to")
act(seat, type, amount) {
// auto: 離席・退席などで自動で行動した(レビューでは本人の判断として扱わない)
act(seat, type, amount, auto = false) {
const o = this.options(seat);
if (!o) return 'あなたの番ではありません';
const p = this.players.get(seat);
@@ -254,6 +264,26 @@ class Hand {
}
const allIn = type !== 'fold' && type !== 'check' && last.amount === o.maxRaiseTo;
const fullRaise = type === 'raise' && amount >= this.state.minRaise;
const entry = {
street: this.street,
seat,
type: last.type,
amount: last.amount || 0, // このストリートでの合計額
added: type === 'call' ? o.toCall : type === 'raise' ? amount - p.bet : 0, // この行動で出した額
allIn,
auto,
pot: this.potTotal, // 行動する前のポット(このストリートのベットを含む)
toCall: o.toCall,
currentBet: this.currentBet,
bet: p.bet,
stack: this.seats[seat].stack,
board: this.board.slice(),
canCheck: o.canCheck,
canRaise: o.canRaise,
minRaiseTo: o.minRaiseTo,
maxRaiseTo: o.maxRaiseTo,
live: this.live().length,
};
try {
this.engine.act(this.engineAction(seat, type, amount));
} catch {
@@ -261,6 +291,7 @@ class Hand {
return type === 'fold' && o.canCheck ? 'いまはフォールドできません。チェックしてください' : 'そのアクションはできません';
}
p.last = allIn ? { type: 'ALLIN', amount: last.amount } : last;
this.log.push(entry);
this.actions++;
if (type === 'raise') {
this.aggressor = seat;
@@ -272,7 +303,7 @@ class Hand {
autoAct() {
const o = this.options(this.toAct);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0, true);
}
// 退席などで手番に関係なくフォールドさせる。オールインの人の手は最後まで生きている
@@ -280,7 +311,7 @@ class Hand {
const p = this.players.get(seat);
if (!p || p.folded || this.finished) return;
if (this.toAct === seat) {
if (this.act(seat, 'fold', 0)) this.autoAct();
if (this.act(seat, 'fold', 0, true)) this.autoAct();
return;
}
if (this.settled) {
@@ -308,6 +339,7 @@ class Hand {
this.engine.stand(String(seat));
this.taken.set(seat, (this.taken.get(seat) || 0) + before - this.state.initialChips);
p.last = { type: 'FOLD' };
this.log.push({ street: this.street, seat, type: 'FOLD', amount: 0, added: 0, auto: true, pot: this.potTotal, board: this.board.slice() });
this.sync();
}
@@ -381,6 +413,7 @@ class Hand {
}
end(showdown, results) {
this.endStacks = new Map(this.order.map((s) => [s, this.seats[s] ? this.seats[s].stack : 0])); // レビュー用
this.finished = true;
this.runout = false;
this.showdown = showdown;
+55 -10
View File
@@ -7,6 +7,8 @@ const { Server } = require('socket.io');
const config = require('./config');
const auth = require('./auth');
const { TableManager, MODES } = require('./tables');
const reviews = require('./review/store');
const { summarize } = require('./review/summary');
const PUBLIC = path.join(__dirname, '..', 'public');
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
@@ -96,12 +98,33 @@ app.use(
index: false,
dotfiles: 'deny',
// カード画像を毎回読み込まないよう、本人のブラウザにだけキャッシュさせる
setHeaders: (res) => res.setHeader('Cache-Control', 'private, max-age=86400'),
// (スクリプトと CSS は更新がすぐ反映されるよう毎回確認させる)
setHeaders: (res, file) =>
res.setHeader('Cache-Control', file.endsWith('.png') ? 'private, max-age=86400' : 'private, no-cache'),
})
);
// ---------- 管理者専用: レビュー付きCPU戦の振り返り ----------
app.get('/review', auth.requireAdmin, page('review.html', ADMIN));
app.get('/api/reviews', auth.requireAdmin, async (req, res, next) => {
try {
res.json(await reviews.list());
} catch (err) {
next(err);
}
});
app.get('/api/reviews/:id', auth.requireAdmin, async (req, res, next) => {
try {
const session = await reviews.get(req.params.id);
if (!session) return res.status(404).json({ error: '見つかりません' });
res.json({ ...session, summary: summarize(session.hands) });
} catch (err) {
next(err);
}
});
app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) }));
app.get('/api/tables', (req, res) => res.json(tables.list()));
app.get('/api/tables', (req, res) => res.json(tables.list(auth.isAdmin(req))));
app.get('/api/tables/suggest-name', (req, res) => res.json({ name: tables.suggestName() }));
const intIn = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
@@ -125,8 +148,12 @@ app.post('/api/tables', (req, res) => {
if (!intIn(smallBlind, 1, startingStack)) return res.status(400).json({ error: 'SBが不正です' });
if (!intIn(bigBlind, smallBlind + 1, startingStack)) return res.status(400).json({ error: 'BBはSBより大きく・開始スタック以下にしてください' });
if (blindTimerEnabled && !intIn(blindIntervalMin, 1, 240)) return res.status(400).json({ error: 'インターバルは1〜240分で入力してください' });
if (!MODES.includes(mode)) return res.status(400).json({ error: 'テーブルの種類が不正です' });
if (mode === 'cpu' && !intIn(cpuCount, 1, config.MAX_SEATS - 1)) {
// レビュー付きCPU戦は管理者だけが作れる
if (!MODES.includes(mode) || (mode === 'review' && !auth.isAdmin(req))) {
return res.status(400).json({ error: 'テーブルの種類が不正です' });
}
const withBots = mode === 'cpu' || mode === 'review';
if (withBots && !intIn(cpuCount, 1, config.MAX_SEATS - 1)) {
return res.status(400).json({ error: `CPU の人数は1〜${config.MAX_SEATS - 1}人にしてください` });
}
if (!hostToken) return res.status(400).json({ error: 'プレイヤーIDがありません' });
@@ -139,13 +166,22 @@ app.post('/api/tables', (req, res) => {
blindTimerEnabled,
blindIntervalMin: blindTimerEnabled ? blindIntervalMin : 10,
mode,
cpuCount: mode === 'cpu' ? cpuCount : 0,
cpuCount: withBots ? cpuCount : 0,
hostToken,
});
broadcastLobby();
res.json({ id: table.id });
});
// 管理者はどのテーブルでも削除できる(着席者・観戦者にはテーブルが閉じられたことを知らせる)
app.delete('/api/tables/:id', auth.requireAdmin, (req, res) => {
const table = tables.get(req.params.id);
if (!table) return res.status(404).json({ error: 'テーブルが見つかりません' });
console.log(`[admin] テーブル「${table.name}」を削除`);
closeTable(table, null, 'deleted');
res.json({ ok: true });
});
app.use((req, res) => res.status(404).type('text/plain').send('Not Found'));
// 内部情報(スタックトレース等)を返さない
@@ -183,8 +219,10 @@ function broadcastTable(table) {
for (const s of sockets) s.emit('table:state', table.publicState(s.data.token, connected));
}
// レビュー付きCPU戦は管理者のロビーにだけ出す
function broadcastLobby() {
io.to('lobby').emit('lobby:tables', tables.list());
io.to('lobby').emit('lobby:tables', tables.list(false));
io.to('lobby:admin').emit('lobby:tables', tables.list(true));
}
function changed(table) {
@@ -193,12 +231,13 @@ function changed(table) {
}
// テーブルを削除し、残っている観戦者に知らせる(leaver: 退席した本人。すでにロビーへ戻るので知らせない)
function closeTable(table, leaver) {
// reason: 'empty'(人間が全員退席した)/ 'deleted'(管理者が削除した)
function closeTable(table, leaver, reason = 'empty') {
tables.delete(table.id);
for (const s of roomSockets(table.id)) {
s.leave(roomOf(table.id));
if (s.data.tableId === table.id) s.data.tableId = null;
if (s !== leaver) s.emit('table:closed');
if (s !== leaver) s.emit('table:closed', { reason });
}
broadcastLobby();
}
@@ -211,6 +250,7 @@ io.use((socket, next) => {
io.on('connection', (socket) => {
const reply = (ack, payload) => typeof ack === 'function' && ack(payload);
socket.data.admin = auth.isAdminCookieHeader(socket.handshake.headers.cookie);
// イベントの連打を制限
let windowStart = Date.now();
@@ -226,8 +266,8 @@ io.on('connection', (socket) => {
});
socket.on('lobby:subscribe', () => {
socket.join('lobby');
socket.emit('lobby:tables', tables.list());
socket.join(socket.data.admin ? 'lobby:admin' : 'lobby');
socket.emit('lobby:tables', tables.list(socket.data.admin));
});
socket.on('table:join', (p, ack) => {
@@ -238,6 +278,10 @@ io.on('connection', (socket) => {
const name = cleanText(p.name, 12);
if (!token) return reply(ack, { ok: false, error: 'BAD_TOKEN' });
if (!name) return reply(ack, { ok: false, error: 'NAME_REQUIRED' });
// レビュー付きCPU戦は作成者(管理者)だけが入れる。ほかの人にはテーブルがあることも見せない
if (table.mode === 'review' && !(socket.data.admin && token === table.hostToken)) {
return reply(ack, { ok: false, error: 'NOT_FOUND' });
}
const prev = socket.data.tableId && tables.get(socket.data.tableId);
if (prev && prev !== table) {
@@ -278,6 +322,7 @@ io.on('connection', (socket) => {
'table:sitOut': (t, token) => t.setSittingOut(token, true),
'table:sitIn': (t, token) => t.setSittingOut(token, false),
'table:rebuy': (t, token) => t.rebuy(token),
'table:next': (t, token) => t.reviewNext(token),
};
for (const [event, fn] of Object.entries(actions)) {
socket.on(event, (p, ack) => {
+275
View File
@@ -0,0 +1,275 @@
'use strict';
// 1ハンドのレビュー。ハンドが終わったら hero(レビューを受ける人)について作る。
// プリフロップ: 登録してあるハンドレンジの色と、そのとおりに行動できたか(preflop.js)
// フロップ以降: 相手の推定レンジに対する勝率から、選択肢ごとの期待値(BB)を見積もり、
// 一番良い選択肢との差(失った期待値)を点数にする。GTO ではなく目安
// オールイン: 実際の手札どうしの勝率から、収支の期待値(運の影響を除いた収支)を出す
const HandRange = require('../../admin/range-data');
const { judgePreflop, positionOf, describeSituation, ACTION_KIND } = require('./preflop');
const { codes, rangeOf, narrow, rangeEquity, showdownShare } = require('./equity');
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
// レビューで色に添える説明(クイズ用の分類名がそのままでは分かりにくいもの)
const COLOR_LABEL = { purple: '1人(SBのみ)', gray: 'レンジ外' };
// 失った期待値がポット(コール後)のこの割合までは誤差として満点にする
const TOLERANCE = 0.03;
// 失った期待値がポット(コール後)の 1% 増えるごとに減らす点数
const PENALTY = 2.5;
// 相手が降りる割合: ベットが損をしない最低限のフォールド率(risk / (risk + reward))より少し低めに見て、
// 相手が多いほど全員は降りにくくする(ただのブラフが得にならないように)
const FOLD_REALISM = 0.7;
const FOLD_MULTIWAY = 0.85;
const markOf = (score) => (score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕');
const round1 = (x) => Math.round(x * 10) / 10;
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
// index より前に降りた席
function foldedBefore(log, index) {
const s = new Set();
for (let i = 0; i < index; i++) if (log[i].type === 'FOLD') s.add(log[i].seat);
return s;
}
// 相手の推定レンジ: プリフロップの色(参加しなかった BB などは全部の手)から、
// フロップ以降にベット・レイズするたびに強い手(いまの場札での役)に絞る。コールでも少し絞る
function opponentRanges(hand, hero, index, estimated, board, dead) {
const log = hand.log;
const folded = foldedBefore(log, index);
const ranges = [];
for (const s of hand.order) {
if (s === hero || folded.has(s)) continue;
let aggr = 0;
let calls = 0;
for (let i = 0; i < index; i++) {
const e = log[i];
if (e.seat !== s || e.street === 'PREFLOP') continue;
if (e.type === 'BET' || e.type === 'RAISE') aggr++;
else if (e.type === 'CALL') calls++;
}
const fraction = Math.max(0.08, 0.55 ** aggr * 0.8 ** calls);
ranges.push(narrow(rangeOf(estimated.get(s) || 0, dead), board, fraction));
}
return ranges;
}
// フロップ以降の1回の判断。選択肢ごとの期待値(いま降りたときを 0 とするチップ)
function evaluateDecision(hand, hero, index, estimated, opts) {
const e = hand.log[index];
const heroCards = codes(hand.players.get(hero).cards);
const board = codes(e.board);
const dead = heroCards.concat(board);
const ranges = opponentRanges(hand, hero, index, estimated, board, dead);
const eq = rangeEquity(heroCards, board, ranges, opts);
const P = e.pot;
const t = e.toCall;
const options = [];
if (t > 0) {
options.push({ kind: 'fold', ev: 0 });
options.push({ kind: 'call', ev: eq * (P + t) - t });
}
if (e.canCheck) options.push({ kind: 'check', ev: eq * P });
if (e.canRaise) {
const clamp = (v) => Math.min(e.maxRaiseTo, Math.max(e.minRaiseTo, Math.round(v)));
const sizes = new Set([clamp(e.currentBet === 0 ? P * 0.66 : e.currentBet * 3)]);
if (ACTION_KIND[e.type] === 'raise') sizes.add(e.amount);
for (const to of sizes) {
const r = to - e.bet; // 自分が出す額
const c = to - e.currentBet; // コールする人が出す額(直前のベットからの差)
// コールされたら、降りなかった強い側のレンジと勝負する
const alpha = r / (P + r);
const foldAll = FOLD_REALISM * alpha * FOLD_MULTIWAY ** Math.max(0, ranges.length - 1);
const eqCalled = rangeEquity(heroCards, board, ranges.map((rg) => narrow(rg, board, 1 - alpha)), opts);
options.push({ kind: 'raise', to, ev: foldAll * P + (1 - foldAll) * (eqCalled * (P + r + c) - r) });
}
}
const kind = ACTION_KIND[e.type];
const actual = options.find((o) => o.kind === kind && (kind !== 'raise' || o.to === e.amount)) || options[0];
const best = options.reduce((a, b) => (b.ev > a.ev ? b : a));
const loss = Math.max(0, best.ev - actual.ev);
const base = P + t;
const score = Math.max(0, Math.min(100, Math.round(100 - (PENALTY * 100 * Math.max(0, loss - TOLERANCE * base)) / base)));
const bb = hand.bb;
return {
index,
street: e.street,
equity: round1(eq * 100),
need: t > 0 ? round1((t / base) * 100) : null,
options: options.map((o) => ({ kind: o.kind, to: o.to, evBB: round1(o.ev / bb) })),
best: { kind: best.kind, to: best.to },
actual: { kind: actual.kind, to: actual.to },
lossBB: round1(loss / bb),
score,
mark: markOf(score),
};
}
// プリフロップでコール・レイズに直面したときの参考: 参加者の推定レンジに対する勝率と必要勝率
function preflopOdds(hand, hero, j, estimated, opts) {
const e = hand.log[j.index];
if (!(e.toCall > 0) || j.situation.kind !== 'facing') return {};
const heroCards = codes(hand.players.get(hero).cards);
const folded = foldedBefore(hand.log, j.index);
const ranges = [];
for (let i = 0; i < j.index; i++) {
const x = hand.log[i];
const k = ACTION_KIND[x.type];
if (x.street !== 'PREFLOP' || x.seat === hero || folded.has(x.seat) || (k !== 'call' && k !== 'raise')) continue;
if (ranges.some((r) => r.seat === x.seat)) continue;
ranges.push({ seat: x.seat, range: rangeOf(estimated.get(x.seat) || 0, heroCards) });
}
if (!ranges.length) return {};
const eq = rangeEquity(heroCards, [], ranges.map((r) => r.range), opts);
return { equity: round1(eq * 100), need: round1((e.toCall / (e.pot + e.toCall)) * 100) };
}
// hero の統計用の記録(VPIP・PFR・3bet・Cベットなど)
function statsOf(hand, hero) {
const mine = hand.log.filter((e) => e.seat === hero && !e.auto);
const pre = mine.filter((e) => e.street === 'PREFLOP');
const post = mine.filter((e) => e.street !== 'PREFLOP');
const me = hand.players.get(hero);
const foldedPre = hand.log.some((e) => e.seat === hero && e.street === 'PREFLOP' && e.type === 'FOLD');
const sawFlop = !foldedPre && hand.board.length >= 3;
// 3bet の機会: プリフロップでレイズがちょうど1回あった後の判断
let threeBetOpp = false;
let threeBet = false;
let raises = 0;
let lastRaiser = null;
for (const e of hand.log) {
if (e.street !== 'PREFLOP') continue;
if (e.seat === hero && !e.auto && raises === 1 && !threeBetOpp) {
threeBetOpp = true;
threeBet = ACTION_KIND[e.type] === 'raise';
}
if (ACTION_KIND[e.type] === 'raise') {
raises++;
lastRaiser = e.seat;
}
}
// Cベット: プリフロップの最後のレイザーが、フロップで誰もベットしていないときにベットしたか
const firstFlop = post.find((e) => e.street === 'FLOP');
const cbetOpp = sawFlop && lastRaiser === hero && Boolean(firstFlop) && firstFlop.currentBet === 0 && firstFlop.canRaise;
const showdown = hand.showdown && !me.folded;
return {
vpip: pre.some((e) => e.type === 'CALL' || e.type === 'BET' || e.type === 'RAISE'),
pfr: pre.some((e) => e.type === 'BET' || e.type === 'RAISE'),
threeBetOpp,
threeBet,
sawFlop,
cbetOpp,
cbet: cbetOpp && firstFlop.type === 'BET',
aggressive: post.filter((e) => e.type === 'BET' || e.type === 'RAISE').length,
calls: post.filter((e) => e.type === 'CALL').length,
showdown,
wonShowdown: showdown && me.won > 0,
};
}
// names: 席番号 → 名前、bots: CPU の席の Set
function analyzeHand(hand, hero, { names, bots, iterations = 1200, rng = Math.random } = {}) {
const opts = { iterations, rng };
const nameOf = (s) => (names && names.get(s)) || `席${s + 1}`;
const me = hand.players.get(hero);
const key = HandRange.handKey(me.cards);
const cat = HandRange.category(HandRange.categoryOf(key));
const { judgments, estimated } = judgePreflop(hand, hero);
const preflop = judgments.map((j) => ({
index: j.index,
situation: describeSituation(j.situation, nameOf),
recommended: j.recommended,
actual: j.actual,
verdict: j.verdict,
label: j.label,
mark: j.mark,
score: j.score,
...preflopOdds(hand, hero, j, estimated, opts),
}));
const decisions = [];
hand.log.forEach((e, i) => {
if (e.seat === hero && !e.auto && e.street !== 'PREFLOP') decisions.push(evaluateDecision(hand, hero, i, estimated, opts));
});
const streetScores = {};
for (const st of STREETS) {
const scores = (st === 'PREFLOP' ? preflop : decisions.filter((d) => d.street === st)).map((d) => d.score);
streetScores[st] = scores.length ? Math.round(mean(scores)) : null;
}
const scored = STREETS.map((st) => streetScores[st]).filter((x) => x != null);
// 収支と、オールインで決着したときの期待値
const net = hand.endStacks.get(hero) - hand.startStacks.get(hero);
let allIn = null;
if (hand.allIn && !me.folded) {
const hands = new Map(hand.live().map((p) => [p.seat, p.cards]));
const { ev, equity } = showdownShare(hero, hands, hand.allIn.board, hand.allIn.pots, { rng });
allIn = { street: hand.allIn.board.length === 0 ? 'PREFLOP' : hand.allIn.board.length === 3 ? 'FLOP' : 'TURN', equity: round1(equity * 100), ev: Math.round(ev), won: me.won };
}
const evNet = allIn ? Math.round(net - me.won + allIn.ev) : net;
return {
number: hand.number,
at: Date.now(),
sb: hand.sb,
bb: hand.bb,
button: hand.button,
sbSeat: hand.sbSeat,
bbSeat: hand.bbSeat,
heroSeat: hero,
players: hand.order.map((s) => {
const p = hand.players.get(s);
return {
seat: s,
name: nameOf(s),
bot: Boolean(bots && bots.has(s)),
position: positionOf(hand, s),
cards: p.cards.slice(),
start: hand.startStacks.get(s),
end: hand.endStacks.get(s),
won: p.won,
folded: p.folded,
};
}),
actions: hand.log.map((e) => ({
street: e.street,
seat: e.seat,
type: e.type,
amount: e.amount,
added: e.added,
allIn: Boolean(e.allIn),
auto: Boolean(e.auto),
pot: e.pot,
toCall: e.toCall || 0,
})),
board: hand.board.slice(),
results: hand.results,
showdown: hand.showdown,
hero: {
cards: me.cards.slice(),
key,
color: cat.id,
colorName: cat.name,
colorLabel: COLOR_LABEL[cat.id] || cat.label,
tier: cat.tier,
position: positionOf(hand, hero),
net,
evNet,
allIn,
},
preflop,
decisions,
streetScores,
handScore: scored.length ? Math.round(mean(scored)) : null,
lossBB: round1(decisions.reduce((a, d) => a + d.lossBB, 0)),
stats: statsOf(hand, hero),
};
}
module.exports = { analyzeHand, STREETS, COLOR_LABEL, markOf };
+141
View File
@@ -0,0 +1,141 @@
'use strict';
// レビュー用の勝率計算(@pokertools/evaluator の役判定を使う。役の強さのスコアは小さいほど強い)
// rangeEquity : 相手ごとの推定レンジ(手札の組み合わせの一覧)に対する勝率(モンテカルロ法)
// showdownShare: 実際の手札どうしで、残りの場札をすべて(多いときはサンプリングで)めくったときの取り分
const { evaluate, getCardCode } = require('@pokertools/evaluator');
const HandRange = require('../../admin/range-data');
const codes = (cards) => cards.map(getCardCode);
// 1326通りの手札。段階(色)ごとに使う
const COMBOS = [];
{
const ranks = 'AKQJT98765432';
const suits = 'shdc';
const deck = [];
for (const r of ranks) for (const s of suits) deck.push(r + s);
for (let i = 0; i < deck.length; i++) {
for (let j = i + 1; j < deck.length; j++) {
const cards = [deck[i], deck[j]];
COMBOS.push({ c: codes(cards), tier: HandRange.tierOf(HandRange.handKey(cards)) });
}
}
}
// minTier 以上の色の手(0 ならすべて)から、dead のカードを含むものを除く
function rangeOf(minTier, dead) {
const d = new Set(dead);
return COMBOS.filter((x) => x.tier >= minTier && !d.has(x.c[0]) && !d.has(x.c[1])).map((x) => x.c);
}
// いまの場札での役の強さの上位 fraction だけ残す(ベット・レイズされるほど強い手に絞る)
function narrow(range, board, fraction) {
if (fraction >= 1 || board.length < 3 || range.length === 0) return range;
const scored = range.map((c) => ({ c, v: evaluate(c.concat(board)) }));
scored.sort((a, b) => a.v - b.v);
const keep = Math.max(1, Math.ceil(scored.length * fraction));
return scored.slice(0, keep).map((x) => x.c);
}
// hero の手札 vs 相手ごとのレンジ。引き分けは等分。ranges が空なら 1
function rangeEquity(hero, board, ranges, { iterations = 1200, rng = Math.random } = {}) {
const live = ranges.filter((r) => r.length > 0);
if (live.length === 0) return 1;
const fixed = hero.concat(board);
let score = 0;
let n = 0;
const used = new Uint8Array(52);
for (let it = 0; it < iterations; it++) {
used.fill(0);
for (const c of fixed) used[c] = 1;
const opp = [];
let ok = true;
for (const r of live) {
let pick = null;
for (let tries = 0; tries < 30 && !pick; tries++) {
const c = r[Math.floor(rng() * r.length)];
if (!used[c[0]] && !used[c[1]]) pick = c;
}
if (!pick) {
ok = false;
break;
}
used[pick[0]] = used[pick[1]] = 1;
opp.push(pick);
}
if (!ok) continue;
const full = board.slice();
while (full.length < 5) {
const c = Math.floor(rng() * 52);
if (!used[c]) {
used[c] = 1;
full.push(c);
}
}
const me = evaluate(hero.concat(full));
let ties = 0;
let lost = false;
for (const o of opp) {
const v = evaluate(o.concat(full));
if (v < me) {
lost = true;
break;
}
if (v === me) ties++;
}
if (!lost) score += 1 / (ties + 1);
n++;
}
return n ? score / n : 0.5;
}
// 残りの場札の組み合わせ。2枚以下ならすべて、3枚以上はサンプリング
function* runouts(board, dead, samples, rng) {
const need = 5 - board.length;
const used = new Set(board.concat(dead));
const deck = [];
for (let c = 0; c < 52; c++) if (!used.has(c)) deck.push(c);
if (need === 0) {
yield board;
} else if (need === 1) {
for (const a of deck) yield board.concat([a]);
} else if (need === 2) {
for (let i = 0; i < deck.length; i++) for (let j = i + 1; j < deck.length; j++) yield board.concat([deck[i], deck[j]]);
} else {
for (let k = 0; k < samples; k++) {
for (let i = 0; i < need; i++) {
const j = i + Math.floor(rng() * (deck.length - i));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
yield board.concat(deck.slice(0, need));
}
}
}
// オールインの時点で、hero がポットごとに受け取る額の期待値と勝率
// hands: 席番号 → 手札("As" 形式)。pots: [{ amount, eligible: [席番号] }]
function showdownShare(heroSeat, hands, boardCards, pots, { samples = 4000, rng = Math.random } = {}) {
const board = codes(boardCards);
const seats = [...hands.keys()];
const hole = new Map(seats.map((s) => [s, codes(hands.get(s))]));
const dead = seats.flatMap((s) => hole.get(s));
let ev = 0;
let equity = 0; // メインポット(全員が対象)での取り分の割合
let n = 0;
for (const full of runouts(board, dead, samples, rng)) {
const score = new Map(seats.map((s) => [s, evaluate(hole.get(s).concat(full))]));
pots.forEach((pot, i) => {
if (!pot.eligible.includes(heroSeat)) return;
const best = Math.min(...pot.eligible.map((s) => score.get(s)));
if (score.get(heroSeat) !== best) return;
const share = 1 / pot.eligible.filter((s) => score.get(s) === best).length;
ev += pot.amount * share;
if (i === 0) equity += share;
});
n++;
}
return { ev: ev / n, equity: equity / n };
}
module.exports = { codes, rangeOf, narrow, rangeEquity, showdownShare };
+116
View File
@@ -0,0 +1,116 @@
'use strict';
// プリフロップの判定: 配られた手が登録してあるハンドレンジの何色で、そのとおりに行動できたか。
// 自分より前に誰もコール・レイズしていない: 後ろの人数(自分を含めず、まだ行動していない人)に対応する色
// 以上ならレイズ(オープン)、それより弱ければフォールド
// 自分より前にコール・レイズした人がいる: その人のレンジを色で推定し(その人の後ろの人数に対応する色)、
// 自分の手がその色の一つ上ならコール、二つ上ならレイズ、それより弱ければフォールド
// 2人目以降に参加した人のレンジは、コールなら直前までで一番強いレンジの一つ上、レイズなら二つ上と推定する
// (同じ決まりで打つ人がコール・レイズできる一番弱い色)。
const HandRange = require('../../admin/range-data');
const TOP_TIER = 7; // 紺
const ACTION_KIND = { FOLD: 'fold', CHECK: 'check', CALL: 'call', BET: 'raise', RAISE: 'raise' };
const ACTION_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
const VERDICTS = {
good: { mark: '◎', score: 100, label: 'レンジどおり' },
passive: { mark: '△', score: 50, label: 'レイズできる手でコール(チェック)した' },
aggressive: { mark: '△', score: 40, label: 'コールまでの手でレイズした' },
loose: { mark: '✕', score: 0, label: 'レンジ外の手で参加した' },
tight: { mark: '✕', score: 0, label: '参加できる手で降りた' },
needless: { mark: '✕', score: 0, label: 'チェックできるのに降りた' },
};
// 9人のテーブルの呼び方。UTG から CO までは人数に合わせて後ろから当てはめる
const MIDDLE_POSITIONS = ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO'];
function positionOf(hand, seat) {
const n = hand.order.length;
if (seat === hand.button) return n === 2 ? 'BTN/SB' : 'BTN';
if (seat === hand.sbSeat) return 'SB';
if (seat === hand.bbSeat) return 'BB';
const middle = hand.preflopOrder.slice(0, n - 3);
const names = MIDDLE_POSITIONS.slice(-middle.length);
return names[middle.indexOf(seat)] || '?';
}
const tierName = (tier) => (tier > TOP_TIER ? 'なし' : HandRange.categoryOfTier(Math.max(0, tier)).name);
// 推奨: 'raise' / 'call' / 'fold'(チェックできるときの 'fold' と 'call' は 'check')
function recommend(tier, situation, entry) {
let rec;
if (situation.kind === 'open') {
rec = situation.behind === 0 ? 'check' : tier >= situation.required ? 'raise' : 'fold';
} else {
const ref = situation.refTier;
if (tier >= ref + 2) rec = 'raise';
else if (tier >= ref + 1 || (tier === TOP_TIER && ref >= TOP_TIER)) rec = 'call';
else rec = 'fold';
}
if (rec === 'raise' && !entry.canRaise) rec = 'call';
if ((rec === 'call' || rec === 'fold') && entry.canCheck) rec = 'check';
return rec;
}
function verdictOf(rec, actual) {
if (rec === actual) return 'good';
if (actual === 'fold') return rec === 'check' ? 'needless' : 'tight';
if (rec === 'raise') return 'passive'; // コール・チェックした
if (rec === 'call') return 'aggressive'; // レイズした
return 'loose'; // 降りる(チェックする)べき手で参加した
}
// プリフロップの各行動を順に見て、hero の判断ごとに判定する。あわせて各席の推定レンジ(色の段階)を返す
function judgePreflop(hand, hero) {
const tier = HandRange.tierOf(HandRange.handKey(hand.players.get(hero).cards));
const order = hand.preflopOrder;
const acted = new Set();
const estimated = new Map(); // 席 → 推定レンジの一番弱い色の段階
let maxTier = null;
let maxSeat = null;
const judgments = [];
// まだ行動していない後ろの人数(自分を含めない)
const behindOf = (seat) => order.slice(order.indexOf(seat) + 1).filter((s) => !acted.has(s)).length;
hand.log.forEach((e, index) => {
if (e.street !== 'PREFLOP') return;
const kind = ACTION_KIND[e.type];
if (e.seat === hero && !e.auto) {
const situation =
maxTier == null
? { kind: 'open', behind: behindOf(hero), required: HandRange.requiredTier(behindOf(hero)) }
: { kind: 'facing', refTier: maxTier, refSeat: maxSeat };
const rec = recommend(tier, situation, e);
const verdict = verdictOf(rec, kind);
judgments.push({ index, situation, recommended: rec, actual: kind, verdict, ...VERDICTS[verdict] });
}
if (kind === 'call' || kind === 'raise') {
const est =
maxTier == null
? HandRange.requiredTier(behindOf(e.seat))
: Math.min(TOP_TIER, maxTier + (kind === 'raise' ? 2 : 1));
estimated.set(e.seat, Math.max(estimated.get(e.seat) || 0, est));
if (maxTier == null || est >= maxTier) {
maxTier = est;
maxSeat = e.seat;
}
}
acted.add(e.seat);
});
return { tier, judgments, estimated };
}
// 状況の説明(画面表示用)
function describeSituation(situation, nameOf) {
if (situation.kind === 'open') {
if (situation.behind === 0) return '全員が降りてBBに回ってきた';
return `前に参加者なし・後ろに${situation.behind}人 → ${tierName(situation.required)}以上でオープン`;
}
const ref = situation.refTier;
return `${nameOf(situation.refSeat)} の推定レンジ ${tierName(ref)} → ${tierName(ref + 1)}以上でコール、${tierName(ref + 2)}以上でレイズ`;
}
module.exports = { judgePreflop, positionOf, describeSituation, tierName, ACTION_KIND, ACTION_LABEL, VERDICTS };
+128
View File
@@ -0,0 +1,128 @@
'use strict';
// レビューの保存先(data/reviews/<id>.json)。テーブルを閉じた後も見返せるようにファイルに残す。
// 対局中のものはメモリにも置き、ハンドが終わるたびに書き出す
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DIR = process.env.REVIEW_DIR || path.join(__dirname, '..', '..', 'data', 'reviews');
const MAX_SESSIONS = 50; // これより古い対局は消す
const MAX_HANDS = 1000; // 1回の対局で保存するハンド数の上限
const active = new Map(); // id → 対局(メモリ上)
const writing = new Map(); // id → 書き出し中の Promise(順番に書く)
const validId = (id) => typeof id === 'string' && /^[0-9a-f]{16}$/.test(id);
const fileOf = (id) => path.join(DIR, `${id}.json`);
function create({ tableName, heroName, startingStack }) {
const session = {
id: crypto.randomBytes(8).toString('hex'),
tableName,
heroName,
startingStack,
createdAt: Date.now(),
updatedAt: Date.now(),
hands: [],
};
active.set(session.id, session);
return session;
}
function addHand(session, record) {
if (session.hands.length >= MAX_HANDS) return;
session.hands.push(record);
session.updatedAt = Date.now();
save(session);
}
function save(session) {
const prev = writing.get(session.id) || Promise.resolve();
const next = prev
.then(async () => {
await fs.promises.mkdir(DIR, { recursive: true, mode: 0o700 });
const tmp = `${fileOf(session.id)}.tmp`;
await fs.promises.writeFile(tmp, JSON.stringify(session), { mode: 0o600 });
await fs.promises.rename(tmp, fileOf(session.id));
await prune();
})
.catch((err) => console.error('[review] 保存に失敗しました', err.message));
writing.set(session.id, next);
next.then(() => {
if (writing.get(session.id) === next) writing.delete(session.id);
});
return next;
}
// テーブルが閉じられたらメモリから外す(ファイルには残る)
function close(session) {
active.delete(session.id);
}
async function readAll() {
let files;
try {
files = await fs.promises.readdir(DIR);
} catch {
return [];
}
const out = [];
for (const f of files) {
const id = f.replace(/\.json$/, '');
if (!f.endsWith('.json') || !validId(id)) continue;
const s = await get(id);
if (s) out.push(s);
}
return out;
}
// 古い対局のファイルを消す(更新日時で判断する。中身は読まない)
async function prune() {
let files;
try {
files = (await fs.promises.readdir(DIR)).filter((f) => f.endsWith('.json'));
} catch {
return;
}
if (files.length <= MAX_SESSIONS) return;
const stats = [];
for (const f of files) {
const st = await fs.promises.stat(path.join(DIR, f)).catch(() => null);
if (st) stats.push({ f, mtime: st.mtimeMs });
}
stats.sort((a, b) => b.mtime - a.mtime);
for (const { f } of stats.slice(MAX_SESSIONS)) {
if (active.has(f.replace(/\.json$/, ''))) continue;
await fs.promises.unlink(path.join(DIR, f)).catch(() => {});
}
}
async function get(id) {
if (!validId(id)) return null;
if (active.has(id)) return active.get(id);
try {
return JSON.parse(await fs.promises.readFile(fileOf(id), 'utf8'));
} catch {
return null;
}
}
// 一覧(新しい順)。中身のハンドは含めない
async function list() {
const all = await readAll();
for (const s of active.values()) if (!all.some((x) => x.id === s.id)) all.push(s);
return all
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((s) => ({
id: s.id,
tableName: s.tableName,
heroName: s.heroName,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
hands: s.hands.length,
active: active.has(s.id),
}));
}
module.exports = { create, addHand, close, get, list, validId, DIR };
+132
View File
@@ -0,0 +1,132 @@
'use strict';
// 1回の対局(レビュー付きCPU戦のテーブル1つ)のハンドのレビューをまとめる
// 成績(VPIP・PFR・3bet・Cベット・WTSD・W$SD・AF)、ストリートごとの平均点、
// 色ごとの参加率とレンジどおりに打てた割合、ポジションごとの傾向とクセ、見直したいハンド、収支の推移
const HandRange = require('../../admin/range-data');
const { STREETS, COLOR_LABEL } = require('./analyze');
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
const mean = (xs) => (xs.length ? Math.round(xs.reduce((x, y) => x + y, 0) / xs.length) : null);
const POSITION_ORDER = ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO', 'BTN', 'BTN/SB', 'SB', 'BB'];
const STREET_LABEL = { FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
// クセとして数える判定と、その説明
const LEAK_TEXT = {
loose: 'レンジ外の手で参加',
tight: '参加できる手で降りた',
passive: 'レイズできる手でコール',
aggressive: 'コールまでの手でレイズ',
needless: 'チェックできるのに降りた',
};
function summarize(hands) {
const n = hands.length;
const count = (f) => hands.filter(f).length;
const stat = (key) => count((h) => h.stats[key]);
const sawFlop = stat('sawFlop');
const showdowns = stat('showdown');
const aggressive = hands.reduce((a, h) => a + h.stats.aggressive, 0);
const calls = hands.reduce((a, h) => a + h.stats.calls, 0);
const stats = {
hands: n,
vpip: pct(stat('vpip'), n),
pfr: pct(stat('pfr'), n),
threeBet: pct(stat('threeBet'), stat('threeBetOpp')),
threeBetOpp: stat('threeBetOpp'),
cbet: pct(stat('cbet'), stat('cbetOpp')),
cbetOpp: stat('cbetOpp'),
wtsd: pct(showdowns, sawFlop),
wsd: pct(stat('wonShowdown'), showdowns),
af: calls ? Math.round((aggressive / calls) * 100) / 100 : aggressive ? null : 0,
};
const streets = {};
for (const st of STREETS) streets[st] = mean(hands.map((h) => h.streetScores[st]).filter((x) => x != null));
const total = mean(hands.map((h) => h.handScore).filter((x) => x != null));
// 色ごと: 配られた回数・参加した回数・プリフロップの判断がレンジどおりだった割合
const colors = HandRange.CATEGORIES.map((c) => {
const hs = hands.filter((h) => h.hero.color === c.id);
const judged = hs.flatMap((h) => h.preflop);
return {
id: c.id,
name: c.name,
label: COLOR_LABEL[c.id] || c.label,
dealt: hs.length,
played: hs.filter((h) => h.stats.vpip).length,
judged: judged.length,
good: judged.filter((j) => j.verdict === 'good').length,
};
});
// ポジションごと
const byPos = new Map();
for (const h of hands) {
const pos = h.hero.position;
if (!byPos.has(pos)) byPos.set(pos, { position: pos, hands: 0, played: 0, judged: 0, good: 0, verdicts: {} });
const p = byPos.get(pos);
p.hands++;
if (h.stats.vpip) p.played++;
for (const j of h.preflop) {
p.judged++;
if (j.verdict === 'good') p.good++;
else p.verdicts[j.verdict] = (p.verdicts[j.verdict] || 0) + 1;
}
}
const positions = [...byPos.values()].sort((a, b) => POSITION_ORDER.indexOf(a.position) - POSITION_ORDER.indexOf(b.position));
// クセ: ポジション × 間違いの種類で多いもの。フロップ以降は失った期待値が大きいストリート × 行動
const leaks = [];
for (const p of positions) {
for (const [v, k] of Object.entries(p.verdicts)) leaks.push({ text: `${p.position}: ${LEAK_TEXT[v]}`, count: k, of: p.judged });
}
const postLeaks = new Map();
for (const h of hands) {
for (const d of h.decisions) {
if (d.score >= 70) continue;
const key = `${d.street}:${d.actual.kind}`;
if (!postLeaks.has(key)) postLeaks.set(key, { street: d.street, kind: d.actual.kind, count: 0, lossBB: 0 });
const x = postLeaks.get(key);
x.count++;
x.lossBB += d.lossBB;
}
}
const KIND = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'ベット・レイズ' };
for (const x of postLeaks.values()) {
leaks.push({ text: `${STREET_LABEL[x.street]}: ${KIND[x.kind]}で期待値を失った(合計 ${Math.round(x.lossBB * 10) / 10}BB)`, count: x.count });
}
leaks.sort((a, b) => b.count - a.count);
// 見直したいハンド: 失った期待値(BB)が大きい順、同じならプリフロップの判定が悪い順
const worst = hands
.filter((h) => h.lossBB > 0 || (h.handScore != null && h.handScore < 100))
.map((h) => ({
number: h.number,
key: h.hero.key,
color: h.hero.color,
position: h.hero.position,
lossBB: h.lossBB,
handScore: h.handScore,
net: h.hero.net,
bb: h.bb,
}))
.sort((a, b) => b.lossBB - a.lossBB || a.handScore - b.handScore)
.slice(0, 5);
// 収支の推移(実際の収支と、オールインの期待値で運を除いた収支)
let net = 0;
let evNet = 0;
const graph = hands.map((h) => {
net += h.hero.net;
evNet += h.hero.evNet;
return { number: h.number, net, evNet };
});
return { stats, streets, total, colors, positions, leaks: leaks.slice(0, 6), worst, graph };
}
module.exports = { summarize };
+90 -4
View File
@@ -5,12 +5,16 @@ const config = require('./config');
const Blinds = require('../public/assets/blinds');
const { Hand } = require('./hand');
const ai = require('./ai');
const { analyzeHand } = require('./review/analyze');
const reviews = require('./review/store');
// テーブルの種類
// chips : キャッシュオンリー。カードは実物で配り、サイトはチップだけを管理する
// friends: 友人戦。サーバがカードを配り、ショーダウンも自動で判定する
// cpu : CPU戦。友人戦に CPU プレイヤーが加わる
const MODES = ['chips', 'friends', 'cpu'];
// review : レビュー付きCPU戦(管理者専用)。作成者と CPU だけで遊び、ハンドごとにレビューを作る。
// レビューを見てから次のハンドに進めるよう、ハンドが終わると作成者が「次のハンドへ」を押すまで待つ
const MODES = ['chips', 'friends', 'cpu', 'review'];
const CPU_NAMES = ['ハル', 'ソラ', 'リク', 'ミオ', 'レン', 'ユイ', 'カイ', 'ナギ'];
class Table {
@@ -33,7 +37,10 @@ class Table {
// bot : CPU プレイヤー(style は思考の種類と性格。ブラウザには渡さない)
this.seats = new Array(this.maxSeats).fill(null);
this.botTurn = null; // { key, at }: CPU が行動する時刻
if (this.mode === 'cpu') this.seatBots(opts.cpuCount);
this.withBots = this.mode === 'cpu' || this.mode === 'review';
if (this.withBots) this.seatBots(opts.cpuCount);
// レビュー付きCPU戦: session = 保存する対局、last = 直前のハンドのレビュー、waiting = 次のハンドへ進むのを待っている
this.review = this.mode === 'review' ? { session: null, last: null, waiting: false } : null;
this.hand = null;
this.started = false;
this.paused = false;
@@ -127,6 +134,7 @@ class Table {
}
return existing;
}
if (this.mode === 'review' && token !== this.hostToken) return -1; // 作成者と CPU だけのテーブル
const free = this.seats.findIndex((s) => !s);
if (free < 0) return -1;
this.seats[free] = { token, name, stack: this.startingStack, buyIns: 1, sittingOut: false, left: false };
@@ -155,7 +163,7 @@ class Table {
// CPU戦で人間が全員退席したら、テーブルを残しておく意味がない
shouldClose() {
return this.mode === 'cpu' && !this.seats.some((s) => s && !s.left && !s.bot);
return this.withBots && !this.seats.some((s) => s && !s.left && !s.bot);
}
removeLeftSeats() {
@@ -189,6 +197,7 @@ class Table {
order.push(i);
}
this.handNumber++;
if (this.review) this.review.waiting = false;
const { sb, bb } = this.blinds;
this.nextHandAt = null;
this.runoutAt = null;
@@ -331,6 +340,12 @@ class Table {
}
}
if (this.hand && this.hand.finished && this.review && !this.hand.reviewed) {
this.reviewHand();
changed = true;
}
if (this.hand && this.hand.finished && this.review && this.review.waiting) return changed;
if (this.hand && this.hand.finished) {
if (this.nextHandAt == null) {
const showdownMs = this.cardsEnabled ? config.CARD_SHOWDOWN_MS : config.SHOWDOWN_MS;
@@ -355,6 +370,72 @@ class Table {
return changed;
}
// ---- レビュー付きCPU戦 ----
// 終わったハンドのレビューを作って保存し、作成者が見終わるまで次のハンドを待つ
reviewHand() {
const h = this.hand;
h.reviewed = true;
const hero = this.seatOf(this.hostToken);
if (hero < 0 || !h.players.has(hero)) return;
let record;
try {
record = analyzeHand(h, hero, {
names: new Map(h.order.map((s) => [s, this.seats[s] ? this.seats[s].name : '退席者'])),
bots: new Set(h.order.filter((s) => this.seats[s] && this.seats[s].bot)),
iterations: config.REVIEW_ITERATIONS,
});
} catch (err) {
console.error('[review] レビューを作れませんでした', err);
return;
}
if (!this.review.session) {
this.review.session = reviews.create({ tableName: this.name, heroName: this.seats[hero].name, startingStack: this.startingStack });
}
reviews.addHand(this.review.session, record);
this.review.last = record;
this.review.waiting = !this.seats[hero].left;
}
reviewNext(token) {
if (!this.review) return 'レビュー付きのテーブルではありません';
if (!this.isHost(token)) return '操作できません';
this.review.waiting = false;
return null;
}
// 作成者に送るレビュー(直前のハンドの要約)
reviewView() {
const r = this.review;
const last = r.last;
const hands = r.session ? r.session.hands : [];
const scores = hands.map((x) => x.handScore).filter((x) => x != null);
return {
sessionId: r.session ? r.session.id : null,
waiting: r.waiting,
hands: hands.length,
average: scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : null,
last: last && {
number: last.number,
bb: last.bb,
hero: last.hero,
preflop: last.preflop,
decisions: last.decisions.map((d) => ({
street: d.street,
equity: d.equity,
need: d.need,
actual: d.actual,
best: d.best,
lossBB: d.lossBB,
score: d.score,
mark: d.mark,
})),
streetScores: last.streetScores,
handScore: last.handScore,
},
};
}
summary() {
const { sb, bb } = this.blinds;
return {
@@ -438,6 +519,7 @@ class Table {
}
: null,
isHost: this.isHost(token),
review: this.review && this.isHost(token) ? this.reviewView() : null,
serverNow: now,
};
}
@@ -452,8 +534,10 @@ class TableManager {
return this.tables.get(id);
}
list() {
// レビュー付きCPU戦は管理者にだけ見せる
list(admin = false) {
return [...this.tables.values()]
.filter((t) => admin || t.mode !== 'review')
.sort((a, b) => b.createdAt - a.createdAt)
.map((t) => t.summary());
}
@@ -480,6 +564,8 @@ class TableManager {
}
delete(id) {
const t = this.tables.get(id);
if (t && t.review && t.review.session) reviews.close(t.review.session);
this.tables.delete(id);
}
}
+256
View File
@@ -0,0 +1,256 @@
'use strict';
// node --test で実行(ACCESS_KEY はテスト用のダミー、レビューの保存先は一時ディレクトリ)
const fs = require('fs');
const os = require('os');
const path = require('path');
process.env.ACCESS_KEY = process.env.ACCESS_KEY || '000000';
process.env.REVIEW_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-review-'));
const test = require('node:test');
const assert = require('node:assert/strict');
const HandRange = require('../admin/range-data');
const { Table } = require('../server/tables');
const { judgePreflop, positionOf } = require('../server/review/preflop');
const { analyzeHand } = require('../server/review/analyze');
const { showdownShare } = require('../server/review/equity');
const { summarize } = require('../server/review/summary');
const reviews = require('../server/review/store');
const T = (n) => 't'.repeat(31) + n; // 32文字のトークン
// n 人の友人戦(カードあり)。席0から順に座る
function makeTable(n) {
const t = new Table({
id: 'x',
name: 'test',
startingStack: 1000,
smallBlind: 5,
bigBlind: 10,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
mode: 'friends',
});
for (let i = 0; i < n; i++) t.join(T(i), 'p' + i);
t.start(T(0), 0);
return t;
}
const act = (t, type, amount = 0) => {
const seat = t.hand.toAct;
assert.equal(t.act(t.seats[seat].token, type, amount), null);
return seat;
};
// 手札を差し替える(判定は手札の色だけを見る。エンジンの状態を写すたびに戻るので、行動の後に差し替える)
const give = (t, seat, cards) => {
t.hand.players.get(seat).cards = cards;
};
test('ハンドレンジ: 手札の表記と、後ろの人数で必要な色', () => {
assert.equal(HandRange.handKey(['Kd', 'As']), 'AKo');
assert.equal(HandRange.handKey(['5h', '4h']), '54s');
assert.equal(HandRange.handKey(['9c', '9d']), '99');
assert.equal(HandRange.categoryOf('AA'), 'navy');
assert.equal(HandRange.categoryOf('72o'), 'gray');
assert.equal(HandRange.tierOf('Q5s'), 1); // 紫枠は白の一つ下
const req = [0, 1, 2, 3, 4, 5, 6, 7, 8].map((b) => HandRange.categoryOfTier(HandRange.requiredTier(b)).id);
assert.deepEqual(req, ['gray', 'purple', 'white', 'blue', 'green', 'green', 'yellow', 'yellow', 'red']);
});
test('ポジションの呼び方', () => {
const t = makeTable(9);
const h = t.hand;
const names = h.preflopOrder.map((s) => positionOf(h, s));
assert.deepEqual(names, ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO', 'BTN', 'SB', 'BB']);
const hu = makeTable(2).hand;
assert.deepEqual(hu.preflopOrder.map((s) => positionOf(hu, s)), ['BTN/SB', 'BB']);
});
test('プリフロップ: 前に参加者がいなければ、後ろの人数の色以上でオープン', () => {
// 6人: UTG〜CO が降りて BTN(後ろに2人 → 白以上)
for (const [cards, action, verdict] of [
[['Kh', '8h'], 'raise', 'good'], // 白でレイズ
[['Qh', '5h'], 'raise', 'loose'], // 紫枠は BTN ではオープンできない
[['Qh', '5h'], 'fold', 'good'],
[['Ah', 'Kd'], 'call', 'passive'], // レイズできる手でコール
[['Ah', 'Kd'], 'fold', 'tight'],
]) {
const t = makeTable(6);
for (let k = 0; k < 3; k++) act(t, 'fold');
const hero = t.hand.toAct;
assert.equal(hero, t.hand.button);
act(t, action, action === 'raise' ? 25 : 0);
give(t, hero, cards);
const j = judgePreflop(t.hand, hero).judgments[0];
assert.equal(j.situation.kind, 'open');
assert.equal(j.situation.behind, 2);
assert.equal(j.verdict, verdict, `${cards} ${action}`);
}
// SB に全員降りて回ってきた(後ろに1人 → 紫枠以上)
const t = makeTable(4);
act(t, 'fold');
act(t, 'fold');
const sb = t.hand.toAct;
act(t, 'raise', 30);
give(t, sb, ['Qs', '4s']);
const j = judgePreflop(t.hand, sb).judgments[0];
assert.equal(j.situation.behind, 1);
assert.equal(j.verdict, 'good');
});
test('プリフロップ: 前に参加者がいたら、その人の色の一つ上でコール、二つ上でレイズ', () => {
// 6人: UTG(後ろに5人 → 緑と推定)がレイズ。次の人(hero)の判定
for (const [cards, action, verdict] of [
[['Kh', 'Qd'], 'call', 'good'], // 黄 = 緑の一つ上 → コール
[['Kh', 'Qd'], 'raise', 'aggressive'],
[['Jh', 'Jd'], 'raise', 'good'], // 赤 = 二つ上 → レイズ
[['Jh', 'Jd'], 'call', 'passive'],
[['Kh', 'Jd'], 'call', 'loose'], // 緑 = 同じ → フォールド
[['Kh', 'Jd'], 'fold', 'good'],
]) {
const t = makeTable(6);
const utg = act(t, 'raise', 30);
const hero = t.hand.toAct;
act(t, action, action === 'raise' ? 90 : 0);
give(t, hero, cards);
const { judgments, estimated } = judgePreflop(t.hand, hero);
assert.equal(estimated.get(utg), 4); // 緑
assert.equal(judgments[0].situation.refTier, 4);
assert.equal(judgments[0].verdict, verdict, `${cards} ${action}`);
}
// BB: リンプ(コール)に対してはチェックでよい。二つ上ならレイズ
const t = makeTable(4);
act(t, 'call'); // UTG(後ろに3人 → 青)
act(t, 'fold');
act(t, 'call'); // SB: 青の一つ上と推定
const bb = t.hand.toAct;
act(t, 'check');
give(t, bb, ['7h', '2d']);
const j = judgePreflop(t.hand, bb).judgments[0];
assert.equal(j.recommended, 'check');
assert.equal(j.verdict, 'good');
});
test('オールインの期待値: AA 対 KK はおよそ 82%', () => {
const hands = new Map([
[0, ['As', 'Ad']],
[1, ['Ks', 'Kd']],
]);
const r = showdownShare(0, hands, [], [{ amount: 200, eligible: [0, 1] }], { samples: 6000 });
assert.ok(r.equity > 0.79 && r.equity < 0.85, String(r.equity));
assert.ok(Math.abs(r.ev - 200 * r.equity) < 1e-6);
// ターンまで出ていれば残り1枚を全部数える
const turn = showdownShare(0, hands, ['2c', '7h', '9d', 'Kh'], [{ amount: 100, eligible: [0, 1] }]);
assert.equal(Math.round(turn.equity * 44), 2); // KK のセットに勝てるのは残り44枚のうち Ac・Ah の2枚だけ
});
test('ハンドのレビュー: 点数は 0〜100、ストリートごとに付き、収支が合う', () => {
for (let round = 0; round < 15; round++) {
const t = makeTable(4);
const h = t.hand;
for (let g = 0; g < 100 && !h.finished; g++) {
if (h.toAct == null) {
h.runoutStep();
continue;
}
const o = h.options(h.toAct);
const r = Math.random();
if (o.canRaise && r < 0.2) act(t, 'raise', o.minRaiseTo);
else if (!o.canCheck && r < 0.35) act(t, 'fold');
else act(t, o.canCheck ? 'check' : 'call');
}
assert.ok(h.finished);
const rec = analyzeHand(h, 0, { iterations: 200 });
const scores = rec.preflop.map((x) => x.score).concat(rec.decisions.map((x) => x.score));
for (const s of scores) assert.ok(s >= 0 && s <= 100);
for (const st of ['PREFLOP', 'FLOP', 'TURN', 'RIVER']) {
const has = rec.decisions.some((d) => d.street === st) || (st === 'PREFLOP' && rec.preflop.length > 0);
assert.equal(rec.streetScores[st] != null, has);
}
assert.equal(rec.hero.net, t.seats[0].stack - 1000);
if (!rec.hero.allIn) assert.equal(rec.hero.evNet, rec.hero.net);
assert.equal(rec.actions.length, h.log.length);
}
});
test('まとめ: 成績・色ごと・見直したいハンド・収支の推移', () => {
const hands = [];
for (let round = 0; round < 10; round++) {
const t = makeTable(3);
const h = t.hand;
for (let g = 0; g < 100 && !h.finished; g++) {
if (h.toAct == null) {
h.runoutStep();
continue;
}
const o = h.options(h.toAct);
act(t, o.canCheck ? 'check' : 'call');
}
hands.push(analyzeHand(h, 0, { iterations: 100 }));
}
const s = summarize(hands);
assert.equal(s.stats.hands, 10);
assert.equal(s.graph.length, 10);
assert.equal(s.graph[9].net, hands.reduce((a, h) => a + h.hero.net, 0));
assert.equal(s.colors.reduce((a, c) => a + c.dealt, 0), 10);
assert.ok(s.worst.length <= 5);
assert.ok(s.stats.vpip >= 0 && s.stats.vpip <= 100);
assert.equal(s.stats.pfr, 0); // 誰もレイズしていない
});
test('レビュー付きCPU戦: 作成者だけが座れ、ハンドごとにレビューを作って次のハンドを待つ', async () => {
const t = new Table({
id: 'r',
name: 'review',
startingStack: 1000,
smallBlind: 10,
bigBlind: 20,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
mode: 'review',
cpuCount: 3,
});
assert.equal(t.cardsEnabled, true);
assert.equal(t.join(T(0), 'me'), 0);
assert.equal(t.join(T(5), 'friend'), -1); // 友達は入れない
assert.equal(t.start(T(0), 0), null);
let now = 0;
for (let k = 0; k < 400 && !t.hand.finished; k++) {
if (t.hand.toAct === 0) {
const o = t.hand.options(0);
t.act(T(0), o.canCheck ? 'check' : 'fold', 0);
}
t.tick((now += 5000));
}
assert.ok(t.hand.finished);
t.tick((now += 5000));
const v = t.publicState(T(0), new Set([T(0)]));
assert.ok(v.review && v.review.last && v.review.waiting);
assert.equal(v.review.hands, 1);
assert.equal(t.publicState(T(5), new Set()).review, null); // 作成者以外には送らない
// 押すまで次のハンドは始まらない
const number = t.hand.number;
for (let k = 0; k < 10; k++) t.tick((now += 60000));
assert.equal(t.hand.number, number);
assert.equal(t.reviewNext(T(5)), '操作できません');
assert.equal(t.reviewNext(T(0)), null);
for (let k = 0; k < 10 && t.hand.number === number; k++) t.tick((now += 5000));
assert.equal(t.hand.number, number + 1);
// ファイルに保存され、一覧と中身を読める
const session = t.review.session;
await new Promise((r) => setTimeout(r, 200));
const list = await reviews.list();
assert.ok(list.some((s) => s.id === session.id && s.hands === 1));
reviews.close(session);
const saved = await reviews.get(session.id);
assert.equal(saved.hands.length, 1);
assert.equal(await reviews.get('../etc/passwd'), null);
});