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,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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user