Files
Poker/test/engine.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

918 lines
35 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.STATS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'holdem-stats-'));
const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('crypto');
const { PokerEngine } = require('@pokertools/engine');
const { getCardCodes, stringifyCardCode } = require('@pokertools/evaluator');
const { Table } = require('../server/tables');
const { bestHand, compareScore } = require('../server/cards');
const config = require('../server/config');
const T = (n) => 't'.repeat(31) + n; // 32文字のトークン
function makeTable(stacks, opts = {}) {
const table = new Table({
id: 'x',
name: 'test',
startingStack: 1000,
smallBlind: 10,
bigBlind: 20,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
...opts,
});
stacks.forEach((st, i) => {
table.join(T(i), 'p' + i);
table.seats[i].stack = st;
});
return table;
}
const tk = (t, seat) => t.seats[seat].token;
// ショーダウンまでチェック/コールで進める
function checkDown(t) {
const h = t.hand;
for (let guard = 0; guard < 100 && h.toAct != null; guard++) {
const o = h.options(h.toAct);
assert.equal(t.act(tk(t, h.toAct), o.canCheck ? 'check' : 'call', 0), null);
}
}
test('ヘッズアップ: ボタンがSBで先に動き、フォールドでBBが勝つ', () => {
const t = makeTable([1000, 1000]);
assert.equal(t.start(T(0), 0), null);
const h = t.hand;
assert.equal(h.sbSeat, h.button);
assert.equal(h.toAct, h.sbSeat);
const sb = h.sbSeat;
const bb = h.bbSeat;
assert.equal(t.seats[sb].stack, 990);
assert.equal(t.seats[bb].stack, 980);
assert.equal(t.act(tk(t, sb), 'fold', 0), null);
assert.ok(h.finished && !h.showdown);
assert.equal(t.seats[sb].stack, 990);
assert.equal(t.seats[bb].stack, 1010);
});
test('BBのオプションとチェックでストリートが進む', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
assert.equal(h.toAct, btn); // UTG = ボタン(3人)
assert.equal(t.act(tk(t, btn), 'call', 0), null);
assert.equal(t.act(tk(t, sb), 'call', 0), null);
assert.equal(h.street, 'PREFLOP');
assert.equal(h.toAct, bb); // BB はまだ動ける
assert.ok(h.options(bb).canRaise);
assert.equal(t.act(tk(t, bb), 'check', 0), null);
assert.equal(h.street, 'FLOP');
assert.equal(h.pot, 60);
assert.equal(h.toAct, sb); // ポストフロップは SB から
checkDown(t);
assert.equal(t.phase, 'SHOWDOWN');
assert.deepEqual(h.view().pots, [{ amount: 60, eligible: [sb, bb, btn] }]);
});
test('ミニマムレイズと、足りないオールインは再レイズ権を与えない', () => {
// ボタンを席0にして、BB(席2)は 75 しか持っていない
const t = makeTable([1000, 1000, 75]);
t.buttonSeat = 2;
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
assert.deepEqual([sb, bb, btn], [1, 2, 0]);
// 最低レイズ額は 40(20 + 20)
assert.ok(t.act(tk(t, btn), 'raise', 39));
assert.equal(t.act(tk(t, btn), 'raise', 60), null); // +40 のレイズ
assert.equal(h.options(sb).minRaiseTo, 100);
assert.equal(t.act(tk(t, sb), 'call', 0), null);
// BB の 75 オールインは 100 未満なので不完全なレイズ
assert.equal(h.options(bb).minRaiseTo, 75);
assert.equal(t.act(tk(t, bb), 'raise', 75), null);
assert.equal(h.currentBet, 75);
assert.equal(h.toAct, btn);
assert.equal(h.options(btn).canRaise, false); // コールかフォールドのみ
assert.equal(t.act(tk(t, btn), 'raise', 200), 'レイズできません');
assert.equal(t.act(tk(t, btn), 'call', 0), null);
assert.equal(t.act(tk(t, sb), 'call', 0), null);
assert.equal(h.street, 'FLOP');
});
test('3人オールイン: すぐショーダウンになり、サイドポットと返金', () => {
const t = makeTable([100, 300, 500]);
t.start(T(0), 0);
const h = t.hand;
for (let guard = 0; guard < 10 && h.toAct != null; guard++) {
const o = h.options(h.toAct);
assert.equal(t.act(tk(t, h.toAct), o.canRaise ? 'raise' : 'call', o.canRaise ? o.maxRaiseTo : 0), null);
}
assert.equal(h.street, 'SHOWDOWN');
// 席2 の超過 200 は返金済み。メイン 300(全員)、サイド 400(席1・席2)
assert.equal(t.seats[2].stack, 200);
assert.deepEqual(
h.view().pots.map((p) => [p.amount, p.eligible.slice().sort()]),
[[300, [0, 1, 2]], [400, [1, 2]]]
);
// 席0 がメイン、席1 がサイドを獲得
assert.equal(t.award(T(0), [[0], [1]]), null);
assert.ok(h.finished && h.showdown);
assert.equal(t.seats[0].stack, 300);
assert.equal(t.seats[1].stack, 400);
assert.equal(t.seats[2].stack, 200);
});
test('勝者の指定: 山分け(端数はボタンの左から)と不正な指定の拒否', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
// SB をフォールドさせて、BB とボタンで 50 のポットを争う(SB の 10 + 20 + 20)
assert.equal(t.act(tk(t, btn), 'call', 0), null);
assert.equal(t.act(tk(t, sb), 'fold', 0), null);
assert.equal(t.act(tk(t, bb), 'check', 0), null);
checkDown(t);
assert.equal(h.street, 'SHOWDOWN');
const host = T(0);
// ホスト以外は、ショーダウンに残った人でも分配できない
for (const s of [sb, bb, btn]) {
if (tk(t, s) !== host) assert.equal(t.award(tk(t, s), [[bb]]), 'ポットの分配はホストのみ行えます');
}
assert.ok(t.award(T(8), [[bb]])); // 着席していない
assert.ok(t.award(host, []));
assert.ok(t.award(host, [[]]));
assert.ok(t.award(host, [[sb]])); // フォールドした人は勝てない
assert.ok(t.award(host, [[bb, bb]]));
assert.ok(t.award(host, [[bb], [btn]])); // ポットは1つ
assert.ok(!h.finished);
assert.equal(t.award(host, [[bb, btn]]), null);
// 50 を2人で → 25 ずつ
assert.equal(t.seats[bb].stack, 1005);
assert.equal(t.seats[btn].stack, 1005);
assert.equal(t.award(host, [[bb]]), 'ショーダウンではありません');
});
test('ホストが退席したら次のホストが分配する', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
// ホスト(席0)以外の2人と一緒にショーダウンまで進め、ホストが退席する
checkDown(t);
assert.equal(h.street, 'SHOWDOWN');
t.leave(T(0));
assert.notEqual(t.hostToken, T(0));
const live = h.live().map((p) => p.seat);
assert.equal(t.award(T(0), [[live[0]]]), 'ポットの分配はホストのみ行えます');
assert.equal(t.award(t.hostToken, [[live[0]]]), null);
assert.ok(h.finished);
});
test('端数はボタンの左から配る', () => {
const t = makeTable([1000, 1000, 1000], { smallBlind: 5, bigBlind: 10 });
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
t.act(tk(t, btn), 'call', 0);
t.act(tk(t, sb), 'fold', 0);
t.act(tk(t, bb), 'check', 0);
checkDown(t);
// 25 を2人で → ボタンの左に近い BB が 13、ボタンが 12
assert.equal(t.award(T(0), [[btn, bb]]), null);
assert.equal(t.seats[bb].stack, 990 + 13);
assert.equal(t.seats[btn].stack, 990 + 12);
});
test('ホストはショーダウンに参加していなくても勝者を選べる', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
// ホスト(席0)だけフォールドさせる
for (let guard = 0; guard < 50 && h.toAct != null; guard++) {
const o = h.options(h.toAct);
t.act(tk(t, h.toAct), h.toAct === 0 ? 'fold' : o.canCheck ? 'check' : 'call', 0);
}
assert.equal(h.street, 'SHOWDOWN');
const other = h.live().map((p) => p.seat);
assert.equal(t.canAward(T(0)), true);
assert.equal(t.award(T(0), [[other[0]]]), null);
assert.ok(h.finished);
});
test('ショーダウン中に1人を残して退席したら残った人が勝つ', () => {
const t = makeTable([1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
checkDown(t);
assert.equal(h.street, 'SHOWDOWN');
const [a, b] = h.order;
t.leave(tk(t, a));
assert.ok(h.finished && !h.showdown);
assert.equal(t.seats[b].stack, 1020);
});
test('離席中の人の手番は自動でチェック/フォールド、次のハンドから外れる', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
const first = h.toAct;
assert.equal(t.setSittingOut(tk(t, first), true), null);
assert.ok(h.players.get(first).folded);
assert.notEqual(h.toAct, first);
t.act(tk(t, h.toAct), 'fold', 0); // 残り1人で終了
assert.ok(h.finished);
assert.equal(t.nextHand(T(0), 0), null);
t.tick(10 ** 6);
t.tick(10 ** 7);
assert.equal(t.hand.number, 2);
assert.ok(!t.hand.players.has(first));
assert.equal(t.setSittingOut(tk(t, first), false), null);
assert.ok(t.eligibleSeats().includes(first));
});
test('一時停止中はアクションも勝者の決定も次のハンドもできない', () => {
const t = makeTable([1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
t.togglePause(T(0), 1000);
assert.equal(t.act(tk(t, h.toAct), 'fold', 0), '一時停止中です');
t.togglePause(T(0), 2000);
checkDown(t);
t.togglePause(T(0), 3000);
assert.equal(t.award(T(0), [[h.order[0]]]), '一時停止中です');
t.togglePause(T(0), 4000);
assert.equal(t.award(T(0), [[h.order[0]]]), null);
t.togglePause(T(0), 5000);
assert.equal(t.nextHand(T(0), 5000), '一時停止中です');
t.tick(10 ** 7);
t.tick(10 ** 8);
assert.equal(t.hand.number, 1); // 止まっている
t.togglePause(T(0), 10 ** 8);
assert.equal(t.nextHand(T(0), 10 ** 8), null);
t.tick(10 ** 8 + 10 ** 5);
t.tick(10 ** 8 + 10 ** 6);
assert.equal(t.hand.number, 2);
});
test('次のハンドはホストが「次のハンドへ」を押すまで始まらない', () => {
for (const mode of ['chips', 'friends']) {
const t = makeTable([1000, 1000, 1000], { mode });
t.start(T(0), 0);
assert.equal(t.nextHand(T(0), 0), 'ハンドが終わっていません');
const h = t.hand;
for (let i = 0; i < 10 && !h.finished; i++) t.act(tk(t, h.toAct), 'fold', 0);
assert.ok(h.finished);
assert.equal(t.publicState(T(1), new Set()).awaitingNext, true);
for (let k = 1; k <= 5; k++) t.tick(k * 10 ** 6);
assert.equal(t.hand, h); // 待っている
assert.equal(t.nextHand(T(1), 10 ** 7), 'ホストのみ操作できます');
assert.equal(t.nextHand(T(0), 10 ** 7), null);
assert.equal(t.awaitingNext, false);
t.tick(10 ** 7);
assert.equal(t.hand.number, 2);
}
});
test('ホストが退席したら、次のホストが押す。人間がいなければ待たずに進む', () => {
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
t.start(T(0), 0);
const h = t.hand;
for (let i = 0; i < 10 && !h.finished; i++) t.act(tk(t, h.toAct), 'fold', 0);
assert.ok(t.leave(T(0)));
assert.ok(t.awaitingNext);
assert.equal(t.nextHand(T(0), 0), 'ホストのみ操作できます');
assert.ok(t.isHost(T(1)));
assert.equal(t.nextHand(T(1), 0), null);
t.tick(1);
assert.equal(t.hand.number, 2);
// CPU戦で人間が退席すると、ボタンを待たずに片付けへ進む
const c = makeTable([1000], { mode: 'cpu', cpuCount: 2 });
c.start(T(0), 0);
const ch = c.hand;
c.leave(T(0));
let now = 0;
for (let k = 0; k < 400 && !ch.finished; k++) c.tick((now += 5000));
assert.ok(ch.finished);
assert.equal(c.awaitingNext, false);
});
test('ハンド中の退席: フォールド扱いで席はハンド終了まで予約', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
t.act(tk(t, btn), 'call', 0);
assert.ok(t.leave(tk(t, sb))); // 手番の SB が退席
assert.ok(h.players.get(sb).folded);
assert.ok(t.seats[sb].left);
assert.equal(t.playerCount, 2);
// 新しく来た人は予約席には座らない
assert.notEqual(t.join(T(9), 'new'), sb);
assert.equal(t.act(tk(t, bb), 'check', 0), null);
assert.equal(h.street, 'FLOP');
});
test('2人のうち片方が退席したら残った人が勝つ(コールされていない分は退席者に返る)', () => {
const t = makeTable([1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
const stay = h.toAct; // SB(ボタン)
const other = h.order.find((s) => s !== stay);
t.leave(tk(t, other));
assert.ok(h.finished && !h.showdown);
// BB の 20 のうち SB の 10 を超える分は返り、SB は 10 + 10 を獲得
assert.equal(t.seats[stay].stack, 1010);
assert.equal(t.seats[other].stack, 990);
});
test('チェックできるのに降りてサイドポットの対象者がいなくなるフォールドは断る', () => {
// 席0・席1 がオールイン、席2・席3 がサイドポットを作ったあと、席2 が降り、最後の席3 もチェックせずに降りようとする
// (@pokertools/engine はサイドポットの対象者がいなくなるとエラーになる)
const t = makeTable([100, 200, 1000, 1000]);
t.buttonSeat = 3;
t.start(T(0), 0);
const h = t.hand;
assert.deepEqual(h.order, [1, 2, 3, 0]);
assert.equal(t.act(tk(t, 3), 'raise', 500), null);
assert.equal(t.act(tk(t, 0), 'call', 0), null); // オールイン
assert.equal(t.act(tk(t, 1), 'call', 0), null); // オールイン
assert.equal(t.act(tk(t, 2), 'call', 0), null);
assert.equal(h.street, 'FLOP');
assert.equal(t.act(tk(t, 2), 'fold', 0), null);
assert.equal(t.act(tk(t, 3), 'fold', 0), 'いまはフォールドできません。チェックしてください');
assert.equal(h.toAct, 3);
// 退席なら代わりにチェックしたことになる(残りはオールインの人だけなのでショーダウンへ)
assert.ok(t.leave(tk(t, 3)));
assert.equal(h.street, 'SHOWDOWN');
assert.deepEqual(
h.view().pots.map((p) => [p.amount, p.eligible]),
[
[400, [1, 3, 0]],
[300, [1, 3]],
[600, [3]],
]
);
});
test('他人の手番では操作できない・不正な額は拒否', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
const notTurn = h.order.find((s) => s !== h.toAct);
assert.equal(t.act(tk(t, notTurn), 'fold', 0), 'あなたの番ではありません');
const me = tk(t, h.toAct);
assert.ok(t.act(me, 'raise', 10 ** 9));
assert.ok(t.act(me, 'raise', 40.5));
assert.ok(t.act(me, 'check', 0)); // BB に対してチェックはできない
assert.ok(t.act(T(8), 'fold', 0)); // 着席していない
});
test('リバイ', () => {
const t = makeTable([1000, 1000]);
t.seats[1].stack = 0;
assert.equal(t.rebuy(tk(t, 0)), 'チップが残っています');
assert.equal(t.rebuy(tk(t, 1)), null);
assert.equal(t.seats[1].stack, 1000);
assert.equal(t.seats[1].buyIns, 2);
});
test('ランダムに遊んでもチップの合計が変わらず、必ず終わる', () => {
let rnd = 12345;
const rand = (n) => {
rnd = (rnd * 1103515245 + 12345) % 2147483648;
return rnd % n;
};
let showdowns = 0;
let sidePots = 0;
for (let game = 0; game < 60; game++) {
const n = 2 + (game % 8);
const stacks = Array.from({ length: n }, () => 20 + rand(2000));
const t = makeTable(stacks);
const sum = stacks.reduce((a, b) => a + b, 0);
t.start(T(0), 0);
let now = 0;
for (let step = 0; step < 20000 && t.hand; step++) {
const h = t.hand;
if (!h.finished && h.toAct != null) {
const o = h.options(h.toAct);
const r = rand(10);
const me = tk(t, h.toAct);
let err;
if (r < 2) {
err = t.act(me, 'fold', 0);
if (err && o.canCheck) err = t.act(me, 'check', 0); // エンジンが断るフォールド
} else if (r < 6 || !o.canRaise) err = t.act(me, o.canCheck ? 'check' : 'call', 0);
else err = t.act(me, 'raise', o.minRaiseTo + rand(o.maxRaiseTo - o.minRaiseTo + 1));
assert.equal(err, null);
} else if (h.awaitingWinners) {
const pots = h.view().pots;
showdowns++;
if (pots.length > 1) sidePots++;
const winners = pots.map((p) => {
const w = p.eligible.filter(() => rand(3) === 0);
return w.length ? w : [p.eligible[rand(p.eligible.length)]];
});
assert.equal(t.award(T(0), winners), null);
} else {
if (t.awaitingNext) assert.equal(t.nextHand(T(0), now), null);
t.tick((now += 10 ** 5));
}
for (const s of t.seats) if (s) assert.ok(s.stack >= 0);
const inPot = t.hand && !t.hand.finished ? t.hand.view().potTotal : 0;
assert.equal(t.seats.reduce((a, s) => a + (s ? s.stack : 0), 0) + inPot, sum);
if (t.hand && t.hand.number > 40) break;
}
}
assert.ok(showdowns > 100 && sidePots > 20, `showdowns=${showdowns} sidePots=${sidePots}`);
});
// ---------- カード機能あり ----------
// エンジンの山札("As" 形式)
const deckOf = (hand) => hand.state.deck.map(stringifyCardCode);
// 手札と、これから配られる場札を差し替える(エンジンの状態をスナップショット経由で書き換える)。holes は h.order の順
function rig(hand, holes, board) {
const snap = hand.engine.snapshot;
const used = new Set([...holes.flat(), ...board]);
const rest = deckOf(hand)
.concat(...hand.order.map((s) => snap.players[hand.toEngine.get(s)].hand))
.filter((c) => !used.has(c));
hand.order.forEach((s, k) => {
const e = hand.toEngine.get(s);
snap.players[e] = { ...snap.players[e], hand: holes[k].slice() };
});
// 山札の先頭から バーン + フロップ3枚、バーン + ターン、バーン + リバー の順に配られる
const seq = ['X', ...board.slice(0, 3), 'X', board[3], 'X', board[4]].map((c) => (c === 'X' ? rest.pop() : c));
snap.deck = getCardCodes(seq.concat(rest));
hand.engine = PokerEngine.restore(snap);
hand.sync();
}
const SPECTATOR = 's'.repeat(32);
const viewOf = (t, token) => t.publicState(token, new Set()).hand;
test('役の判定と比較', () => {
const h = (s) => bestHand(s.split(' '));
assert.equal(h('As Ks Qs Js Ts 2d 3c').name, 'ロイヤルフラッシュ');
assert.equal(h('5s 4s 3s 2s As Kd Kc').name, 'ストレートフラッシュ');
assert.equal(h('9h 9d 9s 9c 2d 3c 4h').name, 'フォーカード');
assert.equal(h('9h 9d 9s 2c 2d 3c 4h').name, 'フルハウス');
assert.equal(h('Ah 9h 7h 4h 2h Kd Kc').name, 'フラッシュ');
assert.equal(h('Ah 2d 3c 4s 5h Kd Kc').name, 'ストレート');
assert.equal(h('Qh Qd Qs 7c 2d 3c 4h').name, 'スリーカード');
assert.equal(h('Qh Qd 7s 7c 2d 3c 4h').name, 'ツーペア');
assert.equal(h('Qh Qd 8s 7c 2d 3c 5h').name, 'ワンペア');
assert.equal(h('Ah Qd 8s 7c 2d 3c 5h').name, 'ハイカード');
// A-5 は最弱のストレート
assert.ok(compareScore(h('Ah 2d 3c 4s 5h').score, h('2h 3d 4c 5s 6h').score) < 0);
// キッカー
assert.ok(compareScore(h('Ah Ad Ks 7c 2d').score, h('As Ac Qs 7d 2h').score) > 0);
// 同点
assert.equal(compareScore(h('Ah Kd Qs Jc 9d').score, h('As Kc Qd Jh 9s').score), 0);
// ツーペアは上のペア → 下のペア → キッカー
assert.ok(compareScore(h('Kh Kd 3s 3c Ad').score, h('Qh Qd Js Jc Ad').score) > 0);
});
test('カードなしのテーブルでは手札も場札も扱わない', () => {
const t = makeTable([1000, 1000]);
t.start(T(0), 0);
assert.equal(t.publicState(T(0), new Set()).cardsEnabled, false);
const v = viewOf(t, T(0));
assert.deepEqual(v.board, []);
assert.equal('cards' in v.players[0], false);
});
test('カードあり: 自分の手札だけが見え、ストリートごとに場札が配られ、勝者は自動で決まる', () => {
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
assert.equal(t.publicState(tk(t, sb), new Set()).cardsEnabled, true);
let v = viewOf(t, tk(t, sb));
assert.equal(v.players[sb].cards.length, 2);
assert.equal(v.players[bb].cards, null);
assert.equal(v.players[btn].cards, null);
assert.equal(viewOf(t, SPECTATOR).players[sb].cards, null);
t.act(tk(t, btn), 'call', 0);
t.act(tk(t, sb), 'call', 0);
t.act(tk(t, bb), 'check', 0);
assert.equal(h.street, 'FLOP');
assert.equal(h.board.length, 3);
assert.ok(viewOf(t, tk(t, sb)).players[sb].handName);
t.act(tk(t, sb), 'check', 0);
t.act(tk(t, bb), 'check', 0);
t.act(tk(t, btn), 'check', 0);
assert.equal(h.board.length, 4);
checkDown(t);
assert.equal(h.board.length, 5);
assert.ok(h.finished && h.showdown);
assert.equal(h.awaitingWinners, false);
assert.equal(t.award(T(0), [[sb]]), 'ショーダウンではありません');
assert.ok(h.results[0].handName && h.results[0].bestCards.length === 5);
// ショーダウン後は残った人の手札が公開される
v = viewOf(t, SPECTATOR);
for (const s of [sb, bb, btn]) assert.equal(v.players[s].cards.length, 2);
// すべて別のカード(バーンの3枚は山札から消えている)
const all = [...deckOf(h), ...h.board, ...h.list.flatMap((p) => p.cards)];
assert.equal(new Set(all).size, all.length);
assert.equal(all.length, 52 - 3);
});
test('カードあり: フォールドした人の手札は公開されない', () => {
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
t.act(tk(t, btn), 'fold', 0);
t.act(tk(t, sb), 'call', 0);
t.act(tk(t, bb), 'check', 0);
checkDown(t);
assert.ok(h.finished);
const v = viewOf(t, tk(t, sb));
assert.equal(v.players[btn].cards, null);
assert.equal(v.players[bb].cards.length, 2);
assert.equal(viewOf(t, tk(t, btn)).players[btn].cards.length, 2); // 本人には見える
});
test('カードあり: 3人オールインで場札を順にめくり、サイドポットを自動で分配', () => {
const t = makeTable([100, 300, 500], { mode: 'friends' });
t.start(T(0), 0);
const h = t.hand;
// 席0 が最強、席1 が2番目、席2 が最弱
const holes = { 0: ['As', 'Ah'], 1: ['Ks', 'Kh'], 2: ['7c', '2d'] };
rig(h, h.order.map((s) => holes[s]), ['Ad', 'Kd', '9c', '5s', '3h']);
for (let guard = 0; guard < 10 && h.toAct != null; guard++) {
const o = h.options(h.toAct);
assert.equal(t.act(tk(t, h.toAct), o.canRaise ? 'raise' : 'call', o.canRaise ? o.maxRaiseTo : 0), null);
}
assert.ok(h.runout && h.revealed);
assert.equal(viewOf(t, SPECTATOR).players[2].cards.length, 2);
const step = config.RUNOUT_STEP_MS;
t.tick(0);
// 一時停止中はめくらない
t.togglePause(T(0), 0);
t.tick(10 * step);
assert.equal(h.board.length, 0);
t.togglePause(T(0), 10 * step);
t.tick(10 * step + step - 1);
assert.equal(h.board.length, 0);
t.tick(11 * step);
assert.equal(h.board.length, 3);
t.tick(12 * step);
assert.equal(h.board.length, 4);
t.tick(13 * step);
assert.equal(h.board.length, 5);
assert.ok(!h.finished);
t.tick(14 * step);
assert.ok(h.finished && h.showdown);
// メイン 300 → 席0、サイド 400 → 席1、席2 の超過 200 は返金
assert.equal(t.seats[0].stack, 300);
assert.equal(t.seats[1].stack, 400);
assert.equal(t.seats[2].stack, 200);
assert.deepEqual(
h.results.map((r) => [r.amount, r.winners, r.handName]),
[
[300, [0], 'スリーカード'],
[400, [1], 'スリーカード'],
]
);
});
test('カードあり: 引き分けは山分け', () => {
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
rig(h, [['2c', '3d'], ['2d', '3c'], ['4h', '5h']], ['As', 'Ks', 'Qs', 'Js', 'Ts']); // 全員ロイヤル
t.act(tk(t, btn), 'raise', 40);
t.act(tk(t, sb), 'call', 0);
t.act(tk(t, bb), 'call', 0);
checkDown(t);
assert.ok(h.finished);
assert.equal(t.seats[sb].stack, 1000);
assert.equal(t.seats[bb].stack, 1000);
assert.equal(t.seats[btn].stack, 1000);
assert.deepEqual(h.results[0].winners, [sb, bb, btn]);
assert.equal(h.results[0].handName, 'ロイヤルフラッシュ');
});
test('カードあり: オールインのあとに退席しても手は最後まで生きている', () => {
const t = makeTable([1000, 1000], { mode: 'friends' });
t.start(T(0), 0);
const h = t.hand;
const [a, b] = h.order;
rig(h, [['As', 'Ah'], ['7c', '2d']], ['Ad', 'Kd', '9c', '5s', '3h']);
t.act(tk(t, h.toAct), 'raise', h.options(h.toAct).maxRaiseTo);
t.act(tk(t, h.toAct), 'call', 0);
assert.ok(h.runout);
assert.ok(t.leave(tk(t, a)));
assert.ok(h.runout && !h.finished);
const step = config.RUNOUT_STEP_MS;
for (let k = 0; k <= 4; k++) t.tick(k * step);
assert.ok(h.finished && h.showdown);
assert.deepEqual(h.results[0].winners, [a]);
assert.equal(t.seats[a].stack, 2000);
assert.equal(t.seats[b].stack, 0);
});
test('カードあり: ランダムに遊んでもチップの合計が変わらず、必ず終わる', () => {
let rnd = 777;
const rand = (n) => {
rnd = (rnd * 1103515245 + 12345) % 2147483648;
return rnd % n;
};
let showdowns = 0;
let runouts = 0;
for (let game = 0; game < 40; game++) {
const n = 2 + (game % 8);
const stacks = Array.from({ length: n }, () => 20 + rand(2000));
const t = makeTable(stacks, { mode: 'friends' });
const sum = stacks.reduce((a, b) => a + b, 0);
t.start(T(0), 0);
let now = 0;
let prev = null;
for (let step = 0; step < 20000 && t.hand; step++) {
const h = t.hand;
if (h !== prev) {
if (prev && prev.showdown) {
showdowns++;
assert.equal(prev.board.length, 5);
}
prev = h;
assert.equal(new Set([...deckOf(h), ...h.list.flatMap((p) => p.cards)]).size, 52);
}
if (!h.finished && h.toAct != null) {
const o = h.options(h.toAct);
const r = rand(10);
const me = tk(t, h.toAct);
let err;
if (r < 2) {
err = t.act(me, 'fold', 0);
if (err && o.canCheck) err = t.act(me, 'check', 0); // エンジンが断るフォールド
} else if (r < 6 || !o.canRaise) err = t.act(me, o.canCheck ? 'check' : 'call', 0);
else err = t.act(me, 'raise', o.minRaiseTo + rand(o.maxRaiseTo - o.minRaiseTo + 1));
assert.equal(err, null);
} else {
assert.equal(h.awaitingWinners, false);
if (h.runout && t.runoutAt == null) runouts++;
if (t.awaitingNext) assert.equal(t.nextHand(T(0), now), null);
t.tick((now += 10 ** 5));
}
for (const s of t.seats) if (s) assert.ok(s.stack >= 0);
const inPot = t.hand && !t.hand.finished ? t.hand.view().potTotal : 0;
assert.equal(t.seats.reduce((a, s) => a + (s ? s.stack : 0), 0) + inPot, sum);
if (t.hand && t.hand.number > 40) break;
}
}
assert.ok(showdowns > 100 && runouts > 20, `showdowns=${showdowns} runouts=${runouts}`);
});
// ---------- CPU ----------
const ai = require('../server/ai');
// 再現できる乱数
function seeded(seed) {
let x = seed;
return () => {
x = (x * 1103515245 + 12345) % 2147483648;
return x / 2147483648;
};
}
test('役の判定: 選んだ5枚は7枚全体と同じ強さ', () => {
const all = [];
for (const r of '23456789TJQKA') for (const su of 'shdc') all.push(r + su);
for (let i = 0; i < 3000; i++) {
for (let j = all.length - 1; j > 0; j--) {
const k = crypto.randomInt(j + 1);
[all[j], all[k]] = [all[k], all[j]];
}
const seven = all.slice(0, 7);
const best = bestHand(seven);
assert.equal(best.cards.length, 5);
assert.ok(best.cards.every((c) => seven.includes(c)));
assert.equal(compareScore(bestHand(best.cards).score, best.score), 0, `${seven}`);
}
});
test('CPU: 勝率の見積もり', () => {
const rng = seeded(42);
const near = (v, target) => assert.ok(Math.abs(v - target) < 0.03, `${v} ≉ ${target}`);
near(ai.equity(['As', 'Ah'], [], 1, 4000, rng), 0.85);
near(ai.equity(['7s', '2h'], [], 1, 4000, rng), 0.35);
// ナッツは負けない
assert.equal(ai.equity(['As', 'Ks'], ['Qs', 'Js', 'Ts'], 3, 500, rng), 1);
});
test('CPU: 強い手はレイズし、弱い手は大きなベットに降りる', () => {
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
rig(h, [['7c', '2d'], ['8h', '3s'], ['As', 'Ah']], ['Kd', '9c', '4s', '5h', 'Jd']);
const rng = seeded(1);
const d = ai.decide(h, btn, { rng });
assert.equal(d.type, 'raise');
t.act(tk(t, btn), 'raise', 1000); // オールイン
assert.equal(ai.decide(h, sb, { rng }).type, 'fold');
});
test('CPU: どんな状況でも正しいアクションを返す', () => {
const rng = seeded(7);
let actions = 0;
for (let game = 0; game < 12; game++) {
const n = 2 + (game % 8);
const stacks = Array.from({ length: n }, () => 20 + Math.floor(rng() * 2000));
const t = makeTable(stacks, { mode: 'friends' });
const sum = stacks.reduce((a, b) => a + b, 0);
t.start(T(0), 0);
let now = 0;
for (let step = 0; step < 5000 && t.hand && t.hand.number <= 15; step++) {
const h = t.hand;
if (!h.finished && h.toAct != null) {
const style = ai.randomStyle(rng);
const d = ai.decide(h, h.toAct, { style, rng, iterations: 60 });
assert.equal(h.act(h.toAct, d.type, d.amount), null, JSON.stringify(d));
actions++;
} else {
if (t.awaitingNext) assert.equal(t.nextHand(T(0), now), null);
t.tick((now += 10 ** 5));
}
const inPot = t.hand && !t.hand.finished ? t.hand.potTotal : 0;
assert.equal(t.seats.reduce((a, s) => a + (s ? s.stack : 0), 0) + inPot, sum);
}
}
assert.ok(actions > 300, `actions=${actions}`);
});
test('CPU: どの思考(ライブラリ)も自分で正しいアクションを決める', () => {
const brains = ai.availableBrains();
// 少なくとも純粋な JavaScript のライブラリの思考は必ず使える
for (const b of ['montecarlo', 'range', 'madehand']) assert.ok(brains.includes(b), b);
for (const brain of brains) {
const rng = seeded(brain.length * 101);
let decisions = 0;
for (let game = 0; game < 6; game++) {
const n = 2 + (game % 5);
const stacks = Array.from({ length: n }, () => 100 + Math.floor(rng() * 1500));
const t = makeTable(stacks, { mode: 'friends' });
t.start(T(0), 0);
let now = 0;
for (let step = 0; step < 3000 && t.hand && t.hand.number <= 8; step++) {
const h = t.hand;
if (!h.finished && h.toAct != null) {
const style = ai.randomStyle(rng, brain);
assert.equal(style.brain, brain);
// 思考そのものがエラーにならず、何かを返す(バランス型に頼らない)
assert.ok(ai.BRAINS[brain].decide(h, h.toAct, { style, rng, iterations: 80 }), brain);
const d = ai.decide(h, h.toAct, { style, rng, iterations: 80 });
assert.equal(h.act(h.toAct, d.type, d.amount), null, `${brain} ${JSON.stringify(d)}`);
decisions++;
} else {
if (t.awaitingNext) assert.equal(t.nextHand(T(0), now), null);
t.tick((now += 10 ** 5));
}
}
}
assert.ok(decisions > 60, `${brain} decisions=${decisions}`);
}
});
test('CPU: 思考の性格は弱い手で降り、強い手では降りない', () => {
for (const brain of ai.availableBrains()) {
const rng = seeded(3);
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
rig(h, [['7c', '2d'], ['8h', '3s'], ['As', 'Ah']], ['Kd', '9c', '4s', '5h', 'Jd']);
const style = ai.randomStyle(rng, brain);
assert.notEqual(ai.decide(h, btn, { style, rng }).type, 'fold', brain); // AA
t.act(tk(t, btn), 'raise', 1000); // オールイン
assert.equal(ai.decide(h, sb, { style, rng }).type, 'fold', brain); // 72o
}
});
test('CPU戦: CPU ごとに違う思考が割り当てられ、どれを使っているかは画面に出ない', () => {
const brains = ai.availableBrains();
assert.deepEqual(new Set(ai.makeStyles(brains.length).map((s) => s.brain)), new Set(brains));
const t = new Table({
id: 'c',
name: 'cpu',
startingStack: 1000,
smallBlind: 10,
bigBlind: 20,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
mode: 'cpu',
cpuCount: 8,
});
const used = new Set(t.seats.filter((s) => s && s.bot).map((s) => s.style.brain));
assert.equal(used.size, brains.length);
t.join(T(0), 'me');
t.start(T(0), 0);
const json = JSON.stringify([t.publicState(T(0), new Set()), t.summary()]);
for (const word of ['brain', 'style', ...brains, 'aggression', 'Threshold']) assert.ok(!json.includes(word), word);
});
test('CPU戦: CPU が席に着き、ホストは人間のまま、CPU は自動で行動する', () => {
const t = new Table({
id: 'c',
name: 'cpu',
startingStack: 1000,
smallBlind: 10,
bigBlind: 20,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
mode: 'cpu',
cpuCount: 3,
});
assert.equal(t.cardsEnabled, true);
assert.deepEqual(t.seats.map((s) => Boolean(s && s.bot)), [false, true, true, true, false, false, false, false, false]);
assert.equal(t.join(T(0), 'me'), 0);
assert.equal(t.isHost(T(0)), true);
const v = t.publicState(T(0), new Set([T(0)]));
assert.equal(v.mode, 'cpu');
assert.ok(v.seats[1].bot && v.seats[1].connected);
assert.equal(v.seats[0].bot, false);
assert.equal(t.start(T(0), 0), null);
// 人間の手番まで CPU が進める(人間の手番は代わりに動かない)
let now = 0;
for (let k = 0; k < 200 && t.hand.toAct !== 0 && !t.hand.finished; k++) t.tick((now += 5000));
if (!t.hand.finished) {
const before = t.hand.actions;
t.tick((now += 60000));
t.tick((now += 60000));
assert.equal(t.hand.toAct, 0);
assert.equal(t.hand.actions, before);
}
// 人間が離席すると CPU だけで次のハンドは始まらない
t.setSittingOut(T(0), true);
for (let k = 0; k < 400 && t.hand; k++) {
if (t.awaitingNext) assert.equal(t.nextHand(T(0), now), null);
t.tick((now += 5000));
}
assert.equal(t.hand, null);
t.setSittingOut(T(0), false);
// チップがなくなった CPU は次のハンドでリバイ
t.seats[2].stack = 0;
for (let k = 0; k < 10 && !t.hand; k++) t.tick((now += 5000));
assert.ok(t.hand);
assert.equal(t.seats[2].buyIns, 2);
assert.ok(t.hand.players.has(2));
// ホストが退席しても CPU はホストにならない
t.leave(T(0));
assert.equal(t.hostToken, null);
t.join(T(5), 'friend');
assert.equal(t.isHost(T(5)), true);
});
test('CPU戦: 人間が全員退席したらテーブルを閉じる(ハンド中でも)', () => {
const opts = {
id: 'c',
name: 'cpu',
startingStack: 1000,
smallBlind: 10,
bigBlind: 20,
blindTimerEnabled: false,
blindIntervalMin: 10,
hostToken: T(0),
};
const t = new Table({ ...opts, mode: 'cpu', cpuCount: 2 });
t.join(T(0), 'me');
t.join(T(5), 'friend');
t.start(T(0), 0);
t.leave(T(0));
assert.equal(t.shouldClose(), false); // まだ友達がいる
t.leave(T(5));
assert.equal(t.shouldClose(), true);
// CPU戦以外は閉じない(これまでどおり、誰も接続していない状態が続いたら削除)
const f = new Table({ ...opts, mode: 'friends' });
f.join(T(0), 'me');
f.leave(T(0));
assert.equal(f.shouldClose(), false);
});