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
+5
-1
@@ -1,7 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
// node --test で実行(ACCESS_KEY はテスト用のダミー)
|
||||
// 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-'));
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
+2
-1
@@ -1,11 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
// node --test で実行(ACCESS_KEY はテスト用のダミー、レビューの保存先は一時ディレクトリ)
|
||||
// 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.REVIEW_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-review-'));
|
||||
process.env.STATS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-stats-'));
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
'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-'));
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { Table } = require('../server/tables');
|
||||
|
||||
const T = (n) => 'q'.repeat(31) + n; // 32文字のトークン
|
||||
|
||||
function makeTable(n) {
|
||||
const t = new Table({
|
||||
id: 'x',
|
||||
name: 'test',
|
||||
startingStack: 1000,
|
||||
smallBlind: 10,
|
||||
bigBlind: 20,
|
||||
blindTimerEnabled: false,
|
||||
blindIntervalMin: 10,
|
||||
hostToken: T(0),
|
||||
mode: 'friends',
|
||||
});
|
||||
for (let i = 0; i < n; i++) t.join(T(i), 'p' + i);
|
||||
return t;
|
||||
}
|
||||
|
||||
// 席の並び(名前)
|
||||
const names = (t) => t.seats.filter(Boolean).map((s) => s.name);
|
||||
|
||||
test('席順: 開始前はすぐに並べ替え、ホスト以外や着席者と合わない指定は受け付けない', () => {
|
||||
const t = makeTable(4);
|
||||
assert.equal(t.setSeating(T(1), [3, 2, 1, 0]), 'ホストのみ操作できます');
|
||||
assert.match(t.setSeating(T(0), [3, 2, 1]), /もう一度/);
|
||||
assert.match(t.setSeating(T(0), [3, 3, 1, 0]), /もう一度/);
|
||||
assert.match(t.setSeating(T(0), [3, 2, 1, 5]), /もう一度/);
|
||||
assert.equal(t.setSeating(T(0), [0, 3, 1, 2]), null);
|
||||
assert.deepEqual(names(t), ['p0', 'p3', 'p1', 'p2']);
|
||||
assert.equal(t.pendingSeating, null);
|
||||
// 席は詰めて座らせ、新しく座った人は空いている次の席(並びの最後)に入る
|
||||
t.leave(T(1));
|
||||
t.join(T(9), 'p9');
|
||||
assert.deepEqual(names(t), ['p0', 'p3', 'p9', 'p2']);
|
||||
});
|
||||
|
||||
test('席順: ハンド中は次のハンドの始めに並べ替え、ボタンは同じ人に付いたまま次へ回る', () => {
|
||||
const t = makeTable(4);
|
||||
t.start(T(0), 0);
|
||||
const h = t.hand;
|
||||
const buttonName = t.seats[h.button].name;
|
||||
const order = [0, 2, 1, 3]; // p0 → p2 → p1 → p3
|
||||
assert.equal(t.setSeating(T(0), order), null);
|
||||
assert.deepEqual(names(t), ['p0', 'p1', 'p2', 'p3']); // まだ変わらない
|
||||
assert.deepEqual(t.publicState(T(0), new Set()).pendingSeating, order);
|
||||
assert.equal(t.publicState(T(1), new Set()).pendingSeating, null); // ホスト以外には送らない
|
||||
|
||||
// 全員降りてハンドを終わらせ、次のハンドへ
|
||||
for (let i = 0; i < 10 && !h.finished; i++) t.act(t.seats[h.toAct].token, 'fold', 0);
|
||||
assert.ok(h.finished);
|
||||
t.tick(10 ** 6);
|
||||
t.tick(10 ** 7);
|
||||
assert.equal(t.hand.number, 2);
|
||||
assert.deepEqual(names(t), ['p0', 'p2', 'p1', 'p3']);
|
||||
assert.equal(t.pendingSeating, null);
|
||||
// 前のハンドのボタンの人の、新しい並びでの次の人がボタン
|
||||
const after = ['p0', 'p2', 'p1', 'p3'];
|
||||
const next = after[(after.indexOf(buttonName) + 1) % 4];
|
||||
assert.equal(t.seats[t.hand.button].name, next);
|
||||
// 参加者の順もボタンの次から新しい並びどおり
|
||||
const seq = t.hand.order.map((s) => t.seats[s].name);
|
||||
const k = after.indexOf(next);
|
||||
assert.deepEqual(seq, [1, 2, 3, 4].map((d) => after[(k + d) % 4]));
|
||||
});
|
||||
|
||||
test('席順: 決めた後に座った人は最後に加え、いなくなった人は外す', () => {
|
||||
const t = makeTable(3);
|
||||
t.start(T(0), 0);
|
||||
const h = t.hand;
|
||||
assert.equal(t.setSeating(T(0), [2, 1, 0]), null);
|
||||
t.join(T(5), 'p5'); // ハンド中に新しく座る
|
||||
t.leave(T(1)); // ハンド中に退席
|
||||
for (let i = 0; i < 10 && !h.finished; i++) t.act(t.seats[h.toAct].token, 'fold', 0);
|
||||
t.tick(10 ** 6);
|
||||
t.tick(10 ** 7);
|
||||
assert.deepEqual(names(t), ['p2', 'p0', 'p5']);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
'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);
|
||||
});
|
||||
Reference in new issue
Block a user