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

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