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:
agomizi1cmandClaude Opus 5.5 committed 2026-10-19 22:11:16 +09:00
1 parent 3116b1da9f
commit 3f2515a9fa
25 files changed
+2262 -60

No files matched your search

+6 -1
View File
@@ -71,8 +71,12 @@ function isAuthed(req) {
return isAuthedCookieHeader(req.headers.cookie);
}
function isAdminCookieHeader(cookieHeader) {
return roleOf(cookieHeader) === 'admin';
}
function isAdmin(req) {
return roleOf(req.headers.cookie) === 'admin';
return isAdminCookieHeader(req.headers.cookie);
}
function setAuthCookie(req, res, role) {
@@ -232,6 +236,7 @@ module.exports = {
isAuthed,
isAdmin,
isAuthedCookieHeader,
isAdminCookieHeader,
isSameOrigin,
isAllowedHost,
requireAllowedHost,
+2
View File
@@ -73,6 +73,8 @@ module.exports = {
BOT_FAST_MS: 400,
// CPU が勝率を見積もるときのシミュレーション回数
BOT_ITERATIONS: 800,
// レビューで勝率を見積もるときのシミュレーション回数(1回の判断・選択肢ごと)
REVIEW_ITERATIONS: 1200,
// 参加者が2人揃ってから次のハンドを始めるまで
NEXT_HAND_DELAY_MS: 2000,
+37 -4
View File
@@ -52,6 +52,10 @@ class Hand {
this.pots = null; // ショーダウンのポット(同じ参加者のポットはまとめる)
this.engineWon = null; // エンジンが分配した額(席番号 → 額)
this.taken = new Map(); // ハンド中に退席した人が持ち出したチップ(エンジンから外れた分)
// アクションの記録(レビュー用)。1手ごとに、そのときの状況と行動を残す
this.log = [];
this.startStacks = new Map(order.map((s) => [s, seats[s].stack])); // ブラインドを払う前
this.allIn = null; // オールインで決着した時点の場札とポット(レビューの期待値用)
this.players = new Map();
for (const i of order) {
@@ -73,6 +77,8 @@ class Hand {
const headsUp = n === 2;
this.sbSeat = headsUp ? button : order[0];
this.bbSeat = headsUp ? order[0] : order[1];
// プリフロップに行動する順(UTG から BB まで)
this.preflopOrder = headsUp ? [button, order[0]] : order.slice(2).concat(order.slice(0, 2));
this.engine.deal();
this.sync();
}
@@ -174,7 +180,10 @@ class Hand {
}
// 手札を公開する。場札が残っていたら Table のタイマーで順にめくる(runoutStep)
this.revealed = true;
if (this.board.length < 5) this.runout = true;
if (this.board.length < 5) {
this.runout = true;
this.allIn = { board: this.board.slice(), pots: this.pots };
}
else this.finishShowdown();
}
@@ -229,7 +238,8 @@ class Hand {
}
// type: fold / check / call / raise(amount = このストリートでの合計額 "raise to")
act(seat, type, amount) {
// auto: 離席・退席などで自動で行動した(レビューでは本人の判断として扱わない)
act(seat, type, amount, auto = false) {
const o = this.options(seat);
if (!o) return 'あなたの番ではありません';
const p = this.players.get(seat);
@@ -254,6 +264,26 @@ class Hand {
}
const allIn = type !== 'fold' && type !== 'check' && last.amount === o.maxRaiseTo;
const fullRaise = type === 'raise' && amount >= this.state.minRaise;
const entry = {
street: this.street,
seat,
type: last.type,
amount: last.amount || 0, // このストリートでの合計額
added: type === 'call' ? o.toCall : type === 'raise' ? amount - p.bet : 0, // この行動で出した額
allIn,
auto,
pot: this.potTotal, // 行動する前のポット(このストリートのベットを含む)
toCall: o.toCall,
currentBet: this.currentBet,
bet: p.bet,
stack: this.seats[seat].stack,
board: this.board.slice(),
canCheck: o.canCheck,
canRaise: o.canRaise,
minRaiseTo: o.minRaiseTo,
maxRaiseTo: o.maxRaiseTo,
live: this.live().length,
};
try {
this.engine.act(this.engineAction(seat, type, amount));
} catch {
@@ -261,6 +291,7 @@ class Hand {
return type === 'fold' && o.canCheck ? 'いまはフォールドできません。チェックしてください' : 'そのアクションはできません';
}
p.last = allIn ? { type: 'ALLIN', amount: last.amount } : last;
this.log.push(entry);
this.actions++;
if (type === 'raise') {
this.aggressor = seat;
@@ -272,7 +303,7 @@ class Hand {
autoAct() {
const o = this.options(this.toAct);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0, true);
}
// 退席などで手番に関係なくフォールドさせる。オールインの人の手は最後まで生きている
@@ -280,7 +311,7 @@ class Hand {
const p = this.players.get(seat);
if (!p || p.folded || this.finished) return;
if (this.toAct === seat) {
if (this.act(seat, 'fold', 0)) this.autoAct();
if (this.act(seat, 'fold', 0, true)) this.autoAct();
return;
}
if (this.settled) {
@@ -308,6 +339,7 @@ class Hand {
this.engine.stand(String(seat));
this.taken.set(seat, (this.taken.get(seat) || 0) + before - this.state.initialChips);
p.last = { type: 'FOLD' };
this.log.push({ street: this.street, seat, type: 'FOLD', amount: 0, added: 0, auto: true, pot: this.potTotal, board: this.board.slice() });
this.sync();
}
@@ -381,6 +413,7 @@ class Hand {
}
end(showdown, results) {
this.endStacks = new Map(this.order.map((s) => [s, this.seats[s] ? this.seats[s].stack : 0])); // レビュー用
this.finished = true;
this.runout = false;
this.showdown = showdown;
+43 -8
View File
@@ -7,6 +7,8 @@ const { Server } = require('socket.io');
const config = require('./config');
const auth = require('./auth');
const { TableManager, MODES } = require('./tables');
const reviews = require('./review/store');
const { summarize } = require('./review/summary');
const PUBLIC = path.join(__dirname, '..', 'public');
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
@@ -96,12 +98,33 @@ app.use(
index: false,
dotfiles: 'deny',
// カード画像を毎回読み込まないよう、本人のブラウザにだけキャッシュさせる
setHeaders: (res) => res.setHeader('Cache-Control', 'private, max-age=86400'),
// (スクリプトと CSS は更新がすぐ反映されるよう毎回確認させる)
setHeaders: (res, file) =>
res.setHeader('Cache-Control', file.endsWith('.png') ? 'private, max-age=86400' : 'private, no-cache'),
})
);
// ---------- 管理者専用: レビュー付きCPU戦の振り返り ----------
app.get('/review', auth.requireAdmin, page('review.html', ADMIN));
app.get('/api/reviews', auth.requireAdmin, async (req, res, next) => {
try {
res.json(await reviews.list());
} catch (err) {
next(err);
}
});
app.get('/api/reviews/:id', auth.requireAdmin, async (req, res, next) => {
try {
const session = await reviews.get(req.params.id);
if (!session) return res.status(404).json({ error: '見つかりません' });
res.json({ ...session, summary: summarize(session.hands) });
} catch (err) {
next(err);
}
});
app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) }));
app.get('/api/tables', (req, res) => res.json(tables.list()));
app.get('/api/tables', (req, res) => res.json(tables.list(auth.isAdmin(req))));
app.get('/api/tables/suggest-name', (req, res) => res.json({ name: tables.suggestName() }));
const intIn = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
@@ -125,8 +148,12 @@ app.post('/api/tables', (req, res) => {
if (!intIn(smallBlind, 1, startingStack)) return res.status(400).json({ error: 'SBが不正です' });
if (!intIn(bigBlind, smallBlind + 1, startingStack)) return res.status(400).json({ error: 'BBはSBより大きく・開始スタック以下にしてください' });
if (blindTimerEnabled && !intIn(blindIntervalMin, 1, 240)) return res.status(400).json({ error: 'インターバルは1〜240分で入力してください' });
if (!MODES.includes(mode)) return res.status(400).json({ error: 'テーブルの種類が不正です' });
if (mode === 'cpu' && !intIn(cpuCount, 1, config.MAX_SEATS - 1)) {
// レビュー付きCPU戦は管理者だけが作れる
if (!MODES.includes(mode) || (mode === 'review' && !auth.isAdmin(req))) {
return res.status(400).json({ error: 'テーブルの種類が不正です' });
}
const withBots = mode === 'cpu' || mode === 'review';
if (withBots && !intIn(cpuCount, 1, config.MAX_SEATS - 1)) {
return res.status(400).json({ error: `CPU の人数は1〜${config.MAX_SEATS - 1}人にしてください` });
}
if (!hostToken) return res.status(400).json({ error: 'プレイヤーIDがありません' });
@@ -139,7 +166,7 @@ app.post('/api/tables', (req, res) => {
blindTimerEnabled,
blindIntervalMin: blindTimerEnabled ? blindIntervalMin : 10,
mode,
cpuCount: mode === 'cpu' ? cpuCount : 0,
cpuCount: withBots ? cpuCount : 0,
hostToken,
});
broadcastLobby();
@@ -183,8 +210,10 @@ function broadcastTable(table) {
for (const s of sockets) s.emit('table:state', table.publicState(s.data.token, connected));
}
// レビュー付きCPU戦は管理者のロビーにだけ出す
function broadcastLobby() {
io.to('lobby').emit('lobby:tables', tables.list());
io.to('lobby').emit('lobby:tables', tables.list(false));
io.to('lobby:admin').emit('lobby:tables', tables.list(true));
}
function changed(table) {
@@ -211,6 +240,7 @@ io.use((socket, next) => {
io.on('connection', (socket) => {
const reply = (ack, payload) => typeof ack === 'function' && ack(payload);
socket.data.admin = auth.isAdminCookieHeader(socket.handshake.headers.cookie);
// イベントの連打を制限
let windowStart = Date.now();
@@ -226,8 +256,8 @@ io.on('connection', (socket) => {
});
socket.on('lobby:subscribe', () => {
socket.join('lobby');
socket.emit('lobby:tables', tables.list());
socket.join(socket.data.admin ? 'lobby:admin' : 'lobby');
socket.emit('lobby:tables', tables.list(socket.data.admin));
});
socket.on('table:join', (p, ack) => {
@@ -238,6 +268,10 @@ io.on('connection', (socket) => {
const name = cleanText(p.name, 12);
if (!token) return reply(ack, { ok: false, error: 'BAD_TOKEN' });
if (!name) return reply(ack, { ok: false, error: 'NAME_REQUIRED' });
// レビュー付きCPU戦は作成者(管理者)だけが入れる。ほかの人にはテーブルがあることも見せない
if (table.mode === 'review' && !(socket.data.admin && token === table.hostToken)) {
return reply(ack, { ok: false, error: 'NOT_FOUND' });
}
const prev = socket.data.tableId && tables.get(socket.data.tableId);
if (prev && prev !== table) {
@@ -278,6 +312,7 @@ io.on('connection', (socket) => {
'table:sitOut': (t, token) => t.setSittingOut(token, true),
'table:sitIn': (t, token) => t.setSittingOut(token, false),
'table:rebuy': (t, token) => t.rebuy(token),
'table:next': (t, token) => t.reviewNext(token),
};
for (const [event, fn] of Object.entries(actions)) {
socket.on(event, (p, ack) => {
+275
View File
@@ -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 };
+141
View File
@@ -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 };
+116
View File
@@ -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 };
+128
View File
@@ -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 };
+132
View File
@@ -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 };
+90 -4
View File
@@ -5,12 +5,16 @@ const config = require('./config');
const Blinds = require('../public/assets/blinds');
const { Hand } = require('./hand');
const ai = require('./ai');
const { analyzeHand } = require('./review/analyze');
const reviews = require('./review/store');
// テーブルの種類
// chips : キャッシュオンリー。カードは実物で配り、サイトはチップだけを管理する
// friends: 友人戦。サーバがカードを配り、ショーダウンも自動で判定する
// cpu : CPU戦。友人戦に CPU プレイヤーが加わる
const MODES = ['chips', 'friends', 'cpu'];
// review : レビュー付きCPU戦(管理者専用)。作成者と CPU だけで遊び、ハンドごとにレビューを作る。
// レビューを見てから次のハンドに進めるよう、ハンドが終わると作成者が「次のハンドへ」を押すまで待つ
const MODES = ['chips', 'friends', 'cpu', 'review'];
const CPU_NAMES = ['ハル', 'ソラ', 'リク', 'ミオ', 'レン', 'ユイ', 'カイ', 'ナギ'];
class Table {
@@ -33,7 +37,10 @@ class Table {
// bot : CPU プレイヤー(style は思考の種類と性格。ブラウザには渡さない)
this.seats = new Array(this.maxSeats).fill(null);
this.botTurn = null; // { key, at }: CPU が行動する時刻
if (this.mode === 'cpu') this.seatBots(opts.cpuCount);
this.withBots = this.mode === 'cpu' || this.mode === 'review';
if (this.withBots) this.seatBots(opts.cpuCount);
// レビュー付きCPU戦: session = 保存する対局、last = 直前のハンドのレビュー、waiting = 次のハンドへ進むのを待っている
this.review = this.mode === 'review' ? { session: null, last: null, waiting: false } : null;
this.hand = null;
this.started = false;
this.paused = false;
@@ -127,6 +134,7 @@ class Table {
}
return existing;
}
if (this.mode === 'review' && token !== this.hostToken) return -1; // 作成者と CPU だけのテーブル
const free = this.seats.findIndex((s) => !s);
if (free < 0) return -1;
this.seats[free] = { token, name, stack: this.startingStack, buyIns: 1, sittingOut: false, left: false };
@@ -155,7 +163,7 @@ class Table {
// CPU戦で人間が全員退席したら、テーブルを残しておく意味がない
shouldClose() {
return this.mode === 'cpu' && !this.seats.some((s) => s && !s.left && !s.bot);
return this.withBots && !this.seats.some((s) => s && !s.left && !s.bot);
}
removeLeftSeats() {
@@ -189,6 +197,7 @@ class Table {
order.push(i);
}
this.handNumber++;
if (this.review) this.review.waiting = false;
const { sb, bb } = this.blinds;
this.nextHandAt = null;
this.runoutAt = null;
@@ -331,6 +340,12 @@ class Table {
}
}
if (this.hand && this.hand.finished && this.review && !this.hand.reviewed) {
this.reviewHand();
changed = true;
}
if (this.hand && this.hand.finished && this.review && this.review.waiting) return changed;
if (this.hand && this.hand.finished) {
if (this.nextHandAt == null) {
const showdownMs = this.cardsEnabled ? config.CARD_SHOWDOWN_MS : config.SHOWDOWN_MS;
@@ -355,6 +370,72 @@ class Table {
return changed;
}
// ---- レビュー付きCPU戦 ----
// 終わったハンドのレビューを作って保存し、作成者が見終わるまで次のハンドを待つ
reviewHand() {
const h = this.hand;
h.reviewed = true;
const hero = this.seatOf(this.hostToken);
if (hero < 0 || !h.players.has(hero)) return;
let record;
try {
record = analyzeHand(h, hero, {
names: new Map(h.order.map((s) => [s, this.seats[s] ? this.seats[s].name : '退席者'])),
bots: new Set(h.order.filter((s) => this.seats[s] && this.seats[s].bot)),
iterations: config.REVIEW_ITERATIONS,
});
} catch (err) {
console.error('[review] レビューを作れませんでした', err);
return;
}
if (!this.review.session) {
this.review.session = reviews.create({ tableName: this.name, heroName: this.seats[hero].name, startingStack: this.startingStack });
}
reviews.addHand(this.review.session, record);
this.review.last = record;
this.review.waiting = !this.seats[hero].left;
}
reviewNext(token) {
if (!this.review) return 'レビュー付きのテーブルではありません';
if (!this.isHost(token)) return '操作できません';
this.review.waiting = false;
return null;
}
// 作成者に送るレビュー(直前のハンドの要約)
reviewView() {
const r = this.review;
const last = r.last;
const hands = r.session ? r.session.hands : [];
const scores = hands.map((x) => x.handScore).filter((x) => x != null);
return {
sessionId: r.session ? r.session.id : null,
waiting: r.waiting,
hands: hands.length,
average: scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : null,
last: last && {
number: last.number,
bb: last.bb,
hero: last.hero,
preflop: last.preflop,
decisions: last.decisions.map((d) => ({
street: d.street,
equity: d.equity,
need: d.need,
actual: d.actual,
best: d.best,
lossBB: d.lossBB,
score: d.score,
mark: d.mark,
})),
streetScores: last.streetScores,
handScore: last.handScore,
},
};
}
summary() {
const { sb, bb } = this.blinds;
return {
@@ -438,6 +519,7 @@ class Table {
}
: null,
isHost: this.isHost(token),
review: this.review && this.isHost(token) ? this.reviewView() : null,
serverNow: now,
};
}
@@ -452,8 +534,10 @@ class TableManager {
return this.tables.get(id);
}
list() {
// レビュー付きCPU戦は管理者にだけ見せる
list(admin = false) {
return [...this.tables.values()]
.filter((t) => admin || t.mode !== 'review')
.sort((a, b) => b.createdAt - a.createdAt)
.map((t) => t.summary());
}
@@ -480,6 +564,8 @@ class TableManager {
}
delete(id) {
const t = this.tables.get(id);
if (t && t.review && t.review.session) reviews.close(t.review.session);
this.tables.delete(id);
}
}