Replace the look of every page (key, lobby, create, table, range, review) with light/dark semantic colors, translucent materials, iOS-style grouped lists and system typography. Add fluid.js, a small spring engine with interruptible, velocity-aware animations, and use it for draggable bottom sheets, sliding segmented controls, a custom bet slider with rubber-banding, swipe-to-delete in the lobby, and dealt/flipped cards. Seats and cards are now reused between renders so animations are not cut off. Honors reduced motion, reduced transparency and increased contrast. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
585 lines
28 KiB
JavaScript
585 lines
28 KiB
JavaScript
// 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=ハンド番号) ----------
|
||
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 = '/';
|
||
$('back-label').textContent = 'ロビー';
|
||
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)}`;
|
||
$('back-label').textContent = '対局';
|
||
return openReplay(h);
|
||
}
|
||
}
|
||
$('back').href = '/review';
|
||
$('back-label').textContent = '一覧';
|
||
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="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();
|
||
})();
|