Compare commits
2
Commits
3116b1da9f
...
0318ee2b5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0318ee2b5e | ||
|
|
3f2515a9fa |
No files matched your search
@@ -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 に保存。リロードしても同じ席に戻れる。
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
|
||||
+37
-7
@@ -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>
|
||||
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
|
||||
${full ? '観戦' : '参加'}
|
||||
</button>
|
||||
<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('');
|
||||
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user