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