Use @pokertools/engine and evaluator for poker rules and hand ranking

Replace the hand-rolled betting rules, side pots, showdown and hand
evaluator with the published @pokertools/engine and @pokertools/evaluator
libraries (MIT, pinned to 2.1.0).

- server/hand.js now wraps a PokerEngine per hand: it maps seats,
  mirrors stacks, staggers the all-in runout, and pays host-chosen
  winners on cash-only tables.
- server/cards.js and server/ai.js use the library evaluator.
- Out-of-turn folds go through the engine's stand; all-in hands stay
  live when the player leaves; uncalled bets return to a leaver.
- The engine refuses some check-able folds that would leave a side pot
  without eligible players; those now return a message (or check for
  a leaving player) instead of crashing.
- BB must be greater than SB (engine requirement); Node >= 24.
- Tests rig cards via engine snapshots and cover the new edge cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-17 17:25:15 +09:00
1 parent 02c8e76237
commit d92953a270
9 files changed
+410 -382

No files matched your search

+93 -30
View File
@@ -5,6 +5,9 @@ process.env.ACCESS_KEY = process.env.ACCESS_KEY || '000000';
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');
@@ -78,17 +81,20 @@ test('BBのオプションとチェックでストリートが進む', () => {
});
test('ミニマムレイズと、足りないオールインは再レイズ権を与えない', () => {
const t = makeTable([1000, 1000, 1000]);
// ボタンを席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 しか持っていないとする → 75 オールインは 100 未満なので不完全なレイズ
t.seats[bb].stack = 75 - 20;
// 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);
@@ -268,15 +274,46 @@ test('ハンド中の退席: フォールド扱いで席はハンド終了まで
assert.equal(h.street, 'FLOP');
});
test('2人のうち片方が退席したら残った人が勝つ(BB は返らない)', () => {
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);
assert.equal(t.seats[stay].stack, 1020);
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('他人の手番では操作できない・不正な額は拒否', () => {
@@ -323,8 +360,10 @@ test('ランダムに遊んでもチップの合計が変わらず、必ず終
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);
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) {
@@ -350,14 +389,25 @@ test('ランダムに遊んでもチップの合計が変わらず、必ず終
// ---------- カード機能あり ----------
// 山札の上(pop される順)に指定のカードを積む。holes は h.order の順
// エンジンの山札("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 = 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 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);
@@ -427,7 +477,7 @@ test('カードあり: 自分の手札だけが見え、ストリートごとに
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)];
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);
});
@@ -510,16 +560,23 @@ test('カードあり: 引き分けは山分け', () => {
assert.equal(h.results[0].handName, 'ロイヤルフラッシュ');
});
test('カードあり: めくっている途中に1人を残して退席したら残った人が勝つ', () => {
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);
t.leave(T(1));
assert.ok(h.finished && !h.showdown && !h.runout);
assert.equal(t.seats[0].stack, 2000);
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('カードあり: ランダムに遊んでもチップの合計が変わらず、必ず終わる', () => {
@@ -546,15 +603,17 @@ test('カードあり: ランダムに遊んでもチップの合計が変わら
assert.equal(prev.board.length, 5);
}
prev = h;
assert.equal(new Set([...h.deck, ...h.list.flatMap((p) => p.cards)]).size, 52);
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);
else if (r < 6 || !o.canRaise) err = t.act(me, o.canCheck ? 'check' : 'call', 0);
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 {
@@ -574,7 +633,6 @@ test('カードあり: ランダムに遊んでもチップの合計が変わら
// ---------- CPU ----------
const ai = require('../server/ai');
const { newDeck } = require('../server/cards');
// 再現できる乱数
function seeded(seed) {
@@ -585,14 +643,19 @@ function seeded(seed) {
};
}
test('CPU: 高速な役判定は bestHand と同じ強さの順になる', () => {
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++) {
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}`);
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}`);
}
});