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

+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);
}
}