- 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>
276 lines
11 KiB
JavaScript
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 };
|