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>
169 lines
5.8 KiB
JavaScript
169 lines
5.8 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
const { Store, api, chips } = App;
|
|
if (!Store.name) {
|
|
location.replace('/');
|
|
return;
|
|
}
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const form = $('form');
|
|
const nameInput = $('table-name');
|
|
const stackInput = $('stack');
|
|
const sbInput = $('sb');
|
|
const bbInput = $('bb');
|
|
const timerInput = $('timer');
|
|
const intervalInput = $('interval');
|
|
const scheduleEl = $('schedule');
|
|
const modeHint = $('mode-hint');
|
|
const cpuOptions = $('cpu-options');
|
|
const blindHint = $('blind-hint');
|
|
const errorEl = $('error');
|
|
const submitBtn = $('submit');
|
|
|
|
// ブラインドを手で変えるまではスタックに追従させる
|
|
let blindsTouched = false;
|
|
|
|
async function suggestName() {
|
|
try {
|
|
const { name } = await api('GET', '/api/tables/suggest-name');
|
|
nameInput.value = name;
|
|
} catch { /* 失敗時は手入力 */ }
|
|
}
|
|
|
|
const int = (el) => {
|
|
const n = Number(el.value);
|
|
return Number.isInteger(n) ? n : NaN;
|
|
};
|
|
|
|
function applyDefaultBlinds() {
|
|
const stack = int(stackInput);
|
|
if (!(stack >= 10)) return;
|
|
const { sb, bb } = Blinds.defaultBlinds(stack);
|
|
sbInput.value = sb;
|
|
bbInput.value = bb;
|
|
}
|
|
|
|
function updateHint() {
|
|
const stack = int(stackInput);
|
|
const bb = int(bbInput);
|
|
if (blindsTouched && stack > 0 && bb > 0) {
|
|
blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ <a href="#" id="reset-blinds" class="link-gold">おすすめに戻す</a>`;
|
|
} else {
|
|
blindHint.textContent = 'スタックに合わせて自動設定(BB ≒ スタックの1/100)';
|
|
}
|
|
}
|
|
|
|
function updateSchedule() {
|
|
const sb = int(sbInput);
|
|
const bb = int(bbInput);
|
|
const on = timerInput.checked;
|
|
intervalInput.disabled = !on;
|
|
scheduleEl.hidden = !on || !(sb > 0 && bb > sb);
|
|
if (scheduleEl.hidden) return;
|
|
const mins = int(intervalInput) > 0 ? int(intervalInput) : 0;
|
|
scheduleEl.innerHTML = Blinds.schedule(sb, bb, 8)
|
|
.map((l, i) => `<span>Lv.${l.level} <b>${chips(l.sb)}/${chips(l.bb)}</b>${mins ? ` · ${i * mins}分〜` : ''}</span>`)
|
|
.join('');
|
|
}
|
|
|
|
const MODE_HINTS = {
|
|
chips: '実物のカードで遊び、サイトはチップだけを管理。ショーダウンの勝者はホストが選ぶ',
|
|
friends: '各自のスマホに手札が配られ、勝敗とポットの分配は自動',
|
|
cpu: 'CPU と対戦(友達も空いている席に参加できる)。カードの配布・勝敗の判定は自動',
|
|
review: 'あなたと CPU だけで対戦(友達は入れない)。ハンドごとにハンドレンジの色・行動の点数を表示し、対局全体の成績も振り返れる',
|
|
};
|
|
const radioValue = (name) => (form.querySelector(`input[name="${name}"]:checked`) || {}).value;
|
|
|
|
function updateMode() {
|
|
const mode = radioValue('mode');
|
|
modeHint.textContent = MODE_HINTS[mode] || '';
|
|
cpuOptions.hidden = mode !== 'cpu' && mode !== 'review';
|
|
}
|
|
|
|
function refresh() {
|
|
updateHint();
|
|
updateSchedule();
|
|
updateMode();
|
|
}
|
|
|
|
stackInput.addEventListener('input', () => {
|
|
if (!blindsTouched) applyDefaultBlinds();
|
|
refresh();
|
|
});
|
|
[sbInput, bbInput].forEach((el) =>
|
|
el.addEventListener('input', () => {
|
|
blindsTouched = true;
|
|
if (el === sbInput && int(sbInput) > 0) bbInput.value = int(sbInput) * 2;
|
|
refresh();
|
|
})
|
|
);
|
|
blindHint.addEventListener('click', (e) => {
|
|
if (e.target.id !== 'reset-blinds') return;
|
|
e.preventDefault();
|
|
blindsTouched = false;
|
|
applyDefaultBlinds();
|
|
refresh();
|
|
});
|
|
timerInput.addEventListener('change', refresh);
|
|
intervalInput.addEventListener('input', refresh);
|
|
form.querySelectorAll('input[type="radio"]').forEach((el) => el.addEventListener('change', refresh));
|
|
$('reroll').addEventListener('click', suggestName);
|
|
|
|
function validate() {
|
|
const name = nameInput.value.trim();
|
|
const stack = int(stackInput);
|
|
const sb = int(sbInput);
|
|
const bb = int(bbInput);
|
|
const interval = int(intervalInput);
|
|
if (!name) return [nameInput, 'テーブル名を入力してください'];
|
|
if (!(stack >= 10)) return [stackInput, '開始スタックは10以上の整数にしてください'];
|
|
if (!(sb >= 1)) return [sbInput, 'SBは1以上の整数にしてください'];
|
|
if (!(bb > sb)) return [bbInput, 'BBはSBより大きくしてください'];
|
|
if (bb > stack) return [bbInput, 'BBが開始スタックより大きいです'];
|
|
if (timerInput.checked && !(interval >= 1 && interval <= 240)) return [intervalInput, 'インターバルは1〜240分にしてください'];
|
|
return null;
|
|
}
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
form.querySelectorAll('.invalid').forEach((el) => el.classList.remove('invalid'));
|
|
const problem = validate();
|
|
if (problem) {
|
|
problem[0].classList.add('invalid');
|
|
problem[0].focus();
|
|
errorEl.textContent = problem[1];
|
|
return;
|
|
}
|
|
errorEl.textContent = '';
|
|
submitBtn.disabled = true;
|
|
try {
|
|
const { id } = await api('POST', '/api/tables', {
|
|
name: nameInput.value.trim(),
|
|
startingStack: int(stackInput),
|
|
smallBlind: int(sbInput),
|
|
bigBlind: int(bbInput),
|
|
blindTimerEnabled: timerInput.checked,
|
|
blindIntervalMin: int(intervalInput),
|
|
mode: radioValue('mode'),
|
|
cpuCount: Number(radioValue('cpu-count')),
|
|
token: Store.token,
|
|
});
|
|
location.href = '/table/' + encodeURIComponent(id);
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
submitBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// レビュー付きCPU戦は管理者だけが作れる(サーバでも確認する)
|
|
api('GET', '/api/me')
|
|
.then((me) => { $('review-mode').hidden = !me.admin; })
|
|
.catch(() => {});
|
|
|
|
applyDefaultBlinds();
|
|
refresh();
|
|
suggestName();
|
|
})();
|