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:
1 parent
49203f202c
commit
dd39c043c4
18 files changed
+2699
-18
No files matched your search
@@ -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 };
|
||||
Reference in new issue
Block a user