Files
Poker/test/engine.test.js
T
agomizi1cmandClaude Opus 5.5 0d3a054f0d Add CPU opponents and a table type selector (cash only / friends / CPU)
- Create screen: choose キャッシュオンリー (real cards, chips only), 友人戦
  (server deals and settles) or CPU戦 (友人戦 plus 1–8 CPU players); replaces
  the card toggle. The lobby shows each table's type
- Poker AI written from scratch (server/ai.js, no libraries): fast 7-card
  evaluator (checked against bestHand), Monte Carlo equity, and decisions from
  hand rank, pot odds, raise count, opponents and a per-CPU style (aggression,
  bluff rate); continuation bets via the last aggressor. It never sees other
  players' hole cards
- CPU seats: think time of 0.7–1.8s (faster when no human is left in the hand),
  automatic rebuy, never the host, no new hand unless a human is playing,
  re-think after pause
- Play UI: robot avatars and a "考えています…" status for CPU turns
- Tests for the evaluator, equity, legality of AI actions and CPU tables

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:55:25 +09:00

701 lines
26 KiB
JavaScript
Raw 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 はテスト用のダミー)
process.env.ACCESS_KEY = process.env.ACCESS_KEY || '000000';
const test = require('node:test');
const assert = require('node:assert/strict');
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('ミニマムレイズと、足りないオールインは再レイズ権を与えない', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
// 最低レイズ額は 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 しか持っていないとする → 75 オールインは 100 未満なので不完全なレイズ
t.seats[bb].stack = 75 - 20;
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);
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);
t.tick(10 ** 7);
t.tick(10 ** 8);
assert.equal(t.hand.number, 1); // 止まっている
t.togglePause(T(0), 10 ** 8);
t.tick(10 ** 8 + 10 ** 5);
t.tick(10 ** 8 + 10 ** 6);
assert.equal(t.hand.number, 2);
});
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人のうち片方が退席したら残った人が勝つ(BB は返らない)', () => {
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);
assert.equal(t.seats[stay].stack, 1020);
});
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);
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 {
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}`);
});
// ---------- カード機能あり ----------
// 山札の上(pop される順)に指定のカードを積む。holes は h.order の順
function rig(hand, holes, board) {
const used = new Set([...holes.flat(), ...board]);
const rest = hand.deck.concat(...[...hand.players.values()].map((p) => p.cards)).filter((c) => !used.has(c));
hand.order.forEach((seat, k) => (hand.players.get(seat).cards = holes[k].slice()));
// dealStreet はバーン1枚 + 場札を pop する
const seq = ['X', board[0], board[1], board[2], 'X', board[3], 'X', board[4]];
hand.deck = rest.slice(0, rest.length - 3).concat(seq.map((c) => (c === 'X' ? rest.pop() : c)).reverse());
}
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 = [...h.deck, ...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('カードあり: めくっている途中に1人を残して退席したら残った人が勝つ', () => {
const t = makeTable([1000, 1000], { mode: 'friends' });
t.start(T(0), 0);
const h = t.hand;
t.act(tk(t, h.toAct), 'raise', h.options(h.toAct).maxRaiseTo);
t.act(tk(t, h.toAct), 'call', 0);
assert.ok(h.runout);
t.leave(T(1));
assert.ok(h.finished && !h.showdown && !h.runout);
assert.equal(t.seats[0].stack, 2000);
});
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([...h.deck, ...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);
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++;
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');
const { newDeck } = require('../server/cards');
// 再現できる乱数
function seeded(seed) {
let x = seed;
return () => {
x = (x * 1103515245 + 12345) % 2147483648;
return x / 2147483648;
};
}
test('CPU: 高速な役判定は bestHand と同じ強さの順になる', () => {
for (let i = 0; i < 3000; i++) {
const d = newDeck();
const a = d.slice(0, 7);
const b = d.slice(7, 14);
const fast = Math.sign(ai.evaluate(a.map(ai.toInt)) - ai.evaluate(b.map(ai.toInt)));
const slow = Math.sign(compareScore(bestHand(a).score, bestHand(b).score));
assert.equal(fast, slow, `${a} / ${b}`);
}
});
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 {
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戦: 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++) 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);
});