Files
Poker/test/review.test.js
野口煌陽andClaude Opus 5.5 f64c502086 Wait for the host to press "next hand" on every table
Previously only the review CPU table paused after each hand; cash-only,
friends and CPU tables moved on automatically after a short delay. Now
every table keeps the result on screen until the host presses 次のハンドへ,
and the next hand is dealt right away. If the host has left, the new host
presses it; with no human seated the table still advances on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:56:37 +09:00

412 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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.awaitingNext);
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.nextHand(T(5), now), 'ホストのみ操作できます');
assert.equal(t.nextHand(T(0), now), 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, /## 質問/);
}
});