'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, /## 質問/); } });