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
+104
-3
@@ -27,6 +27,8 @@
|
||||
// ショーダウンの勝者選択(ポットごとの席番号の Set)
|
||||
let awardKey = '';
|
||||
let awardSel = [];
|
||||
let seenReview = 0; // 自動で開いたレビューのハンド番号(レビュー付きCPU戦)
|
||||
let reviewKey = '';
|
||||
|
||||
// ---------- 名前 ----------
|
||||
function ensureName() {
|
||||
@@ -199,6 +201,7 @@
|
||||
|
||||
renderSeats(s);
|
||||
if (me) renderActionBar(s, me);
|
||||
renderReview(s);
|
||||
renderTimer();
|
||||
|
||||
// 自分の番になったら知らせる(タップ前は振動できない)
|
||||
@@ -335,6 +338,7 @@
|
||||
let status = '';
|
||||
if (!s.started) status = s.isHost ? '2人以上揃ったら START を押してください' : 'ホストの開始を待っています';
|
||||
else if (s.paused) status = '一時停止中';
|
||||
else if (s.review && s.review.waiting) status = 'レビューを確認したら「次のハンドへ」を押してください';
|
||||
else if (m.sittingOut) status = '離席中です。復帰すると次のハンドから参加します';
|
||||
else if (m.canRebuy) status = 'チップがなくなりました';
|
||||
else if (o) status = o.canCheck ? 'あなたの番です' : `あなたの番です(コール ${chips(o.toCall)})`;
|
||||
@@ -356,13 +360,15 @@
|
||||
if (awarding) renderAwardPanel(h.pots);
|
||||
|
||||
// ボタン
|
||||
const alt = (m.sittingOut || m.canRebuy) && !awarding;
|
||||
const reviewWaiting = Boolean(s.review && s.review.waiting);
|
||||
const alt = (m.sittingOut || m.canRebuy) && !awarding && !reviewWaiting;
|
||||
if (!o) raising = false;
|
||||
$('review-actions').hidden = !reviewWaiting;
|
||||
$('alt-actions').hidden = !alt;
|
||||
$('sit-in').hidden = !m.sittingOut;
|
||||
$('rebuy').hidden = !m.canRebuy;
|
||||
$('rebuy').textContent = `リバイ(${chips(s.startingStack)})`;
|
||||
$('actions').hidden = alt || raising || awarding;
|
||||
$('actions').hidden = alt || raising || awarding || reviewWaiting;
|
||||
$('raise-actions').hidden = alt || !raising;
|
||||
$('raise-panel').hidden = alt || !raising;
|
||||
$('status-line').hidden = raising;
|
||||
@@ -387,6 +393,101 @@
|
||||
if (raising) renderRaisePanel(o);
|
||||
}
|
||||
|
||||
// ---------- レビュー(レビュー付きCPU戦の作成者のみ) ----------
|
||||
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 KIND_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
|
||||
const MARK_CLASS = { '◎': 'great', '○': 'good', '△': 'warn', '✕': 'bad' };
|
||||
const optionLabel = (o) => (o.kind === 'raise' ? `ベット/レイズ ${chips(o.to)}` : KIND_LABEL[o.kind]);
|
||||
const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '±') + chips(Math.abs(n));
|
||||
const markHtml = (m) => `<span class="rv-mark ${MARK_CLASS[m] || ''}">${m}</span>`;
|
||||
|
||||
function reviewHtml(r) {
|
||||
const l = r.last;
|
||||
const h = l.hero;
|
||||
const pre = l.preflop.length
|
||||
? l.preflop
|
||||
.map(
|
||||
(j) => `
|
||||
<div class="rv-item">${markHtml(j.mark)}<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>`
|
||||
)
|
||||
.join('')
|
||||
: '<div class="rv-sub">プリフロップで判断する場面はありませんでした</div>';
|
||||
const post = l.decisions
|
||||
.map(
|
||||
(d) => `
|
||||
<div class="rv-item">${markHtml(d.mark)}<div>
|
||||
<div><b>${STREET_LABEL[d.street]}</b> ${optionLabel(d.actual)} <span class="rv-score">${d.score}点</span></div>
|
||||
<div class="rv-sub">推定勝率 ${d.equity}%${d.need != null ? `(コールに必要 ${d.need}%)` : ''}${
|
||||
d.lossBB > 0 ? ` ・ 最善は ${optionLabel(d.best)}(${d.lossBB}BB 損)` : ' ・ 最善の選択'
|
||||
}</div>
|
||||
</div></div>`
|
||||
)
|
||||
.join('');
|
||||
const scores = STREETS.map((st) => {
|
||||
const v = l.streetScores[st];
|
||||
return `<div class="rv-street"><span>${STREET_SHORT[st]}</span><b>${v == null ? '—' : v}</b></div>`;
|
||||
}).join('');
|
||||
const a = h.allIn;
|
||||
const allIn = a
|
||||
? `<div class="rv-section">オールイン</div>
|
||||
<div class="rv-sub">${STREET_LABEL[a.street]}でオールインした時点の勝率 <b>${a.equity}%</b> → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 ${signed(a.won - a.ev)})</div>`
|
||||
: '';
|
||||
return `
|
||||
<div class="rv-head">
|
||||
<div class="rv-cards">${h.cards.map((c) => cardHtml(c)).join('')}</div>
|
||||
<div>
|
||||
<div class="rv-hand"><b>${esc(h.key)}</b> <span class="rv-color cat-${esc(h.color)}">${esc(h.colorName)}</span></div>
|
||||
<div class="rv-sub">${esc(h.colorLabel)} ・ ${esc(h.position)} ・ ハンド #${l.number}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rv-streets">${scores}</div>
|
||||
<div class="rv-section">プリフロップ(ハンドレンジ)</div>${pre}
|
||||
${post ? `<div class="rv-section">フロップ以降(期待値の目安)</div>${post}` : ''}
|
||||
${allIn}
|
||||
<div class="rv-foot">このハンドの収支 ${signed(h.net)} ・ 平均 ${r.average == null ? '—' : r.average}点(${r.hands}ハンド)</div>`;
|
||||
}
|
||||
|
||||
function renderReview(s) {
|
||||
const r = s.review;
|
||||
$('review-open').hidden = !(r && r.last);
|
||||
if (!r || !r.last) {
|
||||
$('review-modal').hidden = true;
|
||||
return;
|
||||
}
|
||||
$('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}` : '/review';
|
||||
$('review-modal-next').hidden = !r.waiting;
|
||||
const key = JSON.stringify(r);
|
||||
if (key !== reviewKey) {
|
||||
reviewKey = key;
|
||||
$('review-body').innerHTML = reviewHtml(r);
|
||||
}
|
||||
// 新しいハンドのレビューが届いたら自動で開く
|
||||
if (r.last.number !== seenReview) {
|
||||
seenReview = r.last.number;
|
||||
if (r.waiting) $('review-modal').hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
const openReview = () => { $('review-modal').hidden = false; };
|
||||
$('review-open').addEventListener('click', openReview);
|
||||
$('review-show').addEventListener('click', openReview);
|
||||
$('review-close').addEventListener('click', () => { $('review-modal').hidden = true; });
|
||||
$('review-modal').addEventListener('click', (e) => {
|
||||
if (e.target === $('review-modal')) $('review-modal').hidden = true;
|
||||
});
|
||||
const nextHand = () => {
|
||||
$('review-modal').hidden = true;
|
||||
emit('table:next');
|
||||
};
|
||||
$('review-next').addEventListener('click', nextHand);
|
||||
$('review-modal-next').addEventListener('click', nextHand);
|
||||
|
||||
// ---------- 勝者選択 ----------
|
||||
function renderAwardPanel(pots) {
|
||||
const key = `${state.handNumber}:${JSON.stringify(pots)}`;
|
||||
@@ -605,7 +706,7 @@
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.target;
|
||||
if (t.closest && t.closest('input[type="range"]')) return;
|
||||
const bar = t.closest && t.closest('.action-bar');
|
||||
const bar = t.closest && (t.closest('.action-bar') || t.closest('.review-modal'));
|
||||
if (bar && bar.scrollHeight > bar.clientHeight) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user