- 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>
412 lines
17 KiB
JavaScript
412 lines
17 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.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');
|
||
const HandRange = require('../admin/range-data');
|
||
const { Table } = require('../server/tables');
|
||
const { judgePreflop, positionOf, describeSituation } = require('../server/review/preflop');
|
||
const { analyzeHand } = require('../server/review/analyze');
|
||
const { showdownShare } = require('../server/review/equity');
|
||
const { summarize } = require('../server/review/summary');
|
||
const reviews = require('../server/review/store');
|
||
|
||
const T = (n) => 't'.repeat(31) + n; // 32文字のトークン
|
||
|
||
// n 人の友人戦(カードあり)。席0から順に座る
|
||
function makeTable(n) {
|
||
const t = new Table({
|
||
id: 'x',
|
||
name: 'test',
|
||
startingStack: 1000,
|
||
smallBlind: 5,
|
||
bigBlind: 10,
|
||
blindTimerEnabled: false,
|
||
blindIntervalMin: 10,
|
||
hostToken: T(0),
|
||
mode: 'friends',
|
||
});
|
||
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 give = (t, seat, cards) => {
|
||
t.hand.players.get(seat).cards = cards;
|
||
};
|
||
|
||
test('ハンドレンジ: 手札の表記と、後ろの人数で必要な色', () => {
|
||
assert.equal(HandRange.handKey(['Kd', 'As']), 'AKo');
|
||
assert.equal(HandRange.handKey(['5h', '4h']), '54s');
|
||
assert.equal(HandRange.handKey(['9c', '9d']), '99');
|
||
assert.equal(HandRange.categoryOf('AA'), 'navy');
|
||
assert.equal(HandRange.categoryOf('72o'), 'gray');
|
||
assert.equal(HandRange.tierOf('Q5s'), 1); // BTN だけで開く手は白(SB の青より下)
|
||
// レイズを受けたときの対応(枠)
|
||
assert.equal(HandRange.facingOf('A5s'), '3bet');
|
||
assert.equal(HandRange.facingOf('TT'), 'call');
|
||
assert.equal(HandRange.facingOf('ATo'), null);
|
||
const share = (id) => {
|
||
const combos = (h) => (h.length === 2 ? 6 : h[2] === 's' ? 4 : 12);
|
||
const n = Object.keys(HandRange.FACING).filter((h) => HandRange.facingOf(h) === id).reduce((a, h) => a + combos(h), 0);
|
||
return Math.round((n / 1326) * 1000) / 10;
|
||
};
|
||
assert.equal(share('3bet'), 5.7);
|
||
assert.equal(share('call'), 14.0);
|
||
const req = [0, 1, 2, 3, 4, 5, 6, 7, 8].map((b) => HandRange.categoryOfTier(HandRange.requiredTier(b)).id);
|
||
assert.deepEqual(req, ['gray', 'blue', 'white', 'green', 'yellow', 'red', 'red', 'red', 'red']);
|
||
});
|
||
|
||
test('ポジションの呼び方', () => {
|
||
const t = makeTable(9);
|
||
const h = t.hand;
|
||
const names = h.preflopOrder.map((s) => positionOf(h, s));
|
||
assert.deepEqual(names, ['UTG', 'UTG+1', 'UTG+2', 'LJ', 'HJ', 'CO', 'BTN', 'SB', 'BB']);
|
||
const hu = makeTable(2).hand;
|
||
assert.deepEqual(hu.preflopOrder.map((s) => positionOf(hu, s)), ['BTN/SB', 'BB']);
|
||
});
|
||
|
||
test('プリフロップ: 前に参加者がいなければ、後ろの人数の色以上でオープン', () => {
|
||
// 6人: UTG〜CO が降りて BTN(後ろに2人 → 白以上)
|
||
for (const [cards, action, verdict] of [
|
||
[['Kh', '3h'], 'raise', 'good'], // 白でレイズ
|
||
[['Qh', '3h'], 'raise', 'loose'], // レンジ外
|
||
[['Qh', '3h'], 'fold', 'good'],
|
||
[['Ah', 'Kd'], 'call', 'passive'], // レイズできる手でコール
|
||
[['Ah', 'Kd'], 'fold', 'tight'],
|
||
]) {
|
||
const t = makeTable(6);
|
||
for (let k = 0; k < 3; k++) act(t, 'fold');
|
||
const hero = t.hand.toAct;
|
||
assert.equal(hero, t.hand.button);
|
||
act(t, action, action === 'raise' ? 25 : 0);
|
||
give(t, hero, cards);
|
||
const j = judgePreflop(t.hand, hero).judgments[0];
|
||
assert.equal(j.situation.kind, 'open');
|
||
assert.equal(j.situation.behind, 2);
|
||
assert.equal(j.verdict, verdict, `${cards} ${action}`);
|
||
}
|
||
|
||
// SB に全員降りて回ってきた(後ろに1人 → 青以上。SB は BTN より狭い)
|
||
const t = makeTable(4);
|
||
act(t, 'fold');
|
||
act(t, 'fold');
|
||
const sb = t.hand.toAct;
|
||
act(t, 'raise', 30);
|
||
give(t, sb, ['Ks', '4s']);
|
||
const j = judgePreflop(t.hand, sb).judgments[0];
|
||
assert.equal(j.situation.behind, 1);
|
||
assert.equal(j.verdict, 'good');
|
||
give(t, sb, ['Qs', '4s']); // 白は BTN だけ
|
||
assert.equal(judgePreflop(t.hand, sb).judgments[0].verdict, 'loose');
|
||
});
|
||
|
||
test('プリフロップ: 前に参加者がいたら、その人の色の一つ上でコール、二つ上でレイズ', () => {
|
||
// 6人: HJ(後ろに4人 → 黄と推定)がレイズ。次の人(hero、CO)の判定
|
||
for (const [cards, action, verdict] of [
|
||
[['Kh', 'Qd'], 'call', 'good'], // 赤 = 黄の一つ上 → コール
|
||
[['Kh', 'Qd'], 'raise', 'aggressive'],
|
||
[['Ah', 'Ad'], 'raise', 'good'], // 紺 = 二つ上 → レイズ
|
||
[['Ah', 'Ad'], 'call', 'passive'],
|
||
[['Kh', '9h'], 'call', 'loose'], // 黄 = 同じ → フォールド
|
||
[['Kh', '9h'], 'fold', 'good'],
|
||
]) {
|
||
const t = makeTable(6);
|
||
act(t, 'fold');
|
||
const hj = act(t, 'raise', 30);
|
||
const hero = t.hand.toAct;
|
||
act(t, action, action === 'raise' ? 90 : 0);
|
||
give(t, hero, cards);
|
||
const { judgments, estimated } = judgePreflop(t.hand, hero);
|
||
assert.equal(estimated.get(hj), 4); // 黄
|
||
assert.equal(judgments[0].situation.refTier, 4);
|
||
assert.equal(judgments[0].verdict, verdict, `${cards} ${action}`);
|
||
}
|
||
|
||
// BB: リンプ(コール)に対してはチェックでよい。二つ上ならレイズ
|
||
const t = makeTable(4);
|
||
act(t, 'call'); // UTG(後ろに3人 → 緑)
|
||
act(t, 'fold');
|
||
act(t, 'call'); // SB: 緑の一つ上と推定
|
||
const bb = t.hand.toAct;
|
||
act(t, 'check');
|
||
give(t, bb, ['7h', '2d']);
|
||
const j = judgePreflop(t.hand, bb).judgments[0];
|
||
assert.equal(j.recommended, 'check');
|
||
assert.equal(j.verdict, 'good');
|
||
});
|
||
|
||
test('プリフロップ: BTN で CO のオープンを受けたら、レンジ表の枠(水色枠は3bet、黄枠はコール)のとおり', () => {
|
||
const coOpen = (cards, action, limp = false) => {
|
||
const t = makeTable(6);
|
||
act(t, limp ? 'call' : 'fold'); // UTG
|
||
act(t, 'fold');
|
||
act(t, 'raise', 30); // CO
|
||
const hero = t.hand.toAct;
|
||
assert.equal(hero, t.hand.button);
|
||
act(t, action, action === 'raise' ? 90 : 0);
|
||
give(t, hero, cards);
|
||
return judgePreflop(t.hand, hero).judgments[0];
|
||
};
|
||
for (const [cards, action, verdict] of [
|
||
[['Ah', '5h'], 'raise', 'good'], // 水色枠 → 3bet
|
||
[['Ah', '5h'], 'call', 'passive'],
|
||
[['Th', 'Td'], 'call', 'good'], // 黄枠 → コール(色の決まりなら赤でレイズ)
|
||
[['Th', 'Td'], 'raise', 'aggressive'],
|
||
[['Qh', 'Jd'], 'call', 'good'], // 黄枠(色は CO の緑)
|
||
[['Ah', 'Td'], 'call', 'loose'], // 枠なし → フォールド(色は HJ の黄)
|
||
[['Ah', 'Td'], 'fold', 'good'],
|
||
]) {
|
||
const j = coOpen(cards, action);
|
||
assert.equal(j.situation.rule, 'vsCO');
|
||
assert.equal(j.verdict, verdict, `${cards} ${action}`);
|
||
}
|
||
assert.match(describeSituation(coOpen(['Th', 'Td'], 'call').situation, () => 'CPU'), /黄枠でコール/);
|
||
// 前にリンプした人がいたら CO のオープンではないので、色の決まりで判定する
|
||
assert.equal(coOpen(['Th', 'Td'], 'call', true).situation.rule, 'normal');
|
||
});
|
||
|
||
test('プリフロップ: 自分のレイズにリレイズされたら、相手と同じ色でもコールできる', () => {
|
||
// 6人: UTG(hero、後ろに5人 → 赤)がオープン、HJ が3bet(赤の二つ上はないので紺と推定)、ほかは降りて hero に戻る
|
||
for (const [cards, action, verdict] of [
|
||
[['Ah', 'Ad'], 'call', 'good'], // 紺 = 同じ → コール(二つ上はない)
|
||
[['Ah', 'Ad'], 'fold', 'tight'],
|
||
[['Jh', 'Jd'], 'call', 'loose'], // 赤 = 一つ下 → フォールド
|
||
[['Jh', 'Jd'], 'fold', 'good'],
|
||
]) {
|
||
const t = makeTable(6);
|
||
const hero = act(t, 'raise', 30);
|
||
const hj = act(t, 'raise', 90);
|
||
for (let k = 0; k < 4; k++) act(t, 'fold');
|
||
assert.equal(t.hand.toAct, hero);
|
||
act(t, action);
|
||
give(t, hero, cards);
|
||
const { judgments, estimated } = judgePreflop(t.hand, hero);
|
||
assert.equal(estimated.get(hj), 6);
|
||
const j = judgments[1];
|
||
assert.equal(j.situation.rule, 'reraised');
|
||
assert.equal(j.verdict, verdict, `${cards} ${action}`);
|
||
}
|
||
});
|
||
|
||
test('プリフロップ: BB は 4BB までのオープンに広くコールし、三つ上でレイズ', () => {
|
||
// 6人: CO(後ろに3人 → 緑)がオープンして BB(hero)まで降りる。青以上でコール、紺でレイズ
|
||
const coOpen = (to, cards, action) => {
|
||
const t = makeTable(6);
|
||
act(t, 'fold');
|
||
act(t, 'fold');
|
||
act(t, 'raise', to);
|
||
act(t, 'fold');
|
||
act(t, 'fold');
|
||
const bb = t.hand.toAct;
|
||
assert.equal(bb, t.hand.bbSeat);
|
||
act(t, action, action === 'raise' ? to * 3 : 0);
|
||
give(t, bb, cards);
|
||
return judgePreflop(t.hand, bb).judgments[0];
|
||
};
|
||
for (const [cards, action, verdict] of [
|
||
[['Kh', '4h'], 'call', 'good'], // 青 = 緑の一つ下 → コール
|
||
[['Qh', '5h'], 'call', 'loose'], // 白 → フォールド
|
||
[['Kh', 'Qd'], 'call', 'good'], // 赤 = 二つ上 → まだコール
|
||
[['Kh', 'Qd'], 'raise', 'aggressive'],
|
||
[['Ah', 'Ad'], 'raise', 'good'], // 紺 = 三つ上 → レイズ
|
||
]) {
|
||
const j = coOpen(30, cards, action);
|
||
assert.equal(j.situation.rule, 'bb');
|
||
assert.equal(j.verdict, verdict, `${cards} ${action}`);
|
||
}
|
||
// 4BB を超えるオープンには、ほかの席と同じく一つ上が必要
|
||
const big = coOpen(50, ['Kh', '4h'], 'call');
|
||
assert.equal(big.situation.rule, 'normal');
|
||
assert.equal(big.verdict, 'loose');
|
||
|
||
// BTN(後ろに2人 → 白)のオープンには白からコールできる。コールした BB のレンジも白と推定する
|
||
const t = makeTable(6);
|
||
for (let k = 0; k < 3; k++) act(t, 'fold');
|
||
act(t, 'raise', 25);
|
||
act(t, 'fold');
|
||
const bb = act(t, 'call');
|
||
give(t, bb, ['Qh', '5h']);
|
||
const { judgments, estimated } = judgePreflop(t.hand, bb);
|
||
assert.equal(judgments[0].verdict, 'good');
|
||
assert.equal(estimated.get(bb), 1);
|
||
});
|
||
|
||
test('オールインの期待値: AA 対 KK はおよそ 82%', () => {
|
||
const hands = new Map([
|
||
[0, ['As', 'Ad']],
|
||
[1, ['Ks', 'Kd']],
|
||
]);
|
||
const r = showdownShare(0, hands, [], [{ amount: 200, eligible: [0, 1] }], { samples: 6000 });
|
||
assert.ok(r.equity > 0.79 && r.equity < 0.85, String(r.equity));
|
||
assert.ok(Math.abs(r.ev - 200 * r.equity) < 1e-6);
|
||
// ターンまで出ていれば残り1枚を全部数える
|
||
const turn = showdownShare(0, hands, ['2c', '7h', '9d', 'Kh'], [{ amount: 100, eligible: [0, 1] }]);
|
||
assert.equal(Math.round(turn.equity * 44), 2); // KK のセットに勝てるのは残り44枚のうち Ac・Ah の2枚だけ
|
||
});
|
||
|
||
test('ハンドのレビュー: 点数は 0〜100、ストリートごとに付き、収支が合う', () => {
|
||
for (let round = 0; round < 15; round++) {
|
||
const t = makeTable(4);
|
||
const h = t.hand;
|
||
for (let g = 0; g < 100 && !h.finished; g++) {
|
||
if (h.toAct == null) {
|
||
h.runoutStep();
|
||
continue;
|
||
}
|
||
const o = h.options(h.toAct);
|
||
const r = Math.random();
|
||
if (o.canRaise && r < 0.2) act(t, 'raise', o.minRaiseTo);
|
||
else if (!o.canCheck && r < 0.35) act(t, 'fold');
|
||
else act(t, o.canCheck ? 'check' : 'call');
|
||
}
|
||
assert.ok(h.finished);
|
||
const rec = analyzeHand(h, 0, { iterations: 200 });
|
||
const scores = rec.preflop.map((x) => x.score).concat(rec.decisions.map((x) => x.score));
|
||
for (const s of scores) assert.ok(s >= 0 && s <= 100);
|
||
for (const st of ['PREFLOP', 'FLOP', 'TURN', 'RIVER']) {
|
||
const has = rec.decisions.some((d) => d.street === st) || (st === 'PREFLOP' && rec.preflop.length > 0);
|
||
assert.equal(rec.streetScores[st] != null, has);
|
||
}
|
||
assert.equal(rec.hero.net, t.seats[0].stack - 1000);
|
||
if (!rec.hero.allIn) assert.equal(rec.hero.evNet, rec.hero.net);
|
||
assert.equal(rec.actions.length, h.log.length);
|
||
}
|
||
});
|
||
|
||
test('まとめ: 成績・色ごと・見直したいハンド・収支の推移', () => {
|
||
const hands = [];
|
||
for (let round = 0; round < 10; round++) {
|
||
const t = makeTable(3);
|
||
const h = t.hand;
|
||
for (let g = 0; g < 100 && !h.finished; g++) {
|
||
if (h.toAct == null) {
|
||
h.runoutStep();
|
||
continue;
|
||
}
|
||
const o = h.options(h.toAct);
|
||
act(t, o.canCheck ? 'check' : 'call');
|
||
}
|
||
hands.push(analyzeHand(h, 0, { iterations: 100 }));
|
||
}
|
||
const s = summarize(hands);
|
||
assert.equal(s.stats.hands, 10);
|
||
assert.equal(s.graph.length, 10);
|
||
assert.equal(s.graph[9].net, hands.reduce((a, h) => a + h.hero.net, 0));
|
||
assert.equal(s.colors.reduce((a, c) => a + c.dealt, 0), 10);
|
||
assert.ok(s.worst.length <= 5);
|
||
assert.ok(s.stats.vpip >= 0 && s.stats.vpip <= 100);
|
||
assert.equal(s.stats.pfr, 0); // 誰もレイズしていない
|
||
});
|
||
|
||
test('レビュー付きCPU戦: 作成者だけが座れ、ハンドごとにレビューを作って次のハンドを待つ', async () => {
|
||
const t = new Table({
|
||
id: 'r',
|
||
name: 'review',
|
||
startingStack: 1000,
|
||
smallBlind: 10,
|
||
bigBlind: 20,
|
||
blindTimerEnabled: false,
|
||
blindIntervalMin: 10,
|
||
hostToken: T(0),
|
||
mode: 'review',
|
||
cpuCount: 3,
|
||
});
|
||
assert.equal(t.cardsEnabled, true);
|
||
assert.equal(t.join(T(0), 'me'), 0);
|
||
assert.equal(t.join(T(5), 'friend'), -1); // 友達は入れない
|
||
assert.equal(t.start(T(0), 0), null);
|
||
|
||
let now = 0;
|
||
for (let k = 0; k < 400 && !t.hand.finished; k++) {
|
||
if (t.hand.toAct === 0) {
|
||
const o = t.hand.options(0);
|
||
t.act(T(0), o.canCheck ? 'check' : 'fold', 0);
|
||
}
|
||
t.tick((now += 5000));
|
||
}
|
||
assert.ok(t.hand.finished);
|
||
t.tick((now += 5000));
|
||
const v = t.publicState(T(0), new Set([T(0)]));
|
||
assert.ok(v.review && v.review.last && v.review.waiting);
|
||
assert.equal(v.review.hands, 1);
|
||
assert.equal(t.publicState(T(5), new Set()).review, null); // 作成者以外には送らない
|
||
|
||
// 押すまで次のハンドは始まらない
|
||
const number = t.hand.number;
|
||
for (let k = 0; k < 10; k++) t.tick((now += 60000));
|
||
assert.equal(t.hand.number, number);
|
||
assert.equal(t.reviewNext(T(5)), '操作できません');
|
||
assert.equal(t.reviewNext(T(0)), null);
|
||
for (let k = 0; k < 10 && t.hand.number === number; k++) t.tick((now += 5000));
|
||
assert.equal(t.hand.number, number + 1);
|
||
|
||
// ファイルに保存され、一覧と中身を読める
|
||
const session = t.review.session;
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
const list = await reviews.list();
|
||
assert.ok(list.some((s) => s.id === session.id && s.hands === 1));
|
||
reviews.close(session);
|
||
const saved = await reviews.get(session.id);
|
||
assert.equal(saved.hands.length, 1);
|
||
assert.equal(await reviews.get('../etc/passwd'), null);
|
||
});
|
||
|
||
test('AI に質問する文章: Hero に見えていた情報だけでハンドの状況を書く', () => {
|
||
const { buildHandPrompt } = require('../server/review/prompt');
|
||
const cardText = (c) => c[0] + { s: '♠', h: '♥', d: '♦', c: '♣' }[c[1]];
|
||
|
||
// 全員が降りて終わる(ショーダウンなし): 相手の手札は書かない
|
||
{
|
||
const t = makeTable(3);
|
||
const h = t.hand;
|
||
act(t, 'raise', 30); // BTN
|
||
act(t, 'fold'); // SB
|
||
act(t, 'fold'); // BB
|
||
const btn = h.button;
|
||
const rec = analyzeHand(h, btn, { iterations: 100 });
|
||
const text = buildHandPrompt(rec);
|
||
assert.match(text, new RegExp(rec.hero.cards.map(cardText).join(' ')));
|
||
assert.match(text, /BTN(Hero・私): レイズ to \$30/);
|
||
assert.match(text, /ショーダウンなし/);
|
||
for (const p of rec.players) {
|
||
if (p.seat === btn) continue;
|
||
assert.ok(!text.includes(p.cards.map(cardText).join(' ')), '公開されていない手札を書かない');
|
||
}
|
||
}
|
||
|
||
// ショーダウンまで行く: 場札と公開された手札を書く
|
||
{
|
||
const t = makeTable(2);
|
||
const h = t.hand;
|
||
for (let g = 0; g < 100 && !h.finished; g++) {
|
||
if (h.toAct == null) {
|
||
h.runoutStep();
|
||
continue;
|
||
}
|
||
const o = h.options(h.toAct);
|
||
act(t, o.canCheck ? 'check' : 'call');
|
||
}
|
||
const rec = analyzeHand(h, 0, { iterations: 100 });
|
||
const text = buildHandPrompt(rec);
|
||
assert.ok(text.includes(`リバー: ${cardText(rec.board[4])}`));
|
||
const villain = rec.players.find((p) => p.seat !== 0);
|
||
assert.ok(text.includes(villain.cards.map(cardText).join(' ')));
|
||
assert.match(text, /## 質問/);
|
||
}
|
||
});
|