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,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 };
|
||||
Reference in new issue
Block a user