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

+42 -88
View File
@@ -1,96 +1,50 @@
'use strict';
const crypto = require('crypto');
// 役の判定は @pokertools/evaluator(@pokertools/engine のショーダウンと同じ判定器)を使う。
// ここでは表示用に、最強の5枚と日本語の役名を求める。
// カードは "As"(スペードのA), "Td"(ダイヤの10)のような2文字
const RANKS = '23456789TJQKA';
const SUITS = 'shdc';
const { evaluate, getCardCodes, rank, HandRank } = require('@pokertools/evaluator');
// 暗号論的乱数で Fisher-Yates シャッフル(山札はサーバの外に出さない)
function newDeck() {
const deck = [];
for (const r of RANKS) for (const s of SUITS) deck.push(r + s);
for (let i = deck.length - 1; i > 0; i--) {
const j = crypto.randomInt(i + 1);
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
}
const HAND_NAMES = {
[HandRank.HighCard]: 'ハイカード',
[HandRank.OnePair]: 'ワンペア',
[HandRank.TwoPair]: 'ツーペア',
[HandRank.ThreeOfAKind]: 'スリーカード',
[HandRank.Straight]: 'ストレート',
[HandRank.Flush]: 'フラッシュ',
[HandRank.FullHouse]: 'フルハウス',
[HandRank.FourOfAKind]: 'フォーカード',
[HandRank.StraightFlush]: 'ストレートフラッシュ',
};
const ROYAL_FLUSH = 1; // 判定器のスコアは小さいほど強く、1 がロイヤルフラッシュ
const rankValue = (card) => RANKS.indexOf(card[0]) + 2;
// 5枚の役の強さ。配列を先頭から比較して大きいほうが強い
// [役の種類(0〜8), 比較用の数字...]
function score5(cards) {
const vals = cards.map(rankValue).sort((a, b) => b - a);
const flush = cards.every((c) => c[1] === cards[0][1]);
let straightHigh = 0;
if (new Set(vals).size === 5) {
if (vals[0] - vals[4] === 4) straightHigh = vals[0];
else if (vals[0] === 14 && vals[1] === 5) straightHigh = 5; // A-2-3-4-5
}
if (straightHigh && flush) return [8, straightHigh];
// 枚数の多い順、同じ枚数なら数字の大きい順
const counts = new Map();
for (const v of vals) counts.set(v, (counts.get(v) || 0) + 1);
const groups = [...counts].sort((a, b) => b[1] - a[1] || b[0] - a[0]);
const byGroup = groups.map((g) => g[0]);
if (groups[0][1] === 4) return [7, ...byGroup];
if (groups[0][1] === 3 && groups[1][1] === 2) return [6, ...byGroup];
if (flush) return [5, ...vals];
if (straightHigh) return [4, straightHigh];
if (groups[0][1] === 3) return [3, ...byGroup];
if (groups[0][1] === 2 && groups[1][1] === 2) return [2, ...byGroup];
if (groups[0][1] === 2) return [1, ...byGroup];
return [0, ...vals];
}
function compareScore(a, b) {
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const d = (a[i] || 0) - (b[i] || 0);
if (d) return d;
}
return 0;
}
const HAND_NAMES = [
'ハイカード',
'ワンペア',
'ツーペア',
'スリーカード',
'ストレート',
'フラッシュ',
'フルハウス',
'フォーカード',
'ストレートフラッシュ',
];
function handName(score) {
if (score[0] === 8 && score[1] === 14) return 'ロイヤルフラッシュ';
return HAND_NAMES[score[0]];
}
// 5〜7枚から最も強い5枚を選ぶ
// 5〜7枚の中で最も強い役。score は小さいほど強い
function bestHand(cards) {
let best = null;
const n = cards.length;
const pick = (start, chosen) => {
if (chosen.length === 5) {
const score = score5(chosen);
if (!best || compareScore(score, best.score) > 0) best = { score, cards: chosen.slice() };
return;
}
for (let i = start; i <= n - (5 - chosen.length); i++) {
chosen.push(cards[i]);
pick(i + 1, chosen);
chosen.pop();
}
};
pick(0, []);
return best && { score: best.score, cards: best.cards, name: handName(best.score) };
const codes = getCardCodes(cards);
const score = evaluate(codes);
const name = score === ROYAL_FLUSH ? 'ロイヤルフラッシュ' : HAND_NAMES[rank(codes)];
// 役を作っている5枚(7枚から5枚を選ぶ21通りで、全体と同じ強さになる組)
let five = cards.slice(0, 5);
if (cards.length > 5) {
const n = cards.length;
const pick = (start, chosen) => {
if (chosen.length === 5) return evaluate(chosen.map((i) => codes[i])) === score ? chosen.slice() : null;
for (let i = start; i <= n - (5 - chosen.length); i++) {
chosen.push(i);
const found = pick(i + 1, chosen);
chosen.pop();
if (found) return found;
}
return null;
};
five = pick(0, []).map((i) => cards[i]);
}
return { score, cards: five, name };
}
module.exports = { newDeck, score5, compareScore, bestHand, handName };
// a が強ければ正、b が強ければ負、同じ強さなら 0
function compareScore(a, b) {
return b - a;
}
module.exports = { bestHand, compareScore };