Files
Poker/server/review/analyze.js
T
agomizi1cmandClaude Opus 5.5 3ddb0996e0 Switch hand ranges to Poker Skill's 6-max charts with 3bet/call frames
- Replace the Yokosawa ranges with the 6-max opening ranges (UTG, HJ, CO,
  SB, BTN). Colors keep the old order: navy (strong UTG), red UTG, yellow
  HJ, green CO, blue SB, white BTN. SB is narrower than BTN, so blue sits
  one tier above white; the purple-frame category is gone.
- Show the "BTN vs CO open" chart on the range page as frames: light blue
  for 3bet, yellow for call.
- Grade BTN facing an unlimped CO open by those frames in reviews; other
  spots keep the color-tier rule (BB defends down to green).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:21:39 +09:00

276 lines
11 KiB
JavaScript

'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 = { 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 };