// 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 CHEVRON = ''; const SPARKLES = ''; Fluid.navBar($('nav'), $('title')); const tabsSeg = Fluid.Segmented($('tabs')); const markOf = (score) => (score == null ? '' : score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕'); const scoreClass = (score) => (score == null ? 'none' : MARK_CLASS[markOf(score)]); const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '±') + chips(Math.abs(n)); const signClass = (n) => (n > 0 ? 'plus' : n < 0 ? 'minus' : ''); const pctText = (v) => (v == null ? '—' : `${v}%`); const dateText = (t) => new Date(t).toLocaleString('ja-JP', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); const optionLabel = (o) => (o.kind === 'raise' ? `ベット/レイズ ${chips(o.to)}` : KIND_LABEL[o.kind]); const colorChip = (h) => `${esc(h.colorName)}`; function cardHtml(c, extra = '') { if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return ``; const rank = c[0] === 'T' ? '10' : c[0]; return `${rank}${SUIT_SYMBOL[c[1]]}`; } // ---------- 画面の切り替え(URL: ?s=対局ID&h=ハンド番号&t=テーブルID) ---------- function show(view) { for (const v of ['list', 'session', 'replay']) $(`view-${v}`).hidden = v !== view; window.scrollTo(0, 0); } // テーブルのレビューから開いたときのテーブルID。どの画面に移っても引き継ぎ、対局の画面からテーブルへ戻れるようにする function fromTable() { const t = new URLSearchParams(location.search).get('t'); return t && /^[0-9a-f]{8}$/.test(t) ? t : null; } function setBack(href, label) { $('back').href = href; $('back-label').textContent = label; } function go(params, push = true) { const table = fromTable(); const q = new URLSearchParams(table ? { ...params, t: table } : 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')); const table = fromTable(); if (!id) { $('title').textContent = 'CPU戦のレビュー'; if (table) setBack(`/table/${table}`, 'テーブル'); else setBack('/', 'ロビー'); 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) { setBack(`?s=${encodeURIComponent(id)}`, '対局'); return openReplay(h); } } if (table) setBack(`/table/${table}`, 'テーブル'); else setBack('/review', '一覧'); show('session'); renderSession(); } window.addEventListener('popstate', route); // 戻るボタンは履歴を積まずに一つ上へ(ロビー・テーブルへはそのままページを移る) $('back').addEventListener('click', (e) => { const href = $('back').getAttribute('href'); if (href === '/' || href.startsWith('/table/')) return; e.preventDefault(); const p = new URLSearchParams(location.search); go(p.get('h') ? { s: p.get('s') } : {}); }); // ---------- 対局の一覧 ---------- async function loadList() { let list; try { list = await api('GET', '/api/reviews'); } catch (err) { $('sessions').innerHTML = `
${esc(err.message)}
`; return; } if (!list.length) { $('sessions').innerHTML = '
まだ対局がありません。
ロビーの「テーブルを作成」で「レビュー付きCPU戦」を選んでください。
'; return; } $('sessions').innerHTML = list .map( (s) => ` ` ) .join(''); } $('sessions').addEventListener('click', (e) => { const b = e.target.closest('[data-id]'); if (b) go({ s: b.dataset.id }); }); // ---------- 1回の対局 ---------- function renderSession() { const s = session.summary; const net = s.graph.length ? s.graph[s.graph.length - 1].net : 0; $('overview').innerHTML = `
ハンド数${s.stats.hands}
平均点${s.total == null ? '—' : s.total}
収支${signed(net)}
`; for (const b of $('tabs').querySelectorAll('[data-tab]')) b.classList.toggle('active', b.dataset.tab === tab); tabsSeg.update(); const render = { scores: renderScores, stats: renderStats, range: renderRange, worst: renderWorst, graph: renderGraph }[tab]; $('tab-body').innerHTML = ''; render(); } $('tabs').addEventListener('click', (e) => { const b = e.target.closest('[data-tab]'); if (!b) return; tab = b.dataset.tab; renderSession(); }); function scoreCell(v) { return `${v == null ? '—' : v}`; } // 点数表: ハンドごと・ストリートごとの点数 function renderScores() { const hands = session.hands; if (!hands.length) { $('tab-body').innerHTML = '
まだハンドがありません。
'; return; } const rows = hands .slice() .reverse() .map( (h) => ` ${h.number} ${esc(h.hero.key)} ${colorChip(h.hero)} ${esc(h.hero.position)} ${STREETS.map((st) => scoreCell(h.streetScores[st])).join('')} ${scoreCell(h.handScore)} ${signed(h.hero.net)} ` ) .join(''); const avg = session.summary.streets; $('tab-body').innerHTML = `

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

${STREETS.map((st) => ``).join('')}${rows}${STREETS.map((st) => scoreCell(avg[st])).join('')}${scoreCell(session.summary.total)}
#ハンド位置${STREET_SHORT[st]}平均収支
平均
`; } $('tab-body').addEventListener('click', (e) => { const r = e.target.closest('[data-hand]'); if (r) go({ s: session.id, h: r.dataset.hand }); }); $('tab-body').addEventListener('keydown', (e) => { const r = e.target.closest('[data-hand]'); if (r && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); go({ s: session.id, h: r.dataset.hand }); } }); // 成績 function renderStats() { const st = session.summary.stats; const tiles = [ ['VPIP', pctText(st.vpip), '自分から参加した割合'], ['PFR', pctText(st.pfr), 'プリフロップでレイズした割合'], ['3bet', pctText(st.threeBet), `レイズに対してレイズし返した割合(機会 ${st.threeBetOpp}回)`], ['Cベット', pctText(st.cbet), `プリフロップのレイザーがフロップでベットした割合(機会 ${st.cbetOpp}回)`], ['WTSD', pctText(st.wtsd), 'フロップを見てショーダウンまで行った割合'], ['W$SD', pctText(st.wsd), 'ショーダウンで勝った割合'], ['AF', st.af == null ? '∞' : st.af, 'フロップ以降の (ベット+レイズ) ÷ コール'], ]; const avg = session.summary.streets; $('tab-body').innerHTML = `
${tiles.map(([k, v, d]) => `
${k}${v}${d}
`).join('')}
ストリートごとの平均点
${STREETS.map((s) => `
${STREET_SHORT[s]}${avg[s] == null ? '—' : avg[s]}
`).join('')}

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

`; } // 色・ポジション・クセ function renderRange() { const s = session.summary; const colorRows = s.colors .map( (c) => ` ${esc(c.name)} ${esc(c.label)} ${c.dealt} ${c.dealt ? pctText(Math.round((c.played / c.dealt) * 1000) / 10) : '—'} ${c.judged ? pctText(Math.round((c.good / c.judged) * 1000) / 10) : '—'} ` ) .join(''); const VERDICT = { loose: '参加しすぎ', tight: '降りすぎ', passive: '消極的', aggressive: '攻めすぎ', needless: '不要なフォールド' }; const posRows = s.positions .map((p) => { const mistakes = Object.entries(p.verdicts) .sort((a, b) => b[1] - a[1]) .map(([v, n]) => `${VERDICT[v]} ${n}`) .join('・'); return ` ${esc(p.position)} ${p.hands} ${pctText(Math.round((p.played / p.hands) * 1000) / 10)} ${p.judged ? pctText(Math.round((p.good / p.judged) * 1000) / 10) : '—'} ${mistakes || '—'} `; }) .join(''); const leaks = s.leaks.length ? `` : '
目立つクセは見つかりませんでした。
'; $('tab-body').innerHTML = `
気をつけたいクセ
${leaks}
色ごと
${colorRows}
色配られた参加率レンジどおり
ポジションごと
${posRows}
位置ハンド参加率レンジどおり主な間違い
`; } // 見直したいハンド function renderWorst() { const w = session.summary.worst; if (!w.length) { $('tab-body').innerHTML = '
見直したいハンドはありません。すべて満点です。
'; return; } $('tab-body').innerHTML = `

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

${w .map( (h, i) => ` ` ) .join('')}
`; } // 収支の推移: 実際の収支と、オールインの期待値で運を除いた収支 function renderGraph() { const g = session.summary.graph; if (g.length < 2) { $('tab-body').innerHTML = '
2ハンド以上でグラフを表示します。
'; return; } const W = 360; const H = 220; const pad = { l: 48, r: 10, t: 12, b: 22 }; const values = g.flatMap((p) => [p.net, p.evNet]).concat([0]); // 目盛りは 1・2・5 × 10^n 刻み const raw = (Math.max(...values) - Math.min(...values)) / 4 || 1; const mag = 10 ** Math.floor(Math.log10(raw)); const step = [1, 2, 5, 10].map((m) => m * mag).find((x) => x >= raw); const min = Math.floor(Math.min(...values) / step) * step; const max = Math.ceil(Math.max(...values) / step) * step || step; const money = (v) => (v < 0 ? '−' : '') + chips(Math.abs(v)); const x = (i) => pad.l + (i / (g.length - 1)) * (W - pad.l - pad.r); const y = (v) => pad.t + ((max - v) / (max - min)) * (H - pad.t - pad.b); const path = (key) => g.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p[key]).toFixed(1)}`).join(''); let grid = ''; for (let v = min; v <= max + step / 2; v += step) { const yy = y(v).toFixed(1); grid += `${esc(money(Math.round(v)))}`; } const last = g[g.length - 1]; $('tab-body').innerHTML = `
実際の収支 ${signed(last.net)} 運を除いた収支 ${signed(last.evNet)}
${grid} #${g[0].number} #${last.number}

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

`; const svg = $('chart').querySelector('svg'); const tip = $('tip'); const move = (clientX) => { const rect = svg.getBoundingClientRect(); const sx = ((clientX - rect.left) / rect.width) * W; const i = Math.max(0, Math.min(g.length - 1, Math.round(((sx - pad.l) / (W - pad.l - pad.r)) * (g.length - 1)))); const p = g[i]; const cx = x(i).toFixed(1); $('cross').setAttribute('x1', cx); $('cross').setAttribute('x2', cx); $('cross').setAttribute('visibility', 'visible'); for (const [id, key] of [['dot-net', 'net'], ['dot-ev', 'evNet']]) { $(id).setAttribute('cx', cx); $(id).setAttribute('cy', y(p[key]).toFixed(1)); $(id).setAttribute('visibility', 'visible'); } tip.innerHTML = `ハンド #${p.number}
実際 ${signed(p.net)}
運を除く ${signed(p.evNet)}
`; tip.hidden = false; const left = (x(i) / W) * rect.width; tip.style.left = `${Math.min(rect.width - tip.offsetWidth, Math.max(0, left - tip.offsetWidth / 2))}px`; }; const hide = () => { tip.hidden = true; for (const id of ['cross', 'dot-net', 'dot-ev']) $(id).setAttribute('visibility', 'hidden'); }; svg.addEventListener('pointermove', (e) => move(e.clientX)); svg.addEventListener('pointerdown', (e) => move(e.clientX)); svg.addEventListener('pointerleave', hide); } // ---------- リプレイ ---------- function openReplay(h) { replay = { hand: h, step: firstHeroStep(h) }; show('replay'); $('replay-head').innerHTML = `
${h.hero.cards.map((c) => cardHtml(c)).join('')}
#${h.number} ${esc(h.hero.key)} ${colorChip(h.hero)}
${esc(h.hero.colorLabel)} ・ ${esc(h.hero.position)} ・ ブラインド ${chips(h.sb)}/${chips(h.bb)} ・ 収支 ${signed(h.hero.net)}
${STREETS.map((st) => `
${STREET_SHORT[st]}${h.streetScores[st] == null ? '—' : h.streetScores[st]}
`).join('')}
${allInHtml(h)} `; $('replay-copy').addEventListener('click', () => copyHandPrompt(h)); $('replay-log').innerHTML = logHtml(h); renderStep(); } // ハンドの状況を、AI に貼り付けて「自分の判断は良かったか」を質問できる文章にしてコピーする async function copyHandPrompt(h) { const url = `/api/reviews/${encodeURIComponent(session.id)}/hands/${h.number}/prompt`; try { if (await App.copyText(() => api('GET', url).then((d) => d.text))) toast('コピーしました。AI のチャットに貼り付けて質問できます'); } catch (err) { toast(err.message); } } function allInHtml(h) { const a = h.hero.allIn; if (!a) return ''; return `
${STREET_LABEL[a.street]}でオールインした時点の勝率 ${a.equity}% → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 ${signed(a.won - a.ev)})
`; } // 最初に見せる場面: hero の最初の判断(無ければ最初) function firstHeroStep(h) { const i = h.actions.findIndex((a) => a.seat === h.heroSeat && !a.auto); return i >= 0 ? i : -1; } const nameOf = (h, seat) => { const p = h.players.find((x) => x.seat === seat); return p ? p.name : `席${seat + 1}`; }; const posOf = (h, seat) => { const p = h.players.find((x) => x.seat === seat); return p ? p.position : ''; }; function actionText(h, a) { const amount = a.type === 'CALL' || a.type === 'BET' || a.type === 'RAISE' ? ` ${chips(a.amount)}` : ''; return `${TYPE_LABEL[a.type]}${amount}${a.allIn ? '(オールイン)' : ''}${a.auto ? '(自動)' : ''}`; } function logHtml(h) { let html = ''; let street = null; h.actions.forEach((a, i) => { if (a.street !== street) { street = a.street; html += `
${STREET_LABEL[street]}
`; } const mine = a.seat === h.heroSeat; html += ``; }); html += ``; return html; } // step 番目のアクションの後の状態(-1 = ブラインドを払った直後、actions.length = 結果) function stateAt(h, step) { const invested = new Map(h.players.map((p) => [p.seat, 0])); const post = (seat, amount) => { const p = h.players.find((x) => x.seat === seat); if (p) invested.set(seat, invested.get(seat) + Math.min(amount, p.start)); }; post(h.sbSeat, h.sb); post(h.bbSeat, h.bb); const folded = new Set(); const last = new Map(); let pot = h.sb + h.bb; let street = 'PREFLOP'; const upto = Math.min(step, h.actions.length - 1); for (let i = 0; i <= upto; i++) { const a = h.actions[i]; if (a.street !== street) { street = a.street; last.clear(); } invested.set(a.seat, invested.get(a.seat) + a.added); if (a.type === 'FOLD') folded.add(a.seat); last.set(a.seat, a); pot = a.pot + a.added; } return { invested, folded, last, pot, street }; } function renderStep() { const h = replay.hand; const step = replay.step; const end = step >= h.actions.length; const st = stateAt(h, step); const a = step >= 0 && !end ? h.actions[step] : null; const shown = end ? h.board.length : BOARD_COUNT[a ? a.street : 'PREFLOP']; let board = ''; for (let i = 0; i < 5; i++) board += i < shown && h.board[i] ? cardHtml(h.board[i]) : ''; $('replay-board').innerHTML = board; $('replay-pot').textContent = `POT ${chips(end ? (h.results || []).reduce((x, r) => x + r.amount, 0) : st.pot)}`; if (end) { const lines = (h.results || []).map((r, i) => { const tag = h.results.length > 1 ? (i === 0 ? 'メイン ' : 'サイド ') : ''; return `
${tag}${r.winners.map((w) => esc(nameOf(h, w))).join('・')} +${chips(r.amount)}${r.handName ? ` ${esc(r.handName)}` : ''}
`; }); $('replay-step').innerHTML = `
結果
${lines.join('')}`; } else if (a) { $('replay-step').innerHTML = `
${STREET_LABEL[a.street]} ${step + 1}/${h.actions.length}
${esc(nameOf(h, a.seat))}(${esc(posOf(h, a.seat))}) ${esc(actionText(h, a))}
ポット ${chips(a.pot)}${a.toCall ? ` ・ コール額 ${chips(a.toCall)}` : ''}
`; } else { $('replay-step').innerHTML = `
ハンド開始
SB ${esc(nameOf(h, h.sbSeat))} ${chips(h.sb)} ・ BB ${esc(nameOf(h, h.bbSeat))} ${chips(h.bb)}
`; } $('replay-analysis').innerHTML = a && a.seat === h.heroSeat && !a.auto ? analysisHtml(h, step) : ''; const order = h.players.slice(); $('replay-players').innerHTML = order .map((p) => { const folded = st.folded.has(p.seat); const acting = a && a.seat === p.seat; const l = st.last.get(p.seat); const cls = ['rv-player', folded ? 'folded' : '', acting ? 'acting' : '', p.seat === h.heroSeat ? 'hero' : ''].join(' '); const won = end && p.won > 0 ? `+${chips(p.won)}` : ''; return `
${p.cards.map((c) => cardHtml(c, folded ? 'dim' : '')).join('')}
${esc(p.name)}${p.bot ? ' CPU' : ''} ${esc(p.position)}
残り ${chips(end ? p.end : p.start - st.invested.get(p.seat))} ・ 投入 ${chips(st.invested.get(p.seat))}
${won || (l && !end ? esc(TYPE_LABEL[l.type]) : folded ? 'フォールド' : '')}
`; }) .join(''); for (const b of $('replay-log').children) b.classList.toggle('current', Number(b.dataset.step) === step); const atStart = step <= -1; const atEnd = end; $('view-replay').querySelector('[data-step="first"]').disabled = atStart; $('view-replay').querySelector('[data-step="prev"]').disabled = atStart; $('view-replay').querySelector('[data-step="next"]').disabled = atEnd; $('view-replay').querySelector('[data-step="last"]').disabled = atEnd; } // hero の判断の詳しい判定 function analysisHtml(h, index) { const j = h.preflop.find((x) => x.index === index); if (j) { return `
${j.mark}
ハンドレンジ ${colorChip(h.hero)} ${j.score}点
${esc(j.situation)}
推奨 ${KIND_LABEL[j.recommended]} / あなた ${KIND_LABEL[j.actual]} — ${esc(j.label)}
${j.equity != null ? `
参加者の推定レンジに対する勝率 ${j.equity}%(コールに必要 ${j.need}%)
` : ''}
`; } const d = h.decisions.find((x) => x.index === index); if (!d) return ''; const best = Math.max(...d.options.map((o) => o.evBB)); const rows = d.options .map((o) => { const isActual = o.kind === d.actual.kind && o.to === d.actual.to; const isBest = o.evBB === best; return `${optionLabel(o)}${isActual ? ' ← あなた' : ''}${o.evBB > 0 ? '+' : ''}${o.evBB}BB`; }) .join(''); return `
${d.mark}
${STREET_LABEL[d.street]}の判断 ${d.score}点${d.lossBB > 0 ? ` (${d.lossBB}BB 損)` : ''}
相手の推定レンジに対する勝率 ${d.equity}%${d.need != null ? ` / コールに必要な勝率 ${d.need}% → ${d.equity >= d.need ? 'コールしてよい' : 'コールは割に合わない'}` : ''}
${rows}
選択肢期待値の目安
`; } function stepTo(target) { if (!replay) return; replay.step = Math.max(-1, Math.min(replay.hand.actions.length, target)); renderStep(); } $('view-replay').addEventListener('click', (e) => { const b = e.target.closest('[data-step]'); if (!b || !replay) return; const v = b.dataset.step; const n = replay.hand.actions.length; if (v === 'first') stepTo(-1); else if (v === 'prev') stepTo(replay.step - 1); else if (v === 'next') stepTo(replay.step + 1); else if (v === 'last') stepTo(n); else stepTo(Number(v)); }); document.addEventListener('keydown', (e) => { if ($('view-replay').hidden || e.target.closest('input, textarea')) return; if (e.key === 'ArrowLeft') stepTo(replay.step - 1); if (e.key === 'ArrowRight') stepTo(replay.step + 1); }); route(); })();