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 };
|
||||
@@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
|
||||
// レビュー用の勝率計算(@pokertools/evaluator の役判定を使う。役の強さのスコアは小さいほど強い)
|
||||
// rangeEquity : 相手ごとの推定レンジ(手札の組み合わせの一覧)に対する勝率(モンテカルロ法)
|
||||
// showdownShare: 実際の手札どうしで、残りの場札をすべて(多いときはサンプリングで)めくったときの取り分
|
||||
const { evaluate, getCardCode } = require('@pokertools/evaluator');
|
||||
const HandRange = require('../../admin/range-data');
|
||||
|
||||
const codes = (cards) => cards.map(getCardCode);
|
||||
|
||||
// 1326通りの手札。段階(色)ごとに使う
|
||||
const COMBOS = [];
|
||||
{
|
||||
const ranks = 'AKQJT98765432';
|
||||
const suits = 'shdc';
|
||||
const deck = [];
|
||||
for (const r of ranks) for (const s of suits) deck.push(r + s);
|
||||
for (let i = 0; i < deck.length; i++) {
|
||||
for (let j = i + 1; j < deck.length; j++) {
|
||||
const cards = [deck[i], deck[j]];
|
||||
COMBOS.push({ c: codes(cards), tier: HandRange.tierOf(HandRange.handKey(cards)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// minTier 以上の色の手(0 ならすべて)から、dead のカードを含むものを除く
|
||||
function rangeOf(minTier, dead) {
|
||||
const d = new Set(dead);
|
||||
return COMBOS.filter((x) => x.tier >= minTier && !d.has(x.c[0]) && !d.has(x.c[1])).map((x) => x.c);
|
||||
}
|
||||
|
||||
// いまの場札での役の強さの上位 fraction だけ残す(ベット・レイズされるほど強い手に絞る)
|
||||
function narrow(range, board, fraction) {
|
||||
if (fraction >= 1 || board.length < 3 || range.length === 0) return range;
|
||||
const scored = range.map((c) => ({ c, v: evaluate(c.concat(board)) }));
|
||||
scored.sort((a, b) => a.v - b.v);
|
||||
const keep = Math.max(1, Math.ceil(scored.length * fraction));
|
||||
return scored.slice(0, keep).map((x) => x.c);
|
||||
}
|
||||
|
||||
// hero の手札 vs 相手ごとのレンジ。引き分けは等分。ranges が空なら 1
|
||||
function rangeEquity(hero, board, ranges, { iterations = 1200, rng = Math.random } = {}) {
|
||||
const live = ranges.filter((r) => r.length > 0);
|
||||
if (live.length === 0) return 1;
|
||||
const fixed = hero.concat(board);
|
||||
let score = 0;
|
||||
let n = 0;
|
||||
const used = new Uint8Array(52);
|
||||
for (let it = 0; it < iterations; it++) {
|
||||
used.fill(0);
|
||||
for (const c of fixed) used[c] = 1;
|
||||
const opp = [];
|
||||
let ok = true;
|
||||
for (const r of live) {
|
||||
let pick = null;
|
||||
for (let tries = 0; tries < 30 && !pick; tries++) {
|
||||
const c = r[Math.floor(rng() * r.length)];
|
||||
if (!used[c[0]] && !used[c[1]]) pick = c;
|
||||
}
|
||||
if (!pick) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
used[pick[0]] = used[pick[1]] = 1;
|
||||
opp.push(pick);
|
||||
}
|
||||
if (!ok) continue;
|
||||
const full = board.slice();
|
||||
while (full.length < 5) {
|
||||
const c = Math.floor(rng() * 52);
|
||||
if (!used[c]) {
|
||||
used[c] = 1;
|
||||
full.push(c);
|
||||
}
|
||||
}
|
||||
const me = evaluate(hero.concat(full));
|
||||
let ties = 0;
|
||||
let lost = false;
|
||||
for (const o of opp) {
|
||||
const v = evaluate(o.concat(full));
|
||||
if (v < me) {
|
||||
lost = true;
|
||||
break;
|
||||
}
|
||||
if (v === me) ties++;
|
||||
}
|
||||
if (!lost) score += 1 / (ties + 1);
|
||||
n++;
|
||||
}
|
||||
return n ? score / n : 0.5;
|
||||
}
|
||||
|
||||
// 残りの場札の組み合わせ。2枚以下ならすべて、3枚以上はサンプリング
|
||||
function* runouts(board, dead, samples, rng) {
|
||||
const need = 5 - board.length;
|
||||
const used = new Set(board.concat(dead));
|
||||
const deck = [];
|
||||
for (let c = 0; c < 52; c++) if (!used.has(c)) deck.push(c);
|
||||
if (need === 0) {
|
||||
yield board;
|
||||
} else if (need === 1) {
|
||||
for (const a of deck) yield board.concat([a]);
|
||||
} else if (need === 2) {
|
||||
for (let i = 0; i < deck.length; i++) for (let j = i + 1; j < deck.length; j++) yield board.concat([deck[i], deck[j]]);
|
||||
} else {
|
||||
for (let k = 0; k < samples; k++) {
|
||||
for (let i = 0; i < need; i++) {
|
||||
const j = i + Math.floor(rng() * (deck.length - i));
|
||||
[deck[i], deck[j]] = [deck[j], deck[i]];
|
||||
}
|
||||
yield board.concat(deck.slice(0, need));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// オールインの時点で、hero がポットごとに受け取る額の期待値と勝率
|
||||
// hands: 席番号 → 手札("As" 形式)。pots: [{ amount, eligible: [席番号] }]
|
||||
function showdownShare(heroSeat, hands, boardCards, pots, { samples = 4000, rng = Math.random } = {}) {
|
||||
const board = codes(boardCards);
|
||||
const seats = [...hands.keys()];
|
||||
const hole = new Map(seats.map((s) => [s, codes(hands.get(s))]));
|
||||
const dead = seats.flatMap((s) => hole.get(s));
|
||||
let ev = 0;
|
||||
let equity = 0; // メインポット(全員が対象)での取り分の割合
|
||||
let n = 0;
|
||||
for (const full of runouts(board, dead, samples, rng)) {
|
||||
const score = new Map(seats.map((s) => [s, evaluate(hole.get(s).concat(full))]));
|
||||
pots.forEach((pot, i) => {
|
||||
if (!pot.eligible.includes(heroSeat)) return;
|
||||
const best = Math.min(...pot.eligible.map((s) => score.get(s)));
|
||||
if (score.get(heroSeat) !== best) return;
|
||||
const share = 1 / pot.eligible.filter((s) => score.get(s) === best).length;
|
||||
ev += pot.amount * share;
|
||||
if (i === 0) equity += share;
|
||||
});
|
||||
n++;
|
||||
}
|
||||
return { ev: ev / n, equity: equity / n };
|
||||
}
|
||||
|
||||
module.exports = { codes, rangeOf, narrow, rangeEquity, showdownShare };
|
||||
@@ -0,0 +1,116 @@
|
||||
'use strict';
|
||||
|
||||
// プリフロップの判定: 配られた手が登録してあるハンドレンジの何色で、そのとおりに行動できたか。
|
||||
// 自分より前に誰もコール・レイズしていない: 後ろの人数(自分を含めず、まだ行動していない人)に対応する色
|
||||
// 以上ならレイズ(オープン)、それより弱ければフォールド
|
||||
// 自分より前にコール・レイズした人がいる: その人のレンジを色で推定し(その人の後ろの人数に対応する色)、
|
||||
// 自分の手がその色の一つ上ならコール、二つ上ならレイズ、それより弱ければフォールド
|
||||
// 2人目以降に参加した人のレンジは、コールなら直前までで一番強いレンジの一つ上、レイズなら二つ上と推定する
|
||||
// (同じ決まりで打つ人がコール・レイズできる一番弱い色)。
|
||||
const HandRange = require('../../admin/range-data');
|
||||
|
||||
const TOP_TIER = 7; // 紺
|
||||
|
||||
const ACTION_KIND = { FOLD: 'fold', CHECK: 'check', CALL: 'call', BET: 'raise', RAISE: 'raise' };
|
||||
const ACTION_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
|
||||
|
||||
const VERDICTS = {
|
||||
good: { mark: '◎', score: 100, label: 'レンジどおり' },
|
||||
passive: { mark: '△', score: 50, label: 'レイズできる手でコール(チェック)した' },
|
||||
aggressive: { mark: '△', score: 40, label: 'コールまでの手でレイズした' },
|
||||
loose: { mark: '✕', score: 0, label: 'レンジ外の手で参加した' },
|
||||
tight: { mark: '✕', score: 0, label: '参加できる手で降りた' },
|
||||
needless: { mark: '✕', score: 0, label: 'チェックできるのに降りた' },
|
||||
};
|
||||
|
||||
// 9人のテーブルの呼び方。UTG から CO までは人数に合わせて後ろから当てはめる
|
||||
const MIDDLE_POSITIONS = ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO'];
|
||||
|
||||
function positionOf(hand, seat) {
|
||||
const n = hand.order.length;
|
||||
if (seat === hand.button) return n === 2 ? 'BTN/SB' : 'BTN';
|
||||
if (seat === hand.sbSeat) return 'SB';
|
||||
if (seat === hand.bbSeat) return 'BB';
|
||||
const middle = hand.preflopOrder.slice(0, n - 3);
|
||||
const names = MIDDLE_POSITIONS.slice(-middle.length);
|
||||
return names[middle.indexOf(seat)] || '?';
|
||||
}
|
||||
|
||||
const tierName = (tier) => (tier > TOP_TIER ? 'なし' : HandRange.categoryOfTier(Math.max(0, tier)).name);
|
||||
|
||||
// 推奨: 'raise' / 'call' / 'fold'(チェックできるときの 'fold' と 'call' は 'check')
|
||||
function recommend(tier, situation, entry) {
|
||||
let rec;
|
||||
if (situation.kind === 'open') {
|
||||
rec = situation.behind === 0 ? 'check' : tier >= situation.required ? 'raise' : 'fold';
|
||||
} else {
|
||||
const ref = situation.refTier;
|
||||
if (tier >= ref + 2) rec = 'raise';
|
||||
else if (tier >= ref + 1 || (tier === TOP_TIER && ref >= TOP_TIER)) rec = 'call';
|
||||
else rec = 'fold';
|
||||
}
|
||||
if (rec === 'raise' && !entry.canRaise) rec = 'call';
|
||||
if ((rec === 'call' || rec === 'fold') && entry.canCheck) rec = 'check';
|
||||
return rec;
|
||||
}
|
||||
|
||||
function verdictOf(rec, actual) {
|
||||
if (rec === actual) return 'good';
|
||||
if (actual === 'fold') return rec === 'check' ? 'needless' : 'tight';
|
||||
if (rec === 'raise') return 'passive'; // コール・チェックした
|
||||
if (rec === 'call') return 'aggressive'; // レイズした
|
||||
return 'loose'; // 降りる(チェックする)べき手で参加した
|
||||
}
|
||||
|
||||
// プリフロップの各行動を順に見て、hero の判断ごとに判定する。あわせて各席の推定レンジ(色の段階)を返す
|
||||
function judgePreflop(hand, hero) {
|
||||
const tier = HandRange.tierOf(HandRange.handKey(hand.players.get(hero).cards));
|
||||
const order = hand.preflopOrder;
|
||||
const acted = new Set();
|
||||
const estimated = new Map(); // 席 → 推定レンジの一番弱い色の段階
|
||||
let maxTier = null;
|
||||
let maxSeat = null;
|
||||
const judgments = [];
|
||||
|
||||
// まだ行動していない後ろの人数(自分を含めない)
|
||||
const behindOf = (seat) => order.slice(order.indexOf(seat) + 1).filter((s) => !acted.has(s)).length;
|
||||
|
||||
hand.log.forEach((e, index) => {
|
||||
if (e.street !== 'PREFLOP') return;
|
||||
const kind = ACTION_KIND[e.type];
|
||||
if (e.seat === hero && !e.auto) {
|
||||
const situation =
|
||||
maxTier == null
|
||||
? { kind: 'open', behind: behindOf(hero), required: HandRange.requiredTier(behindOf(hero)) }
|
||||
: { kind: 'facing', refTier: maxTier, refSeat: maxSeat };
|
||||
const rec = recommend(tier, situation, e);
|
||||
const verdict = verdictOf(rec, kind);
|
||||
judgments.push({ index, situation, recommended: rec, actual: kind, verdict, ...VERDICTS[verdict] });
|
||||
}
|
||||
if (kind === 'call' || kind === 'raise') {
|
||||
const est =
|
||||
maxTier == null
|
||||
? HandRange.requiredTier(behindOf(e.seat))
|
||||
: Math.min(TOP_TIER, maxTier + (kind === 'raise' ? 2 : 1));
|
||||
estimated.set(e.seat, Math.max(estimated.get(e.seat) || 0, est));
|
||||
if (maxTier == null || est >= maxTier) {
|
||||
maxTier = est;
|
||||
maxSeat = e.seat;
|
||||
}
|
||||
}
|
||||
acted.add(e.seat);
|
||||
});
|
||||
return { tier, judgments, estimated };
|
||||
}
|
||||
|
||||
// 状況の説明(画面表示用)
|
||||
function describeSituation(situation, nameOf) {
|
||||
if (situation.kind === 'open') {
|
||||
if (situation.behind === 0) return '全員が降りてBBに回ってきた';
|
||||
return `前に参加者なし・後ろに${situation.behind}人 → ${tierName(situation.required)}以上でオープン`;
|
||||
}
|
||||
const ref = situation.refTier;
|
||||
return `${nameOf(situation.refSeat)} の推定レンジ ${tierName(ref)} → ${tierName(ref + 1)}以上でコール、${tierName(ref + 2)}以上でレイズ`;
|
||||
}
|
||||
|
||||
module.exports = { judgePreflop, positionOf, describeSituation, tierName, ACTION_KIND, ACTION_LABEL, VERDICTS };
|
||||
@@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
|
||||
// レビューの保存先(data/reviews/<id>.json)。テーブルを閉じた後も見返せるようにファイルに残す。
|
||||
// 対局中のものはメモリにも置き、ハンドが終わるたびに書き出す
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DIR = process.env.REVIEW_DIR || path.join(__dirname, '..', '..', 'data', 'reviews');
|
||||
const MAX_SESSIONS = 50; // これより古い対局は消す
|
||||
const MAX_HANDS = 1000; // 1回の対局で保存するハンド数の上限
|
||||
|
||||
const active = new Map(); // id → 対局(メモリ上)
|
||||
const writing = new Map(); // id → 書き出し中の Promise(順番に書く)
|
||||
|
||||
const validId = (id) => typeof id === 'string' && /^[0-9a-f]{16}$/.test(id);
|
||||
const fileOf = (id) => path.join(DIR, `${id}.json`);
|
||||
|
||||
function create({ tableName, heroName, startingStack }) {
|
||||
const session = {
|
||||
id: crypto.randomBytes(8).toString('hex'),
|
||||
tableName,
|
||||
heroName,
|
||||
startingStack,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
hands: [],
|
||||
};
|
||||
active.set(session.id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
function addHand(session, record) {
|
||||
if (session.hands.length >= MAX_HANDS) return;
|
||||
session.hands.push(record);
|
||||
session.updatedAt = Date.now();
|
||||
save(session);
|
||||
}
|
||||
|
||||
function save(session) {
|
||||
const prev = writing.get(session.id) || Promise.resolve();
|
||||
const next = prev
|
||||
.then(async () => {
|
||||
await fs.promises.mkdir(DIR, { recursive: true, mode: 0o700 });
|
||||
const tmp = `${fileOf(session.id)}.tmp`;
|
||||
await fs.promises.writeFile(tmp, JSON.stringify(session), { mode: 0o600 });
|
||||
await fs.promises.rename(tmp, fileOf(session.id));
|
||||
await prune();
|
||||
})
|
||||
.catch((err) => console.error('[review] 保存に失敗しました', err.message));
|
||||
writing.set(session.id, next);
|
||||
next.then(() => {
|
||||
if (writing.get(session.id) === next) writing.delete(session.id);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
// テーブルが閉じられたらメモリから外す(ファイルには残る)
|
||||
function close(session) {
|
||||
active.delete(session.id);
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
let files;
|
||||
try {
|
||||
files = await fs.promises.readdir(DIR);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
for (const f of files) {
|
||||
const id = f.replace(/\.json$/, '');
|
||||
if (!f.endsWith('.json') || !validId(id)) continue;
|
||||
const s = await get(id);
|
||||
if (s) out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 古い対局のファイルを消す(更新日時で判断する。中身は読まない)
|
||||
async function prune() {
|
||||
let files;
|
||||
try {
|
||||
files = (await fs.promises.readdir(DIR)).filter((f) => f.endsWith('.json'));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (files.length <= MAX_SESSIONS) return;
|
||||
const stats = [];
|
||||
for (const f of files) {
|
||||
const st = await fs.promises.stat(path.join(DIR, f)).catch(() => null);
|
||||
if (st) stats.push({ f, mtime: st.mtimeMs });
|
||||
}
|
||||
stats.sort((a, b) => b.mtime - a.mtime);
|
||||
for (const { f } of stats.slice(MAX_SESSIONS)) {
|
||||
if (active.has(f.replace(/\.json$/, ''))) continue;
|
||||
await fs.promises.unlink(path.join(DIR, f)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function get(id) {
|
||||
if (!validId(id)) return null;
|
||||
if (active.has(id)) return active.get(id);
|
||||
try {
|
||||
return JSON.parse(await fs.promises.readFile(fileOf(id), 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 一覧(新しい順)。中身のハンドは含めない
|
||||
async function list() {
|
||||
const all = await readAll();
|
||||
for (const s of active.values()) if (!all.some((x) => x.id === s.id)) all.push(s);
|
||||
return all
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
tableName: s.tableName,
|
||||
heroName: s.heroName,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
hands: s.hands.length,
|
||||
active: active.has(s.id),
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = { create, addHand, close, get, list, validId, DIR };
|
||||
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
// 1回の対局(レビュー付きCPU戦のテーブル1つ)のハンドのレビューをまとめる
|
||||
// 成績(VPIP・PFR・3bet・Cベット・WTSD・W$SD・AF)、ストリートごとの平均点、
|
||||
// 色ごとの参加率とレンジどおりに打てた割合、ポジションごとの傾向とクセ、見直したいハンド、収支の推移
|
||||
const HandRange = require('../../admin/range-data');
|
||||
const { STREETS, COLOR_LABEL } = require('./analyze');
|
||||
|
||||
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
|
||||
const mean = (xs) => (xs.length ? Math.round(xs.reduce((x, y) => x + y, 0) / xs.length) : null);
|
||||
|
||||
const POSITION_ORDER = ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO', 'BTN', 'BTN/SB', 'SB', 'BB'];
|
||||
|
||||
const STREET_LABEL = { FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
|
||||
|
||||
// クセとして数える判定と、その説明
|
||||
const LEAK_TEXT = {
|
||||
loose: 'レンジ外の手で参加',
|
||||
tight: '参加できる手で降りた',
|
||||
passive: 'レイズできる手でコール',
|
||||
aggressive: 'コールまでの手でレイズ',
|
||||
needless: 'チェックできるのに降りた',
|
||||
};
|
||||
|
||||
function summarize(hands) {
|
||||
const n = hands.length;
|
||||
const count = (f) => hands.filter(f).length;
|
||||
const stat = (key) => count((h) => h.stats[key]);
|
||||
|
||||
const sawFlop = stat('sawFlop');
|
||||
const showdowns = stat('showdown');
|
||||
const aggressive = hands.reduce((a, h) => a + h.stats.aggressive, 0);
|
||||
const calls = hands.reduce((a, h) => a + h.stats.calls, 0);
|
||||
const stats = {
|
||||
hands: n,
|
||||
vpip: pct(stat('vpip'), n),
|
||||
pfr: pct(stat('pfr'), n),
|
||||
threeBet: pct(stat('threeBet'), stat('threeBetOpp')),
|
||||
threeBetOpp: stat('threeBetOpp'),
|
||||
cbet: pct(stat('cbet'), stat('cbetOpp')),
|
||||
cbetOpp: stat('cbetOpp'),
|
||||
wtsd: pct(showdowns, sawFlop),
|
||||
wsd: pct(stat('wonShowdown'), showdowns),
|
||||
af: calls ? Math.round((aggressive / calls) * 100) / 100 : aggressive ? null : 0,
|
||||
};
|
||||
|
||||
const streets = {};
|
||||
for (const st of STREETS) streets[st] = mean(hands.map((h) => h.streetScores[st]).filter((x) => x != null));
|
||||
const total = mean(hands.map((h) => h.handScore).filter((x) => x != null));
|
||||
|
||||
// 色ごと: 配られた回数・参加した回数・プリフロップの判断がレンジどおりだった割合
|
||||
const colors = HandRange.CATEGORIES.map((c) => {
|
||||
const hs = hands.filter((h) => h.hero.color === c.id);
|
||||
const judged = hs.flatMap((h) => h.preflop);
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
label: COLOR_LABEL[c.id] || c.label,
|
||||
dealt: hs.length,
|
||||
played: hs.filter((h) => h.stats.vpip).length,
|
||||
judged: judged.length,
|
||||
good: judged.filter((j) => j.verdict === 'good').length,
|
||||
};
|
||||
});
|
||||
|
||||
// ポジションごと
|
||||
const byPos = new Map();
|
||||
for (const h of hands) {
|
||||
const pos = h.hero.position;
|
||||
if (!byPos.has(pos)) byPos.set(pos, { position: pos, hands: 0, played: 0, judged: 0, good: 0, verdicts: {} });
|
||||
const p = byPos.get(pos);
|
||||
p.hands++;
|
||||
if (h.stats.vpip) p.played++;
|
||||
for (const j of h.preflop) {
|
||||
p.judged++;
|
||||
if (j.verdict === 'good') p.good++;
|
||||
else p.verdicts[j.verdict] = (p.verdicts[j.verdict] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const positions = [...byPos.values()].sort((a, b) => POSITION_ORDER.indexOf(a.position) - POSITION_ORDER.indexOf(b.position));
|
||||
|
||||
// クセ: ポジション × 間違いの種類で多いもの。フロップ以降は失った期待値が大きいストリート × 行動
|
||||
const leaks = [];
|
||||
for (const p of positions) {
|
||||
for (const [v, k] of Object.entries(p.verdicts)) leaks.push({ text: `${p.position}: ${LEAK_TEXT[v]}`, count: k, of: p.judged });
|
||||
}
|
||||
const postLeaks = new Map();
|
||||
for (const h of hands) {
|
||||
for (const d of h.decisions) {
|
||||
if (d.score >= 70) continue;
|
||||
const key = `${d.street}:${d.actual.kind}`;
|
||||
if (!postLeaks.has(key)) postLeaks.set(key, { street: d.street, kind: d.actual.kind, count: 0, lossBB: 0 });
|
||||
const x = postLeaks.get(key);
|
||||
x.count++;
|
||||
x.lossBB += d.lossBB;
|
||||
}
|
||||
}
|
||||
const KIND = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'ベット・レイズ' };
|
||||
for (const x of postLeaks.values()) {
|
||||
leaks.push({ text: `${STREET_LABEL[x.street]}: ${KIND[x.kind]}で期待値を失った(合計 ${Math.round(x.lossBB * 10) / 10}BB)`, count: x.count });
|
||||
}
|
||||
leaks.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 見直したいハンド: 失った期待値(BB)が大きい順、同じならプリフロップの判定が悪い順
|
||||
const worst = hands
|
||||
.filter((h) => h.lossBB > 0 || (h.handScore != null && h.handScore < 100))
|
||||
.map((h) => ({
|
||||
number: h.number,
|
||||
key: h.hero.key,
|
||||
color: h.hero.color,
|
||||
position: h.hero.position,
|
||||
lossBB: h.lossBB,
|
||||
handScore: h.handScore,
|
||||
net: h.hero.net,
|
||||
bb: h.bb,
|
||||
}))
|
||||
.sort((a, b) => b.lossBB - a.lossBB || a.handScore - b.handScore)
|
||||
.slice(0, 5);
|
||||
|
||||
// 収支の推移(実際の収支と、オールインの期待値で運を除いた収支)
|
||||
let net = 0;
|
||||
let evNet = 0;
|
||||
const graph = hands.map((h) => {
|
||||
net += h.hero.net;
|
||||
evNet += h.hero.evNet;
|
||||
return { number: h.number, net, evNet };
|
||||
});
|
||||
|
||||
return { stats, streets, total, colors, positions, leaks: leaks.slice(0, 6), worst, graph };
|
||||
}
|
||||
|
||||
module.exports = { summarize };
|
||||
Reference in new issue
Block a user