-
+
対局全体のレビュー点数表・成績・リプレイ
diff --git a/server/hand.js b/server/hand.js
index 9e9b9a5..7dc7703 100644
--- a/server/hand.js
+++ b/server/hand.js
@@ -56,6 +56,8 @@ class Hand {
this.log = [];
this.startStacks = new Map(order.map((s) => [s, seats[s].stack])); // ブラインドを払う前
this.allIn = null; // オールインで決着した時点の場札とポット(レビューの期待値用)
+ this.startedAt = Date.now(); // 統計のプレイ時間用
+ this.endedAt = null;
this.players = new Map();
for (const i of order) {
@@ -413,7 +415,8 @@ class Hand {
}
end(showdown, results) {
- this.endStacks = new Map(this.order.map((s) => [s, this.seats[s] ? this.seats[s].stack : 0])); // レビュー用
+ this.endStacks = new Map(this.order.map((s) => [s, this.seats[s] ? this.seats[s].stack : 0])); // レビュー・統計用
+ this.endedAt = Date.now();
this.finished = true;
this.runout = false;
this.showdown = showdown;
diff --git a/server/index.js b/server/index.js
index 5fead59..9930dd9 100644
--- a/server/index.js
+++ b/server/index.js
@@ -10,6 +10,8 @@ const { TableManager, MODES } = require('./tables');
const reviews = require('./review/store');
const { summarize } = require('./review/summary');
const { buildHandPrompt } = require('./review/prompt');
+const stats = require('./stats/store');
+const { summarize: summarizeStats } = require('./stats/summary');
const PUBLIC = path.join(__dirname, '..', 'public');
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
@@ -89,6 +91,7 @@ app.use(auth.requireSameOrigin);
app.get('/', page('lobby.html'));
app.get('/create', page('create.html'));
app.get('/table/:id', page('table.html'));
+app.get('/stats', page('stats.html'));
// ---------- 管理者専用: ハンドレンジ学習 ----------
app.get('/range', auth.requireAdmin, page('range.html', ADMIN));
@@ -136,6 +139,17 @@ app.get('/api/reviews/:id/hands/:number/prompt', auth.requireAdmin, async (req,
});
app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) }));
+
+// 自分の統計。プレイヤーID(席のトークン)を URL やアクセスログに残さないよう POST の本文で受け取る
+app.post('/api/stats', async (req, res, next) => {
+ const token = cleanToken(req.body && req.body.token);
+ if (!token) return res.status(400).json({ error: 'プレイヤーIDがありません' });
+ try {
+ res.json(summarizeStats(await stats.get(token)));
+ } catch (err) {
+ next(err);
+ }
+});
app.get('/api/tables', (req, res) => res.json(tables.list(auth.isAdmin(req))));
app.get('/api/tables/suggest-name', (req, res) => res.json({ name: tables.suggestName() }));
@@ -335,6 +349,12 @@ io.on('connection', (socket) => {
'table:sitIn': (t, token) => t.setSittingOut(token, false),
'table:rebuy': (t, token) => t.rebuy(token),
'table:next': (t, token) => t.reviewNext(token),
+ 'table:seating': (t, token, p) => {
+ // order: 時計回りに並べた席番号
+ const order = p && typeof p === 'object' ? p.order : null;
+ const ok = Array.isArray(order) && order.length <= config.MAX_SEATS && order.every((i) => Number.isInteger(i));
+ return ok ? t.setSeating(token, order) : '席順の指定が不正です';
+ },
};
for (const [event, fn] of Object.entries(actions)) {
socket.on(event, (p, ack) => {
diff --git a/server/stats/hand.js b/server/stats/hand.js
new file mode 100644
index 0000000..75e3e15
--- /dev/null
+++ b/server/stats/hand.js
@@ -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 };
diff --git a/server/stats/store.js b/server/stats/store.js
new file mode 100644
index 0000000..afd1fe6
--- /dev/null
+++ b/server/stats/store.js
@@ -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 };
diff --git a/server/stats/summary.js b/server/stats/summary.js
new file mode 100644
index 0000000..58e29b3
--- /dev/null
+++ b/server/stats/summary.js
@@ -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 };
diff --git a/server/tables.js b/server/tables.js
index 824c39f..d8025a8 100644
--- a/server/tables.js
+++ b/server/tables.js
@@ -7,6 +7,8 @@ const { Hand } = require('./hand');
const ai = require('./ai');
const { analyzeHand } = require('./review/analyze');
const reviews = require('./review/store');
+const { handStats } = require('./stats/hand');
+const stats = require('./stats/store');
// テーブルの種類
// chips : キャッシュオンリー。カードは実物で配り、サイトはチップだけを管理する
@@ -37,6 +39,7 @@ class Table {
// bot : CPU プレイヤー(style は思考の種類と性格。ブラウザには渡さない)
this.seats = new Array(this.maxSeats).fill(null);
this.botTurn = null; // { key, at }: CPU が行動する時刻
+ this.pendingSeating = null; // ホストが決めた席順(時計回りのトークン)。ハンド中は次のハンドの始めに並べ替える
this.withBots = this.mode === 'cpu' || this.mode === 'review';
if (this.withBots) this.seatBots(opts.cpuCount);
// レビュー付きCPU戦: session = 保存する対局、last = 直前のハンドのレビュー、waiting = 次のハンドへ進むのを待っている
@@ -180,6 +183,7 @@ class Table {
startNewHand() {
this.removeLeftSeats();
+ this.applySeating();
// チップがなくなった CPU は自動でリバイ
for (const s of this.seats) {
if (s && s.bot && s.stack === 0) {
@@ -259,6 +263,45 @@ class Table {
// ---- ホスト操作 ----
+ // 席順を変える。order = 着席している全員の席番号を時計回りに並べたもの。
+ // 進行中(結果の表示中を含む)のハンドは席番号で参加者を覚えているので、そのときは次のハンドの始めに並べ替える
+ setSeating(token, order) {
+ if (!this.isHost(token)) return 'ホストのみ操作できます';
+ const current = this.seats.map((s, i) => (s && !s.left ? i : -1)).filter((i) => i >= 0);
+ const valid =
+ Array.isArray(order) &&
+ order.length === current.length &&
+ new Set(order).size === order.length &&
+ order.every((i) => current.includes(i));
+ if (!valid) return '着席している人が変わりました。もう一度並べてください';
+ this.pendingSeating = order.map((i) => this.seats[i].token);
+ if (!this.hand) this.applySeating();
+ return null;
+ }
+
+ // 決めてある席順に並べ替える。席は詰めて 0 番から座らせる(空席は画面に出ないので、並びだけが意味を持つ)。
+ // 決めた後に座った人は最後(ホストの並びの後ろ)に、元の順のまま加える。ボタンは同じ人に付いたまま
+ applySeating() {
+ const tokens = this.pendingSeating;
+ if (!tokens) return;
+ this.pendingSeating = null;
+ const rank = (s) => {
+ const k = tokens.indexOf(s.token);
+ return k < 0 ? tokens.length : k;
+ };
+ const button = this.buttonSeat != null && this.seats[this.buttonSeat] ? this.seats[this.buttonSeat].token : null;
+ const players = this.seats
+ .map((s, k) => ({ s, k }))
+ .filter((x) => x.s)
+ .sort((a, b) => rank(a.s) - rank(b.s) || a.k - b.k)
+ .map((x) => x.s);
+ this.seats = new Array(this.maxSeats).fill(null);
+ players.forEach((s, i) => {
+ this.seats[i] = s;
+ });
+ this.buttonSeat = button ? this.seatOf(button) : null;
+ }
+
start(token, now = Date.now()) {
if (!this.isHost(token)) return 'ホストのみ開始できます';
if (this.started) return 'すでに開始しています';
@@ -340,6 +383,7 @@ class Table {
}
}
+ if (this.hand && this.hand.finished && !this.hand.statsRecorded) this.recordStats();
if (this.hand && this.hand.finished && this.review && !this.hand.reviewed) {
this.reviewHand();
changed = true;
@@ -356,6 +400,7 @@ class Table {
this.hand = null;
this.nextHandAt = null;
this.removeLeftSeats();
+ this.applySeating();
}
changed = true;
}
@@ -370,6 +415,25 @@ class Table {
return changed;
}
+ // ---- 統計 ----
+
+ // 終わったハンドを、参加した人間のプレイヤーごとの統計に足す(CPU は数えない)。
+ // レビュー付きCPU戦は CPU戦としてまとめる
+ recordStats() {
+ const h = this.hand;
+ h.statsRecorded = true;
+ const mode = this.mode === 'review' ? 'cpu' : this.mode;
+ for (const s of h.order) {
+ const seat = this.seats[s];
+ if (!seat || seat.bot) continue;
+ try {
+ stats.record(seat.token, mode, handStats(h, s), { startedAt: h.startedAt, endedAt: h.endedAt || Date.now() });
+ } catch (err) {
+ console.error('[stats] 統計を数えられませんでした', err);
+ }
+ }
+ }
+
// ---- レビュー付きCPU戦 ----
// 終わったハンドのレビューを作って保存し、作成者が見終わるまで次のハンドを待つ
@@ -457,6 +521,18 @@ class Table {
};
}
+ inLiveHand(seat) {
+ return Boolean(this.handActive && this.hand.players.has(seat) && !this.hand.players.get(seat).folded);
+ }
+
+ // ランキング用: 持っているチップ(進行中のハンドで出した額は、降りていなければまだ本人の分として含める)と
+ // 収支(それ − バイインの合計)
+ standing(seat) {
+ const s = this.seats[seat];
+ const held = s.stack + (this.inLiveHand(seat) ? this.hand.players.get(seat).committed : 0);
+ return { held, net: held - s.buyIns * this.startingStack };
+ }
+
// token = 閲覧者のトークン(他人のトークンや手札は送らない)
publicState(token, connectedTokens) {
const now = Date.now();
@@ -466,7 +542,7 @@ class Table {
const h = this.hand;
const hv = h ? h.view(mySeat, token) : null;
const me = mySeat >= 0 ? this.seats[mySeat] : null;
- const inLiveHand = Boolean(this.handActive && h.players.has(mySeat) && !h.players.get(mySeat).folded);
+ const inLiveHand = this.inLiveHand(mySeat);
return {
id: this.id,
@@ -498,6 +574,7 @@ class Table {
seat: i,
name: s.name,
stack: s.stack,
+ ...this.standing(i),
connected: Boolean(s.bot) || connectedTokens.has(s.token),
bot: Boolean(s.bot),
sittingOut: s.sittingOut,
@@ -510,7 +587,7 @@ class Table {
me && !me.left
? {
stack: me.stack,
- net: me.stack + (inLiveHand ? h.players.get(mySeat).committed : 0) - me.buyIns * this.startingStack,
+ net: this.standing(mySeat).net,
buyIns: me.buyIns,
sittingOut: me.sittingOut,
canRebuy: me.stack === 0 && !inLiveHand,
@@ -519,6 +596,11 @@ class Table {
}
: null,
isHost: this.isHost(token),
+ // ホストだけに: 次のハンドから使う席順(席番号を時計回りに)
+ pendingSeating:
+ this.isHost(token) && this.pendingSeating
+ ? this.pendingSeating.map((t) => this.seatOf(t)).filter((i) => i >= 0 && !this.seats[i].left)
+ : null,
review: this.review && this.isHost(token) ? this.reviewView() : null,
serverNow: now,
};
diff --git a/test/engine.test.js b/test/engine.test.js
index a2a574b..82f7eef 100644
--- a/test/engine.test.js
+++ b/test/engine.test.js
@@ -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');
diff --git a/test/review.test.js b/test/review.test.js
index f969e9f..c4a9bbb 100644
--- a/test/review.test.js
+++ b/test/review.test.js
@@ -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');
diff --git a/test/seating.test.js b/test/seating.test.js
new file mode 100644
index 0000000..bf00a80
--- /dev/null
+++ b/test/seating.test.js
@@ -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']);
+});
diff --git a/test/stats.test.js b/test/stats.test.js
new file mode 100644
index 0000000..e21f9b6
--- /dev/null
+++ b/test/stats.test.js
@@ -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);
+});