Files
Poker/test/review.test.js
T
agomizi1cmandClaude Opus 5.5 3ddb0996e0 Switch hand ranges to Poker Skill's 6-max charts with 3bet/call frames
- Replace the Yokosawa ranges with the 6-max opening ranges (UTG, HJ, CO,
  SB, BTN). Colors keep the old order: navy (strong UTG), red UTG, yellow
  HJ, green CO, blue SB, white BTN. SB is narrower than BTN, so blue sits
  one tier above white; the purple-frame category is gone.
- Show the "BTN vs CO open" chart on the range page as frames: light blue
  for 3bet, yellow for call.
- Grade BTN facing an unlimped CO open by those frames in reviews; other
  spots keep the color-tier rule (BB defends down to green).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:21:39 +09:00

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