Files
Poker/test/engine.test.js
T
agomizi1cmandClaude Opus 5.5 8de55f7ca5 Implement Texas Hold'em play: dealing, betting, side pots, showdown
- Hand engine (server/hand.js): crypto-shuffled deck kept server-side,
  blinds (heads-up button = SB), min-raise rules incl. incomplete all-in,
  uncalled bet refunds, side pots, split pots, all-in runout
- Hand evaluator (server/cards.js) and node:test suite (npm test)
- Table flow: hands auto-continue after host START, 30s action clock
  (timeout = check/fold + sit out), sit out / sit in, rebuy, mid-hand leave
  folds and keeps the seat reserved until the hand ends, pause freezes all clocks
- Only each player's own hole cards are sent until a showdown/runout reveal;
  all actions validated server-side
- Play UI: board, hole cards, bets, action tags, turn timer, raise panel with
  presets, showdown results with winning cards highlighted, compact 7+ layout
- Remove the host crown and the temporary "next phase" control

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

304 lines
12 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 { bestHand, compareScore } = require('../server/cards');
const { Table } = require('../server/tables');
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;
}
// 山札の上(pop される順)に指定のカードを積む
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 = [];
seq.push('X', board[0], board[1], board[2], 'X', board[3], 'X', board[4]);
const filler = rest.slice();
hand.deck = filler.slice(0, filler.length - 8).concat(seq.map((c) => (c === 'X' ? filler.pop() : c)).reverse());
}
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('ヘッズアップ: ボタンが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(t.seats[sb].token, 'fold', 0, 1), null);
assert.ok(h.finished);
assert.equal(t.seats[sb].stack, 990);
assert.equal(t.seats[bb].stack, 1010);
// 手札は公開されない
assert.equal(h.view(sb, t.seats[sb].token).players[bb].cards, null);
});
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(t.seats[btn].token, 'call', 0, 1), null);
assert.equal(t.act(t.seats[sb].token, 'call', 0, 1), null);
assert.equal(h.street, 'PREFLOP');
assert.equal(h.toAct, bb); // BB はまだ動ける
assert.ok(h.options(bb).canRaise);
assert.equal(t.act(t.seats[bb].token, 'check', 0, 1), null);
assert.equal(h.street, 'FLOP');
assert.equal(h.board.length, 3);
assert.equal(h.pot, 60);
assert.equal(h.toAct, sb); // ポストフロップは SB から
});
test('ミニマムレイズと、足りないオールインは再レイズ権を与えない', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
const tk = (s) => t.seats[s].token;
// 最低レイズ額は 40(20 + 20)
assert.ok(t.act(tk(btn), 'raise', 39, 1));
assert.equal(t.act(tk(btn), 'raise', 60, 1), null); // +40 のレイズ
assert.equal(h.options(sb).minRaiseTo, 100);
assert.equal(t.act(tk(sb), 'call', 0, 1), null);
// BB は 75 しか持っていないとする → 75 オールインは 100 未満なので不完全なレイズ
t.seats[bb].stack = 75 - 20;
assert.equal(t.act(tk(bb), 'raise', 75, 1), null);
assert.equal(h.currentBet, 75);
assert.equal(h.toAct, btn);
assert.equal(h.options(btn).canRaise, false); // コールかフォールドのみ
assert.equal(t.act(tk(btn), 'raise', 200, 1), 'レイズできません');
assert.equal(t.act(tk(btn), 'call', 0, 1), null);
assert.equal(t.act(tk(sb), 'call', 0, 1), null);
assert.equal(h.street, 'FLOP');
});
test('3人オールイン: サイドポットと返金', () => {
const t = makeTable([100, 300, 500]);
t.start(T(0), 0);
const h = t.hand;
const tk = (s) => t.seats[s].token;
// 席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(h.toAct), o.canRaise ? 'raise' : 'call', o.canRaise ? o.maxRaiseTo : 0, 1), null);
}
assert.ok(h.revealed);
// 残りのカードを順にめくる
for (let now = 2; !h.finished && now < 100; now++) t.tick(now * config.RUNOUT_STEP_MS);
assert.ok(h.finished && h.showdown);
assert.equal(h.board.length, 5);
// メイン 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]), [[300, [0]], [400, [1]]]);
});
test('引き分けは山分け、端数はボタンの左から', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
const tk = (s) => t.seats[s].token;
const [sb, bb, btn] = h.order;
rig(h, [['2c', '3d'], ['2d', '3c'], ['4h', '5h']], ['As', 'Ks', 'Qs', 'Js', 'Ts']); // 全員ロイヤル
t.seats[sb].stack = 0; // SB は 10 だけ入れてオールイン扱いにする
h.players.get(sb).allIn = true;
t.act(tk(btn), 'call', 0, 1);
t.act(tk(bb), 'check', 0, 1);
for (let now = 2; !h.finished && now < 100; now++) {
if (h.toAct != null) t.act(tk(h.toAct), 'check', 0, now);
}
assert.ok(h.finished);
// メイン 30 を3人で 10 ずつ、サイド 20 を2人で 10 ずつ
assert.equal(t.seats[sb].stack, 10);
assert.equal(t.seats[bb].stack, 1000);
assert.equal(t.seats[btn].stack, 1000);
});
test('持ち時間切れで自動フォールドし離席扱い、次のハンドから外れる', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
const first = h.toAct;
t.tick(config.ACTION_TIME_MS + 1);
assert.ok(h.players.get(first).folded);
assert.ok(t.seats[first].sittingOut);
assert.notEqual(h.toAct, first);
// 残りの2人で終わらせる
while (!h.finished) {
const o = h.options(h.toAct);
t.act(t.seats[h.toAct].token, o.canCheck ? 'check' : 'call', 0, 2);
}
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(t.seats[first].token, 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(t.seats[h.toAct].token, 'fold', 0, 1500), '一時停止中です');
t.tick(10 ** 6);
assert.ok(!h.finished);
t.togglePause(T(0), 10 ** 6);
t.tick(10 ** 6 + config.ACTION_TIME_MS - 2000);
assert.ok(!h.finished);
t.tick(10 ** 6 + config.ACTION_TIME_MS);
assert.ok(h.finished);
});
test('ハンド中の退席: フォールド扱いで席はハンド終了まで予約', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
const [sb, bb, btn] = h.order;
const leaver = t.seats[sb].token;
t.act(t.seats[btn].token, 'call', 0, 1);
assert.ok(t.leave(leaver, 1)); // 手番の SB が退席
assert.ok(h.players.get(sb).folded);
assert.ok(t.seats[sb].left);
assert.equal(t.playerCount, 2);
// 新しく来た人は予約席には座らない
const seat = t.join(T(9), 'new');
assert.notEqual(seat, sb);
assert.equal(t.act(t.seats[bb].token, 'check', 0, 1), null);
assert.equal(h.street, 'FLOP');
// ホストが抜けたら次の人へ
if (leaver === T(0)) assert.notEqual(t.hostToken, T(0));
});
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(t.seats[other].token, 1);
assert.ok(h.finished);
// BB の 20 は返らずそのまま勝者へ
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(t.seats[notTurn].token, 'fold', 0, 1), 'あなたの番ではありません');
const tk = t.seats[h.toAct].token;
assert.ok(t.act(tk, 'raise', 10 ** 9, 1));
assert.ok(t.act(tk, 'raise', 40.5, 1));
assert.ok(t.act(tk, 'check', 0, 1)); // BB に対してチェックはできない
assert.ok(t.act(T(8), 'fold', 0, 1)); // 着席していない
});
test('リバイ', () => {
const t = makeTable([1000, 1000]);
t.seats[1].stack = 0;
assert.equal(t.rebuy(t.seats[0].token), 'チップが残っています');
assert.equal(t.rebuy(t.seats[1].token), 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;
};
for (let game = 0; game < 30; 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++) {
now += 100;
const h = t.hand;
if (h && !h.finished && h.toAct != null) {
const o = h.options(h.toAct);
const r = rand(10);
const tk = t.seats[h.toAct].token;
let err;
if (r < 2) err = t.act(tk, 'fold', 0, now);
else if (r < 6) err = t.act(tk, o.canCheck ? 'check' : 'call', 0, now);
else if (o.canRaise) {
const amt = o.minRaiseTo + rand(o.maxRaiseTo - o.minRaiseTo + 1);
err = t.act(tk, 'raise', amt, now);
} else err = t.act(tk, o.canCheck ? 'check' : 'call', 0, now);
assert.equal(err, null);
} else {
t.tick(now + 10 ** 5);
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(-1).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;
}
}
});