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