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