Add player stats, in-table ranking, host seating and review return

- Stats page (/stats): play style radar (TAG/LAG/rock/fish), VPIP, PFR,
  ATS, 3bet, WTSD, W$SD, WWSF, per-street fold rate, aggression
  frequency, hand win rate and net in BB, per table type, each with a
  bands-and-explanation sheet. Hands are counted per human player when
  they finish and stored as totals under data/stats/, keyed by a hash
  of the player ID (sent in a POST body, never in the URL).
- Ranking sheet at the table: everyone seated, ordered by stack or by
  net (stack minus buy-ins), updating live.
- The host can reorder seats by dragging (with a mini table preview and
  shuffle). Takes effect immediately between hands, otherwise at the
  start of the next hand; the dealer button stays with the same player.
- The full-session review opens in the same tab and its back button
  returns to the table it was opened from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-22 16:04:33 +09:00
1 parent 49203f202c
commit dd39c043c4
18 files changed
+2699 -18

No files matched your search

+4 -1
View File
@@ -56,6 +56,8 @@ class Hand {
this.log = [];
this.startStacks = new Map(order.map((s) => [s, seats[s].stack])); // ブラインドを払う前
this.allIn = null; // オールインで決着した時点の場札とポット(レビューの期待値用)
this.startedAt = Date.now(); // 統計のプレイ時間用
this.endedAt = null;
this.players = new Map();
for (const i of order) {
@@ -413,7 +415,8 @@ class Hand {
}
end(showdown, results) {
this.endStacks = new Map(this.order.map((s) => [s, this.seats[s] ? this.seats[s].stack : 0])); // レビュー用
this.endStacks = new Map(this.order.map((s) => [s, this.seats[s] ? this.seats[s].stack : 0])); // レビュー・統計用
this.endedAt = Date.now();
this.finished = true;
this.runout = false;
this.showdown = showdown;
+20
View File
@@ -10,6 +10,8 @@ const { TableManager, MODES } = require('./tables');
const reviews = require('./review/store');
const { summarize } = require('./review/summary');
const { buildHandPrompt } = require('./review/prompt');
const stats = require('./stats/store');
const { summarize: summarizeStats } = require('./stats/summary');
const PUBLIC = path.join(__dirname, '..', 'public');
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
@@ -89,6 +91,7 @@ app.use(auth.requireSameOrigin);
app.get('/', page('lobby.html'));
app.get('/create', page('create.html'));
app.get('/table/:id', page('table.html'));
app.get('/stats', page('stats.html'));
// ---------- 管理者専用: ハンドレンジ学習 ----------
app.get('/range', auth.requireAdmin, page('range.html', ADMIN));
@@ -136,6 +139,17 @@ app.get('/api/reviews/:id/hands/:number/prompt', auth.requireAdmin, async (req,
});
app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) }));
// 自分の統計。プレイヤーID(席のトークン)を URL やアクセスログに残さないよう POST の本文で受け取る
app.post('/api/stats', async (req, res, next) => {
const token = cleanToken(req.body && req.body.token);
if (!token) return res.status(400).json({ error: 'プレイヤーIDがありません' });
try {
res.json(summarizeStats(await stats.get(token)));
} catch (err) {
next(err);
}
});
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() }));
@@ -335,6 +349,12 @@ io.on('connection', (socket) => {
'table:sitIn': (t, token) => t.setSittingOut(token, false),
'table:rebuy': (t, token) => t.rebuy(token),
'table:next': (t, token) => t.reviewNext(token),
'table:seating': (t, token, p) => {
// order: 時計回りに並べた席番号
const order = p && typeof p === 'object' ? p.order : null;
const ok = Array.isArray(order) && order.length <= config.MAX_SEATS && order.every((i) => Number.isInteger(i));
return ok ? t.setSeating(token, order) : '席順の指定が不正です';
},
};
for (const [event, fn] of Object.entries(actions)) {
socket.on(event, (p, ack) => {
+90
View File
@@ -0,0 +1,90 @@
'use strict';
// 1ハンドから、1人のプレイヤーの統計の材料(数えるだけ。割合は summary.js で出す)を取り出す。
// 離席・退席などで自動で行った行動は本人の判断として数えない。キャッシュオンリー(場札なし)でも同じように数える
const { positionOf, ACTION_KIND } = require('../review/preflop');
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
// スチール(ATS)を試みられる位置: 全員が降りて回ってきた CO・BTN・SB
const STEAL_POSITIONS = new Set(['CO', 'BTN', 'SB', 'BTN/SB']);
function handStats(hand, seat) {
const p = hand.players.get(seat);
const mine = hand.log.filter((e) => e.seat === seat && !e.auto);
const pre = mine.filter((e) => e.street === 'PREFLOP');
const kind = (e) => ACTION_KIND[e.type];
// フロップを見た: プリフロップで降りておらず、ハンドがフロップまで進んだ(プリフロップのオールインで
// 残りをめくったときも含む)
const foldedPre = hand.log.some((e) => e.seat === seat && e.street === 'PREFLOP' && e.type === 'FOLD');
const reachedFlop = hand.showdown || hand.board.length >= 3 || hand.log.some((e) => e.street !== 'PREFLOP');
const sawFlop = !foldedPre && reachedFlop;
const showdown = Boolean(hand.showdown && !p.folded);
const won = p.won > 0;
// スチールと 3bet の機会(プリフロップを順に見る)
const position = positionOf(hand, seat);
let entered = false; // 自分より前に誰かがコール・レイズした
let raises = 0;
let first = true;
let atsOpp = false;
let ats = false;
let threeBetOpp = false;
let threeBet = false;
for (const e of hand.log) {
if (e.street !== 'PREFLOP') continue;
const k = kind(e);
if (e.seat === seat && !e.auto) {
if (first && !entered && STEAL_POSITIONS.has(position)) {
atsOpp = true;
ats = k === 'raise';
}
first = false;
// 3bet の機会: プリフロップでレイズがちょうど1回あった後の判断
if (raises === 1 && !threeBetOpp) {
threeBetOpp = true;
threeBet = k === 'raise';
}
}
if (k === 'call' || k === 'raise') entered = true;
if (k === 'raise') raises++;
}
// ストリートごと: 判断した(そのストリートに来た)・降りた・全部の行動のうちベット・レイズ
const seen = [0, 0, 0, 0];
const folds = [0, 0, 0, 0];
const actions = [0, 0, 0, 0];
const aggressive = [0, 0, 0, 0];
for (const e of mine) {
const i = STREETS.indexOf(e.street);
if (i < 0) continue;
seen[i] = 1;
actions[i]++;
if (kind(e) === 'raise') aggressive[i]++;
if (e.type === 'FOLD') folds[i] = 1;
}
const start = hand.startStacks.get(seat);
const end = hand.endStacks ? hand.endStacks.get(seat) : start;
return {
preflop: pre.length > 0, // VPIP・PFR の分母(BB で全員が降りてきたハンドなど、判断の無いハンドは除く)
vpip: pre.some((e) => kind(e) === 'call' || kind(e) === 'raise'),
pfr: pre.some((e) => kind(e) === 'raise'),
atsOpp,
ats,
threeBetOpp,
threeBet,
sawFlop,
showdown,
showdownWon: showdown && won,
wonSawFlop: sawFlop && won,
won,
seen,
folds,
actions,
aggressive,
netBB: hand.bb > 0 ? (end - start) / hand.bb : 0,
};
}
module.exports = { handStats, STREETS };
+115
View File
@@ -0,0 +1,115 @@
'use strict';
// プレイヤーごとの統計の保存先(data/stats/<プレイヤーIDのハッシュ>.json)。
// プレイヤーID(ブラウザの localStorage にある席のトークン)はそのまま残さず、SHA-256 の先頭で名前を付ける。
// 中身はテーブルの種類ごとの回数の合計だけで、ハンドの内容は持たない。
// 同じプレイヤーの読み書きは順番に行う(ハンドが続けて終わっても数え漏らさない)
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DIR = process.env.STATS_DIR || path.join(__dirname, '..', '..', 'data', 'stats');
const MODES = ['chips', 'friends', 'cpu'];
// 続けて遊んでいるとみなす間隔(前のハンドの終わりからこれ以内に始まったら、間の時間もプレイ時間に入れる)
const CONTINUOUS_MS = 3 * 60 * 1000;
const queue = new Map(); // key → 直前の読み書きの Promise
const cache = new Map(); // key → 中身(読んだもの・書いたもの)
const keyOf = (token) => crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 32);
const fileOf = (key) => path.join(DIR, `${key}.json`);
const streets = () => [0, 0, 0, 0];
function emptyBucket() {
return {
hands: 0,
won: 0,
preflop: 0,
vpip: 0,
pfr: 0,
atsOpp: 0,
ats: 0,
threeBetOpp: 0,
threeBet: 0,
sawFlop: 0,
showdown: 0,
showdownWon: 0,
wonSawFlop: 0,
seen: streets(),
folds: streets(),
actions: streets(),
aggressive: streets(),
netBB: 0,
ms: 0,
};
}
const empty = () => ({ version: 1, firstAt: null, lastAt: null, modes: {} });
async function load(key) {
if (cache.has(key)) return cache.get(key);
let data;
try {
data = JSON.parse(await fs.promises.readFile(fileOf(key), 'utf8'));
if (!data || typeof data !== 'object' || !data.modes) data = empty();
} catch {
data = empty();
}
cache.set(key, data);
return data;
}
async function write(key, data) {
await fs.promises.mkdir(DIR, { recursive: true, mode: 0o700 });
const tmp = `${fileOf(key)}.tmp`;
await fs.promises.writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
await fs.promises.rename(tmp, fileOf(key));
}
// 同じ key の処理を順番に行う
function serial(key, fn) {
const prev = queue.get(key) || Promise.resolve();
const next = prev.then(fn);
const settled = next.catch((err) => console.error('[stats] 保存に失敗しました', err.message));
queue.set(key, settled);
settled.then(() => {
if (queue.get(key) === settled) queue.delete(key);
});
return next;
}
function add(bucket, entry, ms) {
bucket.hands++;
for (const k of ['won', 'preflop', 'vpip', 'pfr', 'atsOpp', 'ats', 'threeBetOpp', 'threeBet', 'sawFlop', 'showdown', 'showdownWon', 'wonSawFlop']) {
if (entry[k]) bucket[k]++;
}
for (const k of ['seen', 'folds', 'actions', 'aggressive']) {
for (let i = 0; i < 4; i++) bucket[k][i] += entry[k][i] || 0;
}
bucket.netBB = Math.round((bucket.netBB + entry.netBB) * 100) / 100;
bucket.ms += ms;
}
// 1ハンド分を足す。startedAt / endedAt はハンドの始まりと終わりの時刻
function record(token, mode, entry, { startedAt, endedAt }) {
if (!MODES.includes(mode)) return Promise.resolve();
const key = keyOf(token);
return serial(key, async () => {
const data = await load(key);
// プレイ時間: ハンドの長さ。前のハンドから続けて遊んでいれば、間の待ち時間も入れる
const continuous = data.lastAt != null && startedAt - data.lastAt >= 0 && startedAt - data.lastAt <= CONTINUOUS_MS;
const ms = Math.max(0, endedAt - (continuous ? data.lastAt : startedAt));
if (!data.modes[mode]) data.modes[mode] = emptyBucket();
add(data.modes[mode], entry, ms);
if (data.firstAt == null) data.firstAt = startedAt;
data.lastAt = endedAt;
await write(key, data);
});
}
// 書き込み中のものを待ってから返す
function get(token) {
const key = keyOf(token);
return serial(key, () => load(key));
}
module.exports = { record, get, emptyBucket, MODES, DIR };
+55
View File
@@ -0,0 +1,55 @@
'use strict';
// 保存してある回数から、画面に出す割合を作る。テーブルの種類ごとと、すべてを合わせた合計(total)
const { emptyBucket, MODES } = require('./store');
const pct = (a, b) => (b > 0 ? Math.round((a / b) * 1000) / 10 : null);
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
function merge(buckets) {
const out = emptyBucket();
for (const b of buckets) {
for (const [k, v] of Object.entries(b)) {
if (Array.isArray(v)) v.forEach((x, i) => (out[k][i] += x));
else out[k] += v;
}
}
out.netBB = Math.round(out.netBB * 100) / 100;
return out;
}
function view(b) {
return {
hands: b.hands,
won: b.won,
winRate: pct(b.won, b.hands),
vpip: pct(b.vpip, b.preflop),
pfr: pct(b.pfr, b.preflop),
ats: pct(b.ats, b.atsOpp),
atsOpp: b.atsOpp,
threeBet: pct(b.threeBet, b.threeBetOpp),
threeBetOpp: b.threeBetOpp,
sawFlop: b.sawFlop,
showdowns: b.showdown,
wtsd: pct(b.showdown, b.sawFlop),
wsd: pct(b.showdownWon, b.showdown),
wwsf: pct(b.wonSawFlop, b.sawFlop),
// ストリートごとの降りた割合(そのストリートで判断したハンドのうち)
fold: b.folds.map((f, i) => pct(f, b.seen[i])),
// アグレッション頻度: 全部の行動(フォールド・チェック・コール・ベット・レイズ)のうちベット・レイズ
afq: pct(sum(b.aggressive), sum(b.actions)),
afqStreets: b.aggressive.map((a, i) => pct(a, b.actions[i])),
netBB: b.netBB,
bb100: b.hands > 0 ? Math.round((b.netBB / b.hands) * 1000) / 10 : null,
hours: Math.round((b.ms / 3600000) * 10) / 10,
};
}
function summarize(data) {
const modes = {};
for (const m of MODES) modes[m] = view(data.modes[m] || emptyBucket());
modes.total = view(merge(MODES.map((m) => data.modes[m]).filter(Boolean)));
return { since: data.firstAt, modes };
}
module.exports = { summarize };
+84 -2
View File
@@ -7,6 +7,8 @@ const { Hand } = require('./hand');
const ai = require('./ai');
const { analyzeHand } = require('./review/analyze');
const reviews = require('./review/store');
const { handStats } = require('./stats/hand');
const stats = require('./stats/store');
// テーブルの種類
// chips : キャッシュオンリー。カードは実物で配り、サイトはチップだけを管理する
@@ -37,6 +39,7 @@ class Table {
// bot : CPU プレイヤー(style は思考の種類と性格。ブラウザには渡さない)
this.seats = new Array(this.maxSeats).fill(null);
this.botTurn = null; // { key, at }: CPU が行動する時刻
this.pendingSeating = null; // ホストが決めた席順(時計回りのトークン)。ハンド中は次のハンドの始めに並べ替える
this.withBots = this.mode === 'cpu' || this.mode === 'review';
if (this.withBots) this.seatBots(opts.cpuCount);
// レビュー付きCPU戦: session = 保存する対局、last = 直前のハンドのレビュー、waiting = 次のハンドへ進むのを待っている
@@ -180,6 +183,7 @@ class Table {
startNewHand() {
this.removeLeftSeats();
this.applySeating();
// チップがなくなった CPU は自動でリバイ
for (const s of this.seats) {
if (s && s.bot && s.stack === 0) {
@@ -259,6 +263,45 @@ class Table {
// ---- ホスト操作 ----
// 席順を変える。order = 着席している全員の席番号を時計回りに並べたもの。
// 進行中(結果の表示中を含む)のハンドは席番号で参加者を覚えているので、そのときは次のハンドの始めに並べ替える
setSeating(token, order) {
if (!this.isHost(token)) return 'ホストのみ操作できます';
const current = this.seats.map((s, i) => (s && !s.left ? i : -1)).filter((i) => i >= 0);
const valid =
Array.isArray(order) &&
order.length === current.length &&
new Set(order).size === order.length &&
order.every((i) => current.includes(i));
if (!valid) return '着席している人が変わりました。もう一度並べてください';
this.pendingSeating = order.map((i) => this.seats[i].token);
if (!this.hand) this.applySeating();
return null;
}
// 決めてある席順に並べ替える。席は詰めて 0 番から座らせる(空席は画面に出ないので、並びだけが意味を持つ)。
// 決めた後に座った人は最後(ホストの並びの後ろ)に、元の順のまま加える。ボタンは同じ人に付いたまま
applySeating() {
const tokens = this.pendingSeating;
if (!tokens) return;
this.pendingSeating = null;
const rank = (s) => {
const k = tokens.indexOf(s.token);
return k < 0 ? tokens.length : k;
};
const button = this.buttonSeat != null && this.seats[this.buttonSeat] ? this.seats[this.buttonSeat].token : null;
const players = this.seats
.map((s, k) => ({ s, k }))
.filter((x) => x.s)
.sort((a, b) => rank(a.s) - rank(b.s) || a.k - b.k)
.map((x) => x.s);
this.seats = new Array(this.maxSeats).fill(null);
players.forEach((s, i) => {
this.seats[i] = s;
});
this.buttonSeat = button ? this.seatOf(button) : null;
}
start(token, now = Date.now()) {
if (!this.isHost(token)) return 'ホストのみ開始できます';
if (this.started) return 'すでに開始しています';
@@ -340,6 +383,7 @@ class Table {
}
}
if (this.hand && this.hand.finished && !this.hand.statsRecorded) this.recordStats();
if (this.hand && this.hand.finished && this.review && !this.hand.reviewed) {
this.reviewHand();
changed = true;
@@ -356,6 +400,7 @@ class Table {
this.hand = null;
this.nextHandAt = null;
this.removeLeftSeats();
this.applySeating();
}
changed = true;
}
@@ -370,6 +415,25 @@ class Table {
return changed;
}
// ---- 統計 ----
// 終わったハンドを、参加した人間のプレイヤーごとの統計に足す(CPU は数えない)。
// レビュー付きCPU戦は CPU戦としてまとめる
recordStats() {
const h = this.hand;
h.statsRecorded = true;
const mode = this.mode === 'review' ? 'cpu' : this.mode;
for (const s of h.order) {
const seat = this.seats[s];
if (!seat || seat.bot) continue;
try {
stats.record(seat.token, mode, handStats(h, s), { startedAt: h.startedAt, endedAt: h.endedAt || Date.now() });
} catch (err) {
console.error('[stats] 統計を数えられませんでした', err);
}
}
}
// ---- レビュー付きCPU戦 ----
// 終わったハンドのレビューを作って保存し、作成者が見終わるまで次のハンドを待つ
@@ -457,6 +521,18 @@ class Table {
};
}
inLiveHand(seat) {
return Boolean(this.handActive && this.hand.players.has(seat) && !this.hand.players.get(seat).folded);
}
// ランキング用: 持っているチップ(進行中のハンドで出した額は、降りていなければまだ本人の分として含める)と
// 収支(それ − バイインの合計)
standing(seat) {
const s = this.seats[seat];
const held = s.stack + (this.inLiveHand(seat) ? this.hand.players.get(seat).committed : 0);
return { held, net: held - s.buyIns * this.startingStack };
}
// token = 閲覧者のトークン(他人のトークンや手札は送らない)
publicState(token, connectedTokens) {
const now = Date.now();
@@ -466,7 +542,7 @@ class Table {
const h = this.hand;
const hv = h ? h.view(mySeat, token) : null;
const me = mySeat >= 0 ? this.seats[mySeat] : null;
const inLiveHand = Boolean(this.handActive && h.players.has(mySeat) && !h.players.get(mySeat).folded);
const inLiveHand = this.inLiveHand(mySeat);
return {
id: this.id,
@@ -498,6 +574,7 @@ class Table {
seat: i,
name: s.name,
stack: s.stack,
...this.standing(i),
connected: Boolean(s.bot) || connectedTokens.has(s.token),
bot: Boolean(s.bot),
sittingOut: s.sittingOut,
@@ -510,7 +587,7 @@ class Table {
me && !me.left
? {
stack: me.stack,
net: me.stack + (inLiveHand ? h.players.get(mySeat).committed : 0) - me.buyIns * this.startingStack,
net: this.standing(mySeat).net,
buyIns: me.buyIns,
sittingOut: me.sittingOut,
canRebuy: me.stack === 0 && !inLiveHand,
@@ -519,6 +596,11 @@ class Table {
}
: null,
isHost: this.isHost(token),
// ホストだけに: 次のハンドから使う席順(席番号を時計回りに)
pendingSeating:
this.isHost(token) && this.pendingSeating
? this.pendingSeating.map((t) => this.seatOf(t)).filter((i) => i >= 0 && !this.seats[i].left)
: null,
review: this.review && this.isHost(token) ? this.reviewView() : null,
serverNow: now,
};