Add admin-only CPU tables with per-hand reviews
A new table type, "review" (レビュー付きCPU戦), can only be created by admins. Only the creator and CPUs sit there: friends cannot join, and the table is hidden from non-admin lobbies. After every hand a review opens, and the next hand waits until the creator presses "次のハンドへ". - Preflop: shows which color of the registered hand range the hole cards are and whether the action followed it. With no entrant before you, open with the color for the number of players still to act; facing an entrant, estimate their range from their position, call one color above it and raise two above. Purple ranks just below white (SB only). - Flop onward: equity vs ranges estimated from those colors (narrowed by later bets/calls) gives a rough EV per option. The score is the EV lost against the best option, relative to the pot. It is a guide, not GTO. - Also: pot-odds call check, all-in equity and luck-adjusted P&L, VPIP/PFR/3bet/Cbet/WTSD/W$SD/AF, per-color and per-position compliance, habits to watch, worst-5 hands, a P&L chart and a step-by-step replay on the admin-only /review page. Hand now logs every action for the review. The range data moves to admin/range-data.js, shared by the range page and the server. Reviews are saved under data/reviews/ (newest 50 sessions). Admin scripts and CSS are no longer cached for a day, so updates show up immediately. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3116b1da9f
commit
3f2515a9fa
25 files changed
+2262
-60
No files matched your search
+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();
|
||||
})();
|
||||
Reference in new issue
Block a user