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
@@ -0,0 +1,275 @@
|
||||
'use strict';
|
||||
|
||||
// 1ハンドのレビュー。ハンドが終わったら hero(レビューを受ける人)について作る。
|
||||
// プリフロップ: 登録してあるハンドレンジの色と、そのとおりに行動できたか(preflop.js)
|
||||
// フロップ以降: 相手の推定レンジに対する勝率から、選択肢ごとの期待値(BB)を見積もり、
|
||||
// 一番良い選択肢との差(失った期待値)を点数にする。GTO ではなく目安
|
||||
// オールイン: 実際の手札どうしの勝率から、収支の期待値(運の影響を除いた収支)を出す
|
||||
const HandRange = require('../../admin/range-data');
|
||||
const { judgePreflop, positionOf, describeSituation, ACTION_KIND } = require('./preflop');
|
||||
const { codes, rangeOf, narrow, rangeEquity, showdownShare } = require('./equity');
|
||||
|
||||
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
|
||||
// レビューで色に添える説明(クイズ用の分類名がそのままでは分かりにくいもの)
|
||||
const COLOR_LABEL = { purple: '1人(SBのみ)', gray: 'レンジ外' };
|
||||
|
||||
// 失った期待値がポット(コール後)のこの割合までは誤差として満点にする
|
||||
const TOLERANCE = 0.03;
|
||||
// 失った期待値がポット(コール後)の 1% 増えるごとに減らす点数
|
||||
const PENALTY = 2.5;
|
||||
// 相手が降りる割合: ベットが損をしない最低限のフォールド率(risk / (risk + reward))より少し低めに見て、
|
||||
// 相手が多いほど全員は降りにくくする(ただのブラフが得にならないように)
|
||||
const FOLD_REALISM = 0.7;
|
||||
const FOLD_MULTIWAY = 0.85;
|
||||
|
||||
const markOf = (score) => (score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕');
|
||||
const round1 = (x) => Math.round(x * 10) / 10;
|
||||
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
||||
|
||||
// index より前に降りた席
|
||||
function foldedBefore(log, index) {
|
||||
const s = new Set();
|
||||
for (let i = 0; i < index; i++) if (log[i].type === 'FOLD') s.add(log[i].seat);
|
||||
return s;
|
||||
}
|
||||
|
||||
// 相手の推定レンジ: プリフロップの色(参加しなかった BB などは全部の手)から、
|
||||
// フロップ以降にベット・レイズするたびに強い手(いまの場札での役)に絞る。コールでも少し絞る
|
||||
function opponentRanges(hand, hero, index, estimated, board, dead) {
|
||||
const log = hand.log;
|
||||
const folded = foldedBefore(log, index);
|
||||
const ranges = [];
|
||||
for (const s of hand.order) {
|
||||
if (s === hero || folded.has(s)) continue;
|
||||
let aggr = 0;
|
||||
let calls = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
const e = log[i];
|
||||
if (e.seat !== s || e.street === 'PREFLOP') continue;
|
||||
if (e.type === 'BET' || e.type === 'RAISE') aggr++;
|
||||
else if (e.type === 'CALL') calls++;
|
||||
}
|
||||
const fraction = Math.max(0.08, 0.55 ** aggr * 0.8 ** calls);
|
||||
ranges.push(narrow(rangeOf(estimated.get(s) || 0, dead), board, fraction));
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
// フロップ以降の1回の判断。選択肢ごとの期待値(いま降りたときを 0 とするチップ)
|
||||
function evaluateDecision(hand, hero, index, estimated, opts) {
|
||||
const e = hand.log[index];
|
||||
const heroCards = codes(hand.players.get(hero).cards);
|
||||
const board = codes(e.board);
|
||||
const dead = heroCards.concat(board);
|
||||
const ranges = opponentRanges(hand, hero, index, estimated, board, dead);
|
||||
const eq = rangeEquity(heroCards, board, ranges, opts);
|
||||
const P = e.pot;
|
||||
const t = e.toCall;
|
||||
|
||||
const options = [];
|
||||
if (t > 0) {
|
||||
options.push({ kind: 'fold', ev: 0 });
|
||||
options.push({ kind: 'call', ev: eq * (P + t) - t });
|
||||
}
|
||||
if (e.canCheck) options.push({ kind: 'check', ev: eq * P });
|
||||
if (e.canRaise) {
|
||||
const clamp = (v) => Math.min(e.maxRaiseTo, Math.max(e.minRaiseTo, Math.round(v)));
|
||||
const sizes = new Set([clamp(e.currentBet === 0 ? P * 0.66 : e.currentBet * 3)]);
|
||||
if (ACTION_KIND[e.type] === 'raise') sizes.add(e.amount);
|
||||
for (const to of sizes) {
|
||||
const r = to - e.bet; // 自分が出す額
|
||||
const c = to - e.currentBet; // コールする人が出す額(直前のベットからの差)
|
||||
// コールされたら、降りなかった強い側のレンジと勝負する
|
||||
const alpha = r / (P + r);
|
||||
const foldAll = FOLD_REALISM * alpha * FOLD_MULTIWAY ** Math.max(0, ranges.length - 1);
|
||||
const eqCalled = rangeEquity(heroCards, board, ranges.map((rg) => narrow(rg, board, 1 - alpha)), opts);
|
||||
options.push({ kind: 'raise', to, ev: foldAll * P + (1 - foldAll) * (eqCalled * (P + r + c) - r) });
|
||||
}
|
||||
}
|
||||
|
||||
const kind = ACTION_KIND[e.type];
|
||||
const actual = options.find((o) => o.kind === kind && (kind !== 'raise' || o.to === e.amount)) || options[0];
|
||||
const best = options.reduce((a, b) => (b.ev > a.ev ? b : a));
|
||||
const loss = Math.max(0, best.ev - actual.ev);
|
||||
const base = P + t;
|
||||
const score = Math.max(0, Math.min(100, Math.round(100 - (PENALTY * 100 * Math.max(0, loss - TOLERANCE * base)) / base)));
|
||||
const bb = hand.bb;
|
||||
return {
|
||||
index,
|
||||
street: e.street,
|
||||
equity: round1(eq * 100),
|
||||
need: t > 0 ? round1((t / base) * 100) : null,
|
||||
options: options.map((o) => ({ kind: o.kind, to: o.to, evBB: round1(o.ev / bb) })),
|
||||
best: { kind: best.kind, to: best.to },
|
||||
actual: { kind: actual.kind, to: actual.to },
|
||||
lossBB: round1(loss / bb),
|
||||
score,
|
||||
mark: markOf(score),
|
||||
};
|
||||
}
|
||||
|
||||
// プリフロップでコール・レイズに直面したときの参考: 参加者の推定レンジに対する勝率と必要勝率
|
||||
function preflopOdds(hand, hero, j, estimated, opts) {
|
||||
const e = hand.log[j.index];
|
||||
if (!(e.toCall > 0) || j.situation.kind !== 'facing') return {};
|
||||
const heroCards = codes(hand.players.get(hero).cards);
|
||||
const folded = foldedBefore(hand.log, j.index);
|
||||
const ranges = [];
|
||||
for (let i = 0; i < j.index; i++) {
|
||||
const x = hand.log[i];
|
||||
const k = ACTION_KIND[x.type];
|
||||
if (x.street !== 'PREFLOP' || x.seat === hero || folded.has(x.seat) || (k !== 'call' && k !== 'raise')) continue;
|
||||
if (ranges.some((r) => r.seat === x.seat)) continue;
|
||||
ranges.push({ seat: x.seat, range: rangeOf(estimated.get(x.seat) || 0, heroCards) });
|
||||
}
|
||||
if (!ranges.length) return {};
|
||||
const eq = rangeEquity(heroCards, [], ranges.map((r) => r.range), opts);
|
||||
return { equity: round1(eq * 100), need: round1((e.toCall / (e.pot + e.toCall)) * 100) };
|
||||
}
|
||||
|
||||
// hero の統計用の記録(VPIP・PFR・3bet・Cベットなど)
|
||||
function statsOf(hand, hero) {
|
||||
const mine = hand.log.filter((e) => e.seat === hero && !e.auto);
|
||||
const pre = mine.filter((e) => e.street === 'PREFLOP');
|
||||
const post = mine.filter((e) => e.street !== 'PREFLOP');
|
||||
const me = hand.players.get(hero);
|
||||
const foldedPre = hand.log.some((e) => e.seat === hero && e.street === 'PREFLOP' && e.type === 'FOLD');
|
||||
const sawFlop = !foldedPre && hand.board.length >= 3;
|
||||
|
||||
// 3bet の機会: プリフロップでレイズがちょうど1回あった後の判断
|
||||
let threeBetOpp = false;
|
||||
let threeBet = false;
|
||||
let raises = 0;
|
||||
let lastRaiser = null;
|
||||
for (const e of hand.log) {
|
||||
if (e.street !== 'PREFLOP') continue;
|
||||
if (e.seat === hero && !e.auto && raises === 1 && !threeBetOpp) {
|
||||
threeBetOpp = true;
|
||||
threeBet = ACTION_KIND[e.type] === 'raise';
|
||||
}
|
||||
if (ACTION_KIND[e.type] === 'raise') {
|
||||
raises++;
|
||||
lastRaiser = e.seat;
|
||||
}
|
||||
}
|
||||
// Cベット: プリフロップの最後のレイザーが、フロップで誰もベットしていないときにベットしたか
|
||||
const firstFlop = post.find((e) => e.street === 'FLOP');
|
||||
const cbetOpp = sawFlop && lastRaiser === hero && Boolean(firstFlop) && firstFlop.currentBet === 0 && firstFlop.canRaise;
|
||||
const showdown = hand.showdown && !me.folded;
|
||||
return {
|
||||
vpip: pre.some((e) => e.type === 'CALL' || e.type === 'BET' || e.type === 'RAISE'),
|
||||
pfr: pre.some((e) => e.type === 'BET' || e.type === 'RAISE'),
|
||||
threeBetOpp,
|
||||
threeBet,
|
||||
sawFlop,
|
||||
cbetOpp,
|
||||
cbet: cbetOpp && firstFlop.type === 'BET',
|
||||
aggressive: post.filter((e) => e.type === 'BET' || e.type === 'RAISE').length,
|
||||
calls: post.filter((e) => e.type === 'CALL').length,
|
||||
showdown,
|
||||
wonShowdown: showdown && me.won > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// names: 席番号 → 名前、bots: CPU の席の Set
|
||||
function analyzeHand(hand, hero, { names, bots, iterations = 1200, rng = Math.random } = {}) {
|
||||
const opts = { iterations, rng };
|
||||
const nameOf = (s) => (names && names.get(s)) || `席${s + 1}`;
|
||||
const me = hand.players.get(hero);
|
||||
const key = HandRange.handKey(me.cards);
|
||||
const cat = HandRange.category(HandRange.categoryOf(key));
|
||||
const { judgments, estimated } = judgePreflop(hand, hero);
|
||||
|
||||
const preflop = judgments.map((j) => ({
|
||||
index: j.index,
|
||||
situation: describeSituation(j.situation, nameOf),
|
||||
recommended: j.recommended,
|
||||
actual: j.actual,
|
||||
verdict: j.verdict,
|
||||
label: j.label,
|
||||
mark: j.mark,
|
||||
score: j.score,
|
||||
...preflopOdds(hand, hero, j, estimated, opts),
|
||||
}));
|
||||
|
||||
const decisions = [];
|
||||
hand.log.forEach((e, i) => {
|
||||
if (e.seat === hero && !e.auto && e.street !== 'PREFLOP') decisions.push(evaluateDecision(hand, hero, i, estimated, opts));
|
||||
});
|
||||
|
||||
const streetScores = {};
|
||||
for (const st of STREETS) {
|
||||
const scores = (st === 'PREFLOP' ? preflop : decisions.filter((d) => d.street === st)).map((d) => d.score);
|
||||
streetScores[st] = scores.length ? Math.round(mean(scores)) : null;
|
||||
}
|
||||
const scored = STREETS.map((st) => streetScores[st]).filter((x) => x != null);
|
||||
|
||||
// 収支と、オールインで決着したときの期待値
|
||||
const net = hand.endStacks.get(hero) - hand.startStacks.get(hero);
|
||||
let allIn = null;
|
||||
if (hand.allIn && !me.folded) {
|
||||
const hands = new Map(hand.live().map((p) => [p.seat, p.cards]));
|
||||
const { ev, equity } = showdownShare(hero, hands, hand.allIn.board, hand.allIn.pots, { rng });
|
||||
allIn = { street: hand.allIn.board.length === 0 ? 'PREFLOP' : hand.allIn.board.length === 3 ? 'FLOP' : 'TURN', equity: round1(equity * 100), ev: Math.round(ev), won: me.won };
|
||||
}
|
||||
const evNet = allIn ? Math.round(net - me.won + allIn.ev) : net;
|
||||
|
||||
return {
|
||||
number: hand.number,
|
||||
at: Date.now(),
|
||||
sb: hand.sb,
|
||||
bb: hand.bb,
|
||||
button: hand.button,
|
||||
sbSeat: hand.sbSeat,
|
||||
bbSeat: hand.bbSeat,
|
||||
heroSeat: hero,
|
||||
players: hand.order.map((s) => {
|
||||
const p = hand.players.get(s);
|
||||
return {
|
||||
seat: s,
|
||||
name: nameOf(s),
|
||||
bot: Boolean(bots && bots.has(s)),
|
||||
position: positionOf(hand, s),
|
||||
cards: p.cards.slice(),
|
||||
start: hand.startStacks.get(s),
|
||||
end: hand.endStacks.get(s),
|
||||
won: p.won,
|
||||
folded: p.folded,
|
||||
};
|
||||
}),
|
||||
actions: hand.log.map((e) => ({
|
||||
street: e.street,
|
||||
seat: e.seat,
|
||||
type: e.type,
|
||||
amount: e.amount,
|
||||
added: e.added,
|
||||
allIn: Boolean(e.allIn),
|
||||
auto: Boolean(e.auto),
|
||||
pot: e.pot,
|
||||
toCall: e.toCall || 0,
|
||||
})),
|
||||
board: hand.board.slice(),
|
||||
results: hand.results,
|
||||
showdown: hand.showdown,
|
||||
hero: {
|
||||
cards: me.cards.slice(),
|
||||
key,
|
||||
color: cat.id,
|
||||
colorName: cat.name,
|
||||
colorLabel: COLOR_LABEL[cat.id] || cat.label,
|
||||
tier: cat.tier,
|
||||
position: positionOf(hand, hero),
|
||||
net,
|
||||
evNet,
|
||||
allIn,
|
||||
},
|
||||
preflop,
|
||||
decisions,
|
||||
streetScores,
|
||||
handScore: scored.length ? Math.round(mean(scored)) : null,
|
||||
lossBB: round1(decisions.reduce((a, d) => a + d.lossBB, 0)),
|
||||
stats: statsOf(hand, hero),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { analyzeHand, STREETS, COLOR_LABEL, markOf };
|
||||
Reference in new issue
Block a user