Files
Poker/test/stats.test.js
agomizi1cmandClaude Opus 5.5 dd39c043c4 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>
2026-10-22 16:04:33 +09:00

202 lines
7.6 KiB
JavaScript

'use strict';
// node --test で実行(ACCESS_KEY はテスト用のダミー、統計とレビューの保存先は一時ディレクトリ)
const fs = require('fs');
const os = require('os');
const path = require('path');
process.env.ACCESS_KEY = process.env.ACCESS_KEY || '000000';
process.env.STATS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-stats-'));
process.env.REVIEW_DIR = process.env.REVIEW_DIR || fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-review-'));
const test = require('node:test');
const assert = require('node:assert/strict');
const { Table } = require('../server/tables');
const { positionOf } = require('../server/review/preflop');
const { handStats } = require('../server/stats/hand');
const stats = require('../server/stats/store');
const { summarize } = require('../server/stats/summary');
const T = (n) => 's'.repeat(31) + n; // 32文字のトークン
function makeTable(n, mode = 'friends', extra = {}) {
const t = new Table({
id: 'x',
name: 'test',
startingStack: 1000,
smallBlind: 5,
bigBlind: 10,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
mode,
...extra,
});
for (let i = 0; i < n; i++) t.join(T(i), 'p' + i);
t.start(T(0), 0);
return t;
}
const act = (t, type, amount = 0) => {
const seat = t.hand.toAct;
assert.equal(t.act(t.seats[seat].token, type, amount), null);
return seat;
};
const pos = (h, s) => positionOf(h, s);
test('統計: BTN のスチールが通り、BB がフロップで降りたハンド', () => {
const t = makeTable(3);
const h = t.hand;
const btn = act(t, 'raise', 25); // BTN オープン
const sb = act(t, 'fold');
const bb = act(t, 'call');
assert.equal(pos(h, btn), 'BTN');
assert.equal(pos(h, sb), 'SB');
assert.equal(pos(h, bb), 'BB');
act(t, 'check'); // BB
act(t, 'raise', 30); // BTN ベット
act(t, 'fold'); // BB
assert.ok(h.finished);
const a = handStats(h, btn);
assert.equal(a.preflop, true);
assert.equal(a.vpip, true);
assert.equal(a.pfr, true);
assert.equal(a.atsOpp, true);
assert.equal(a.ats, true);
assert.equal(a.threeBetOpp, false);
assert.equal(a.sawFlop, true);
assert.equal(a.showdown, false);
assert.equal(a.won, true);
assert.equal(a.wonSawFlop, true);
assert.deepEqual(a.aggressive, [1, 1, 0, 0]);
assert.equal(a.netBB, 3); // SB 5 + BB の 25
// SB: BTN のレイズの後なのでスチールの機会ではないが、3bet の機会
const s = handStats(h, sb);
assert.equal(s.atsOpp, false);
assert.equal(s.threeBetOpp, true);
assert.equal(s.threeBet, false);
assert.equal(s.vpip, false);
assert.equal(s.sawFlop, false);
assert.deepEqual(s.folds, [1, 0, 0, 0]);
assert.equal(s.netBB, -0.5);
const b = handStats(h, bb);
assert.equal(b.vpip, true);
assert.equal(b.pfr, false);
assert.equal(b.sawFlop, true);
assert.equal(b.won, false);
assert.deepEqual(b.seen, [1, 1, 0, 0]);
assert.deepEqual(b.folds, [0, 1, 0, 0]);
assert.deepEqual(b.actions, [1, 2, 0, 0]);
assert.deepEqual(b.aggressive, [0, 0, 0, 0]);
assert.equal(b.netBB, -2.5);
});
test('統計: キャッシュオンリーのショーダウン(場札なし)でもフロップを見た・ショーダウンを数える', () => {
const t = makeTable(2, 'chips');
const h = t.hand;
const first = act(t, 'call'); // BTN/SB
const second = act(t, 'check'); // BB
for (let i = 0; i < 3; i++) {
act(t, 'check');
act(t, 'check');
}
assert.equal(h.street, 'SHOWDOWN');
assert.equal(t.award(T(0), [[second]]), null);
const w = handStats(h, second);
const l = handStats(h, first);
assert.equal(w.sawFlop, true);
assert.equal(w.showdown, true);
assert.equal(w.showdownWon, true);
assert.equal(w.vpip, false); // BB のチェックは参加に数えない
assert.equal(l.vpip, true); // SB のコールは参加
assert.equal(l.showdown, true);
assert.equal(l.showdownWon, false);
// ヘッズアップのボタン(SB)は全員が降りて回ってきたのでスチールの機会
assert.equal(l.atsOpp, true);
assert.equal(l.ats, false);
});
test('統計: 保存して合計・種類ごとの割合を出す。プレイ時間は続けて遊んだ間を含める', async () => {
const token = 'a'.repeat(32);
const base = {
preflop: true, vpip: true, pfr: true, atsOpp: false, ats: false, threeBetOpp: true, threeBet: true,
sawFlop: true, showdown: true, showdownWon: true, wonSawFlop: true, won: true,
seen: [1, 1, 1, 1], folds: [0, 0, 0, 0], actions: [1, 1, 1, 1], aggressive: [1, 1, 0, 0], netBB: 12.5,
};
const lose = {
...base, vpip: false, pfr: false, threeBetOpp: false, threeBet: false, sawFlop: false, showdown: false,
showdownWon: false, wonSawFlop: false, won: false, seen: [1, 0, 0, 0], folds: [1, 0, 0, 0],
actions: [1, 0, 0, 0], aggressive: [0, 0, 0, 0], netBB: -1,
};
await stats.record(token, 'friends', base, { startedAt: 0, endedAt: 60000 });
await stats.record(token, 'friends', lose, { startedAt: 90000, endedAt: 120000 }); // 30秒後(続けて遊んでいる)
await stats.record(token, 'cpu', lose, { startedAt: 10 ** 7, endedAt: 10 ** 7 + 30000 }); // 間が空いた
const s = summarize(await stats.get(token));
assert.equal(s.since, 0);
const f = s.modes.friends;
assert.equal(f.hands, 2);
assert.equal(f.vpip, 50);
assert.equal(f.threeBet, 100);
assert.equal(f.wtsd, 100);
assert.deepEqual(f.fold, [50, 0, 0, 0]);
assert.equal(f.afq, 40); // 行動5回のうちベット・レイズ2回
assert.equal(f.netBB, 11.5);
assert.equal(f.hours, Math.round((120000 / 3600000) * 10) / 10);
const tot = s.modes.total;
assert.equal(tot.hands, 3);
assert.equal(tot.won, 1);
assert.equal(tot.winRate, 33.3);
assert.equal(tot.netBB, 10.5);
assert.equal(tot.bb100, 350);
assert.equal(s.modes.chips.hands, 0);
assert.equal(s.modes.chips.vpip, null);
// ファイル名にプレイヤーIDそのものは使わない
const files = fs.readdirSync(stats.DIR);
assert.ok(files.length > 0 && files.every((f) => !f.includes(token)));
});
test('統計: テーブルはハンドが終わると人間のプレイヤーだけを数える(CPU は数えない)', async () => {
const t = new Table({
id: 'y',
name: 'cpu',
startingStack: 1000,
smallBlind: 5,
bigBlind: 10,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: 'c'.repeat(32),
mode: 'cpu',
cpuCount: 2,
});
t.join('c'.repeat(32), 'me');
t.start('c'.repeat(32), 0);
// 人間の手番が来たら降りる。CPU は時間を進めて動かす
let now = 0;
for (let i = 0; i < 200 && !t.hand.finished; i++) {
if (t.seats[t.hand.toAct] && !t.seats[t.hand.toAct].bot) t.act('c'.repeat(32), t.hand.options(t.hand.toAct).canCheck ? 'check' : 'fold');
t.tick((now += 10 ** 4));
}
assert.ok(t.hand.finished);
t.tick((now += 1));
assert.ok(t.hand.statsRecorded);
const s = summarize(await stats.get('c'.repeat(32)));
assert.equal(s.modes.cpu.hands, 1);
assert.equal(s.modes.total.hands, 1);
for (const seat of t.seats.filter((x) => x && x.bot)) {
assert.equal(summarize(await stats.get(seat.token)).modes.total.hands, 0);
}
});
test('ランキング: 進行中のハンドで出した額は降りていなければ持ち分に含め、収支はバイインとの差', () => {
const t = makeTable(3);
const btn = act(t, 'raise', 30);
const sb = act(t, 'fold');
const st = t.publicState(T(0), new Set());
assert.equal(st.seats[btn].held, 1000);
assert.equal(st.seats[btn].net, 0);
assert.equal(st.seats[sb].held, 995); // 降りた SB のブラインドはもう戻らない
assert.equal(st.seats[sb].net, -5);
});