Add admin-only CPU tables with per-hand reviews
A new table type, "review" (レビュー付きCPU戦), can only be created by admins. Only the creator and CPUs sit there: friends cannot join, and the table is hidden from non-admin lobbies. After every hand a review opens, and the next hand waits until the creator presses "次のハンドへ". - Preflop: shows which color of the registered hand range the hole cards are and whether the action followed it. With no entrant before you, open with the color for the number of players still to act; facing an entrant, estimate their range from their position, call one color above it and raise two above. Purple ranks just below white (SB only). - Flop onward: equity vs ranges estimated from those colors (narrowed by later bets/calls) gives a rough EV per option. The score is the EV lost against the best option, relative to the pot. It is a guide, not GTO. - Also: pot-odds call check, all-in equity and luck-adjusted P&L, VPIP/PFR/3bet/Cbet/WTSD/W$SD/AF, per-color and per-position compliance, habits to watch, worst-5 hands, a P&L chart and a step-by-step replay on the admin-only /review page. Hand now logs every action for the review. The range data moves to admin/range-data.js, shared by the range page and the server. Reviews are saved under data/reviews/ (newest 50 sessions). Admin scripts and CSS are no longer cached for a day, so updates show up immediately. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3116b1da9f
commit
3f2515a9fa
25 files changed
+2262
-60
No files matched your search
@@ -0,0 +1,256 @@
|
||||
'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 } = 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); // 紫枠は白の一つ下
|
||||
const req = [0, 1, 2, 3, 4, 5, 6, 7, 8].map((b) => HandRange.categoryOfTier(HandRange.requiredTier(b)).id);
|
||||
assert.deepEqual(req, ['gray', 'purple', 'white', 'blue', 'green', 'green', 'yellow', 'yellow', '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', '8h'], 'raise', 'good'], // 白でレイズ
|
||||
[['Qh', '5h'], 'raise', 'loose'], // 紫枠は BTN ではオープンできない
|
||||
[['Qh', '5h'], '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人 → 紫枠以上)
|
||||
const t = makeTable(4);
|
||||
act(t, 'fold');
|
||||
act(t, 'fold');
|
||||
const sb = t.hand.toAct;
|
||||
act(t, 'raise', 30);
|
||||
give(t, sb, ['Qs', '4s']);
|
||||
const j = judgePreflop(t.hand, sb).judgments[0];
|
||||
assert.equal(j.situation.behind, 1);
|
||||
assert.equal(j.verdict, 'good');
|
||||
});
|
||||
|
||||
test('プリフロップ: 前に参加者がいたら、その人の色の一つ上でコール、二つ上でレイズ', () => {
|
||||
// 6人: UTG(後ろに5人 → 緑と推定)がレイズ。次の人(hero)の判定
|
||||
for (const [cards, action, verdict] of [
|
||||
[['Kh', 'Qd'], 'call', 'good'], // 黄 = 緑の一つ上 → コール
|
||||
[['Kh', 'Qd'], 'raise', 'aggressive'],
|
||||
[['Jh', 'Jd'], 'raise', 'good'], // 赤 = 二つ上 → レイズ
|
||||
[['Jh', 'Jd'], 'call', 'passive'],
|
||||
[['Kh', 'Jd'], 'call', 'loose'], // 緑 = 同じ → フォールド
|
||||
[['Kh', 'Jd'], 'fold', 'good'],
|
||||
]) {
|
||||
const t = makeTable(6);
|
||||
const utg = 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(utg), 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('オールインの期待値: 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);
|
||||
});
|
||||
Reference in new issue
Block a user