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