Files
Poker/admin/review.js
T
agomizi1cmandClaude Opus 5.5 dd39c043c4 Add player stats, in-table ranking, host seating and review return
- Stats page (/stats): play style radar (TAG/LAG/rock/fish), VPIP, PFR,
  ATS, 3bet, WTSD, W$SD, WWSF, per-street fold rate, aggression
  frequency, hand win rate and net in BB, per table type, each with a
  bands-and-explanation sheet. Hands are counted per human player when
  they finish and stored as totals under data/stats/, keyed by a hash
  of the player ID (sent in a POST body, never in the URL).
- Ranking sheet at the table: everyone seated, ordered by stack or by
  net (stack minus buy-ins), updating live.
- The host can reorder seats by dragging (with a mini table preview and
  shuffle). Takes effect immediately between hands, otherwise at the
  start of the next hand; the dealer button stays with the same player.
- The full-session review opens in the same tab and its back button
  returns to the table it was opened from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:04:33 +09:00

597 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = '<svg class="i chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>';
const SPARKLES =
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 3.5l1.6 4.4L16 9.5l-4.4 1.6L10 15.5l-1.6-4.4L4 9.5l4.4-1.6z"/><path d="M18 14l.8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8z"/></svg>';
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) => `<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=ハンド番号&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 = `<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="cell cell-link rv-session" data-id="${esc(s.id)}">
<span class="cell-title">
<span class="rv-session-name">${esc(s.tableName)}${s.active ? '<span class="badge live">PLAYING</span>' : ''}</span>
<span class="cell-sub">${dateText(s.createdAt)} ・ ${s.hands}ハンド ・ ${esc(s.heroName)}</span>
</span>
${CHEVRON}
</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').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 `<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)}
<button type="button" class="btn btn-block rv-copy" id="replay-copy">${SPARKLES}AIに質問する用にコピー</button>`;
$('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 `<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();
})();