// CPU戦のレビュー(管理者専用): 対局ごとの点数表・成績・色とポジションの傾向・見直したいハンド・収支の推移と、
// 1ハンドずつのリプレイ
(function () {
'use strict';
const { esc, chips, api, toast } = App;
const $ = (id) => document.getElementById(id);
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
const STREET_LABEL = { PREFLOP: 'プリフロップ', FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
const STREET_SHORT = { PREFLOP: 'PF', FLOP: 'Flop', TURN: 'Turn', RIVER: 'River' };
const BOARD_COUNT = { PREFLOP: 0, FLOP: 3, TURN: 4, RIVER: 5 };
const KIND_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
const TYPE_LABEL = { FOLD: 'フォールド', CHECK: 'チェック', CALL: 'コール', BET: 'ベット', RAISE: 'レイズ' };
const MARK_CLASS = { '◎': 'great', '○': 'good', '△': 'warn', '✕': 'bad' };
const SUIT_SYMBOL = { s: '♠︎', h: '♥︎', d: '♦︎', c: '♣︎' };
let session = null;
let tab = 'scores';
let replay = null; // { hand, step }
const markOf = (score) => (score == null ? '' : score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕');
const scoreClass = (score) => (score == null ? 'none' : MARK_CLASS[markOf(score)]);
const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '±') + chips(Math.abs(n));
const signClass = (n) => (n > 0 ? 'plus' : n < 0 ? 'minus' : '');
const pctText = (v) => (v == null ? '—' : `${v}%`);
const dateText = (t) => new Date(t).toLocaleString('ja-JP', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
const optionLabel = (o) => (o.kind === 'raise' ? `ベット/レイズ ${chips(o.to)}` : KIND_LABEL[o.kind]);
const colorChip = (h) => `${esc(h.colorName)}`;
function cardHtml(c, extra = '') {
if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return ``;
const rank = c[0] === 'T' ? '10' : c[0];
return ``;
}
// ---------- 画面の切り替え(URL: ?s=対局ID&h=ハンド番号) ----------
function show(view) {
for (const v of ['list', 'session', 'replay']) $(`view-${v}`).hidden = v !== view;
window.scrollTo(0, 0);
}
function go(params, push = true) {
const q = new URLSearchParams(params).toString();
const url = location.pathname + (q ? `?${q}` : '');
if (push) history.pushState(null, '', url);
else history.replaceState(null, '', url);
route();
}
async function route() {
const p = new URLSearchParams(location.search);
const id = p.get('s');
const hand = Number(p.get('h'));
if (!id) {
$('title').textContent = 'CPU戦のレビュー';
$('back').href = '/';
show('list');
return loadList();
}
if (!session || session.id !== id || !hand) {
try {
session = await api('GET', `/api/reviews/${encodeURIComponent(id)}`);
} catch (err) {
toast(err.message);
return go({}, false);
}
}
$('title').textContent = session.tableName;
if (hand) {
const h = session.hands.find((x) => x.number === hand);
if (h) {
$('back').href = `?s=${encodeURIComponent(id)}`;
return openReplay(h);
}
}
$('back').href = '/review';
show('session');
renderSession();
}
window.addEventListener('popstate', route);
// 戻るボタンは履歴を積まずに一つ上へ
$('back').addEventListener('click', (e) => {
const href = $('back').getAttribute('href');
if (href === '/') return;
e.preventDefault();
const p = new URLSearchParams(location.search);
go(p.get('h') ? { s: p.get('s') } : {});
});
// ---------- 対局の一覧 ----------
async function loadList() {
let list;
try {
list = await api('GET', '/api/reviews');
} catch (err) {
$('sessions').innerHTML = `
${esc(err.message)}
`;
return;
}
if (!list.length) {
$('sessions').innerHTML = 'まだ対局がありません。
ロビーの「テーブルを作成」で「レビュー付きCPU戦」を選んでください。
';
return;
}
$('sessions').innerHTML = list
.map(
(s) => `
`
)
.join('');
}
$('sessions').addEventListener('click', (e) => {
const b = e.target.closest('[data-id]');
if (b) go({ s: b.dataset.id });
});
// ---------- 1回の対局 ----------
function renderSession() {
const s = session.summary;
const net = s.graph.length ? s.graph[s.graph.length - 1].net : 0;
$('overview').innerHTML = `
ハンド数${s.stats.hands}
平均点${s.total == null ? '—' : s.total}
収支${signed(net)}
`;
for (const b of $('tabs').children) b.classList.toggle('active', b.dataset.tab === tab);
const render = { scores: renderScores, stats: renderStats, range: renderRange, worst: renderWorst, graph: renderGraph }[tab];
$('tab-body').innerHTML = '';
render();
}
$('tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-tab]');
if (!b) return;
tab = b.dataset.tab;
renderSession();
});
function scoreCell(v) {
return `${v == null ? '—' : v} | `;
}
// 点数表: ハンドごと・ストリートごとの点数
function renderScores() {
const hands = session.hands;
if (!hands.length) {
$('tab-body').innerHTML = 'まだハンドがありません。
';
return;
}
const rows = hands
.slice()
.reverse()
.map(
(h) => `
| ${h.number} |
${esc(h.hero.key)} ${colorChip(h.hero)} |
${esc(h.hero.position)} |
${STREETS.map((st) => scoreCell(h.streetScores[st])).join('')}
${scoreCell(h.handScore)}
${signed(h.hero.net)} |
`
)
.join('');
const avg = session.summary.streets;
$('tab-body').innerHTML = `
行をタップするとリプレイと詳しい判定を表示します。PF はハンドレンジどおりか、Flop 以降は推定レンジに対する期待値の目安(100点 = 最善)。
| # | ハンド | 位置 | ${STREETS.map((st) => `${STREET_SHORT[st]} | `).join('')}平均 | 収支 |
${rows}
| 平均 | ${STREETS.map((st) => scoreCell(avg[st])).join('')}${scoreCell(session.summary.total)} |
`;
}
$('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
? `${s.leaks.map((l) => `- ${esc(l.text)} ${l.count}回${l.of ? `(${l.of}回中)` : ''}
`).join('')}
`
: '目立つクセは見つかりませんでした。
';
$('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)}
運を除いた収支は、オールインで決着したハンドを「オールインした時点の勝率どおりにポットを受け取った」とみなした収支です。実際の収支がこれより上なら運が良かった、下なら運が悪かったことになります。
`;
const svg = $('chart').querySelector('svg');
const tip = $('tip');
const move = (clientX) => {
const rect = svg.getBoundingClientRect();
const sx = ((clientX - rect.left) / rect.width) * W;
const i = Math.max(0, Math.min(g.length - 1, Math.round(((sx - pad.l) / (W - pad.l - pad.r)) * (g.length - 1))));
const p = g[i];
const cx = x(i).toFixed(1);
$('cross').setAttribute('x1', cx);
$('cross').setAttribute('x2', cx);
$('cross').setAttribute('visibility', 'visible');
for (const [id, key] of [['dot-net', 'net'], ['dot-ev', 'evNet']]) {
$(id).setAttribute('cx', cx);
$(id).setAttribute('cy', y(p[key]).toFixed(1));
$(id).setAttribute('visibility', 'visible');
}
tip.innerHTML = `ハンド #${p.number}実際 ${signed(p.net)}
運を除く ${signed(p.evNet)}
`;
tip.hidden = false;
const left = (x(i) / W) * rect.width;
tip.style.left = `${Math.min(rect.width - tip.offsetWidth, Math.max(0, left - tip.offsetWidth / 2))}px`;
};
const hide = () => {
tip.hidden = true;
for (const id of ['cross', 'dot-net', 'dot-ev']) $(id).setAttribute('visibility', 'hidden');
};
svg.addEventListener('pointermove', (e) => move(e.clientX));
svg.addEventListener('pointerdown', (e) => move(e.clientX));
svg.addEventListener('pointerleave', hide);
}
// ---------- リプレイ ----------
function openReplay(h) {
replay = { hand: h, step: firstHeroStep(h) };
show('replay');
$('replay-head').innerHTML = `
${h.hero.cards.map((c) => cardHtml(c)).join('')}
#${h.number} ${esc(h.hero.key)} ${colorChip(h.hero)}
${esc(h.hero.colorLabel)} ・ ${esc(h.hero.position)} ・ ブラインド ${chips(h.sb)}/${chips(h.bb)} ・ 収支 ${signed(h.hero.net)}
${STREETS.map((st) => `
${STREET_SHORT[st]}${h.streetScores[st] == null ? '—' : h.streetScores[st]}
`).join('')}
${allInHtml(h)}`;
$('replay-log').innerHTML = logHtml(h);
renderStep();
}
function allInHtml(h) {
const a = h.hero.allIn;
if (!a) return '';
return `${STREET_LABEL[a.street]}でオールインした時点の勝率 ${a.equity}% → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 ${signed(a.won - a.ev)})
`;
}
// 最初に見せる場面: hero の最初の判断(無ければ最初)
function firstHeroStep(h) {
const i = h.actions.findIndex((a) => a.seat === h.heroSeat && !a.auto);
return i >= 0 ? i : -1;
}
const nameOf = (h, seat) => {
const p = h.players.find((x) => x.seat === seat);
return p ? p.name : `席${seat + 1}`;
};
const posOf = (h, seat) => {
const p = h.players.find((x) => x.seat === seat);
return p ? p.position : '';
};
function actionText(h, a) {
const amount = a.type === 'CALL' || a.type === 'BET' || a.type === 'RAISE' ? ` ${chips(a.amount)}` : '';
return `${TYPE_LABEL[a.type]}${amount}${a.allIn ? '(オールイン)' : ''}${a.auto ? '(自動)' : ''}`;
}
function logHtml(h) {
let html = '';
let street = null;
h.actions.forEach((a, i) => {
if (a.street !== street) {
street = a.street;
html += `${STREET_LABEL[street]}
`;
}
const mine = a.seat === h.heroSeat;
html += ``;
});
html += ``;
return html;
}
// step 番目のアクションの後の状態(-1 = ブラインドを払った直後、actions.length = 結果)
function stateAt(h, step) {
const invested = new Map(h.players.map((p) => [p.seat, 0]));
const post = (seat, amount) => {
const p = h.players.find((x) => x.seat === seat);
if (p) invested.set(seat, invested.get(seat) + Math.min(amount, p.start));
};
post(h.sbSeat, h.sb);
post(h.bbSeat, h.bb);
const folded = new Set();
const last = new Map();
let pot = h.sb + h.bb;
let street = 'PREFLOP';
const upto = Math.min(step, h.actions.length - 1);
for (let i = 0; i <= upto; i++) {
const a = h.actions[i];
if (a.street !== street) {
street = a.street;
last.clear();
}
invested.set(a.seat, invested.get(a.seat) + a.added);
if (a.type === 'FOLD') folded.add(a.seat);
last.set(a.seat, a);
pot = a.pot + a.added;
}
return { invested, folded, last, pot, street };
}
function renderStep() {
const h = replay.hand;
const step = replay.step;
const end = step >= h.actions.length;
const st = stateAt(h, step);
const a = step >= 0 && !end ? h.actions[step] : null;
const shown = end ? h.board.length : BOARD_COUNT[a ? a.street : 'PREFLOP'];
let board = '';
for (let i = 0; i < 5; i++) board += i < shown && h.board[i] ? cardHtml(h.board[i]) : '';
$('replay-board').innerHTML = board;
$('replay-pot').textContent = `POT ${chips(end ? (h.results || []).reduce((x, r) => x + r.amount, 0) : st.pot)}`;
if (end) {
const lines = (h.results || []).map((r, i) => {
const tag = h.results.length > 1 ? (i === 0 ? 'メイン ' : 'サイド ') : '';
return `${tag}${r.winners.map((w) => esc(nameOf(h, w))).join('・')} +${chips(r.amount)}${r.handName ? ` ${esc(r.handName)}` : ''}
`;
});
$('replay-step').innerHTML = `結果
${lines.join('')}`;
} else if (a) {
$('replay-step').innerHTML = `${STREET_LABEL[a.street]} ${step + 1}/${h.actions.length}
${esc(nameOf(h, a.seat))}(${esc(posOf(h, a.seat))}) ${esc(actionText(h, a))}
ポット ${chips(a.pot)}${a.toCall ? ` ・ コール額 ${chips(a.toCall)}` : ''}
`;
} else {
$('replay-step').innerHTML = `ハンド開始
SB ${esc(nameOf(h, h.sbSeat))} ${chips(h.sb)} ・ BB ${esc(nameOf(h, h.bbSeat))} ${chips(h.bb)}
`;
}
$('replay-analysis').innerHTML = a && a.seat === h.heroSeat && !a.auto ? analysisHtml(h, step) : '';
const order = h.players.slice();
$('replay-players').innerHTML = order
.map((p) => {
const folded = st.folded.has(p.seat);
const acting = a && a.seat === p.seat;
const l = st.last.get(p.seat);
const cls = ['rv-player', folded ? 'folded' : '', acting ? 'acting' : '', p.seat === h.heroSeat ? 'hero' : ''].join(' ');
const won = end && p.won > 0 ? `+${chips(p.won)}` : '';
return `
${p.cards.map((c) => cardHtml(c, folded ? 'dim' : '')).join('')}
${esc(p.name)}${p.bot ? ' CPU' : ''} ${esc(p.position)}
残り ${chips(end ? p.end : p.start - st.invested.get(p.seat))} ・ 投入 ${chips(st.invested.get(p.seat))}
${won || (l && !end ? esc(TYPE_LABEL[l.type]) : folded ? 'フォールド' : '')}
`;
})
.join('');
for (const b of $('replay-log').children) b.classList.toggle('current', Number(b.dataset.step) === step);
const atStart = step <= -1;
const atEnd = end;
$('view-replay').querySelector('[data-step="first"]').disabled = atStart;
$('view-replay').querySelector('[data-step="prev"]').disabled = atStart;
$('view-replay').querySelector('[data-step="next"]').disabled = atEnd;
$('view-replay').querySelector('[data-step="last"]').disabled = atEnd;
}
// hero の判断の詳しい判定
function analysisHtml(h, index) {
const j = h.preflop.find((x) => x.index === index);
if (j) {
return `
${j.mark}
ハンドレンジ ${colorChip(h.hero)} ${j.score}点
${esc(j.situation)}
推奨 ${KIND_LABEL[j.recommended]} / あなた ${KIND_LABEL[j.actual]} — ${esc(j.label)}
${j.equity != null ? `
参加者の推定レンジに対する勝率 ${j.equity}%(コールに必要 ${j.need}%)
` : ''}
`;
}
const d = h.decisions.find((x) => x.index === index);
if (!d) return '';
const best = Math.max(...d.options.map((o) => o.evBB));
const rows = d.options
.map((o) => {
const isActual = o.kind === d.actual.kind && o.to === d.actual.to;
const isBest = o.evBB === best;
return `| ${optionLabel(o)}${isActual ? ' ← あなた' : ''} | ${o.evBB > 0 ? '+' : ''}${o.evBB}BB |
`;
})
.join('');
return `
${d.mark}
${STREET_LABEL[d.street]}の判断 ${d.score}点${d.lossBB > 0 ? ` (${d.lossBB}BB 損)` : ''}
相手の推定レンジに対する勝率 ${d.equity}%${d.need != null ? ` / コールに必要な勝率 ${d.need}% → ${d.equity >= d.need ? 'コールしてよい' : 'コールは割に合わない'}` : ''}
`;
}
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();
})();