From d92953a270c44d8f919fcb7ae6a9c94a390e7918 Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Sat, 17 Oct 2026 17:25:15 +0900 Subject: [PATCH] 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 --- README.md | 18 +- package-lock.json | 52 ++++++ package.json | 4 +- public/assets/create.js | 4 +- server/ai.js | 86 +-------- server/cards.js | 130 +++++--------- server/hand.js | 373 ++++++++++++++++++++++------------------ server/index.js | 2 +- test/engine.test.js | 123 +++++++++---- 9 files changed, 410 insertions(+), 382 deletions(-) diff --git a/README.md b/README.md index 9d57cd2..8305f59 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 友達専用のテキサスホールデム(リングゲーム)サイト。Node.js + Express + Socket.IO。 -「鍵入力 → ロビー → テーブル作成 → プレイ」まで動く。 +「鍵入力 → ロビー → テーブル作成 → プレイ」まで動く。Node.js 24 以上が必要(@pokertools/engine の要件)。 ## ルール・仕様 @@ -14,18 +14,20 @@ | 友人戦 | 実物のカードがなくても遊べる。サイトがカードを配り、勝敗とポットの分配まで自動で判定する | | CPU戦 | 友人戦に CPU プレイヤー(1〜8人)が加わる。空いている席には友達も参加できる | +- ポーカーのルール(ブラインド・手番・最低レイズ額・再レイズ権・返金・サイドポット・ショーダウンの判定と分配)と役の判定は、公開ライブラリ [@pokertools/engine](https://www.npmjs.com/package/@pokertools/engine) / [@pokertools/evaluator](https://www.npmjs.com/package/@pokertools/evaluator)(MIT、バージョン固定)に任せている。 - ノーリミット・テキサスホールデムのリングゲーム(最大9人)。ホストが START を押すと、以降は2人以上いる限り自動でハンドが続く。 - 画面にディーラーボタン(D)・SB・BB と、いまのストリート(PREFLOP / FLOP / TURN / RIVER)が出るので、それに合わせて実物のカードを配る。 -- ブラインドは自動で支払われ、ハンド開始時のレベルで固定。ヘッズアップはボタンが SB。 +- ブラインドは自動で支払われ、ハンド開始時のレベルで固定。BB は SB より大きくする。ヘッズアップはボタンが SB。 - 各自が自分のスマホで Fold / Check / Call / Bet / Raise を行う。最低レイズ額は直前のレイズ幅以上。足りないオールインのレイズは、すでにアクションした人に再レイズ権を与えない。 - ベッティングが終わると次のストリートへ。全員オールインならその時点でショーダウンになる(残りのカードは実物でめくる)。 - **ショーダウン(キャッシュオンリー)**: カードを見せ合い、ホストが勝った人を画面で選んで確定する(ポットの分配はテーブルのホストだけが行える)。サイドポットがあるときはポットごとに選ぶ。複数選ぶと山分け(端数はボタンの左から)。 -- **カードを配るテーブル(友人戦・CPU戦)**: 各自のスマホに自分の手札だけが表示され、場札はストリートごとに画面に配られる。ショーダウンでは残った人の手札を公開して役を判定し、ポット(サイドポット含む)を自動で分配する。引き分けは山分け(端数はボタンの左から)。全員オールインになったら手札を公開し、残りの場札を順にめくる。山札は暗号論的乱数でシャッフルし、サーバの外に出さない(他人の手札は公開されるまで送らない)。 -- **CPU**: 自前実装(`server/ai.js`、外部ライブラリなし)。自分の手札と場札から、残っている相手に勝つ確率をモンテカルロ法で見積もり、ポットオッズ・レイズの回数・相手の人数から行動を決める。CPU ごとに攻撃性・ブラフ頻度がランダムに違う。相手の手札は見ない。1手ごとに 0.7〜1.8 秒考える。チップがなくなると次のハンドで自動でリバイする。人間が1人も参加していないと次のハンドは始まらない。CPU はホストにならない。人間が全員「退席」するとテーブルはすぐに削除される(ブラウザを閉じただけの場合は、ほかのテーブルと同じく誰も接続していない状態が30分続いたら削除)。 +- **カードを配るテーブル(友人戦・CPU戦)**: 各自のスマホに自分の手札だけが表示され、場札はストリートごとに画面に配られる。ショーダウンでは残った人の手札を公開して役を判定し、ポット(サイドポット含む)を自動で分配する。引き分けは山分け(端数はボタンの左から)。全員オールインになったら手札を公開し、残りの場札を順にめくる。山札はエンジンが暗号論的乱数(Node.js の crypto)でシャッフルし、サーバの外に出さない(他人の手札は公開されるまで送らない)。 +- **CPU**: 自前実装(`server/ai.js`。役の強さだけ @pokertools/evaluator を使う)。自分の手札と場札から、残っている相手に勝つ確率をモンテカルロ法で見積もり、ポットオッズ・レイズの回数・相手の人数から行動を決める。CPU ごとに攻撃性・ブラフ頻度がランダムに違う。相手の手札は見ない。1手ごとに 0.7〜1.8 秒考える。チップがなくなると次のハンドで自動でリバイする。人間が1人も参加していないと次のハンドは始まらない。CPU はホストにならない。人間が全員「退席」するとテーブルはすぐに削除される(ブラウザを閉じただけの場合は、ほかのテーブルと同じく誰も接続していない状態が30分続いたら削除)。 - 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。 - 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。 - チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。 -- ハンド中に退席するとフォールド扱い(出したチップはポットに残る)。 +- ハンド中に退席するとフォールド扱い(出したチップはポットに残る。ただし誰もコールしていない分は返る)。オールインしている人の手は退席しても最後まで生きている。 +- チェックできるのに降りるとサイドポットの対象者がいなくなる場合、エンジンが受け付けないので「チェックしてください」と出る(退席のときは自動でチェックになる)。 - ホストの一時停止中は、アクション・勝者の確定・オールイン後の場札・ブラインドタイマー・次のハンドの開始がすべて止まる。 ## ローカルで動かす @@ -79,9 +81,9 @@ http://localhost:4671 を開く。 | `server/index.js` | HTTP ルーティング・セキュリティヘッダ・REST API・Socket.IO | | `server/auth.js` | 参加キー認証(Cookie)・回数制限・Origin チェック | | `server/tables.js` | テーブル状態(席・ボタン・ブラインド・タイマー・ハンドの開始/進行) | -| `server/hand.js` | 1ハンドの進行(ブラインド・ベッティング・サイドポット・勝者への分配、友人戦・CPU戦での配札とショーダウン) | -| `server/cards.js` | 山札のシャッフルと役の判定(カードを配るテーブル) | -| `server/ai.js` | CPU の思考(高速な役判定・勝率の見積もり・行動の決定) | +| `server/hand.js` | 1ハンドの進行。ルールは @pokertools/engine に任せ、テーブルとの橋渡し(席番号・スタック・退席・離席)、オールイン後に場札を順にめくる表示、キャッシュオンリーでホストが選んだ勝者への分配を行う | +| `server/cards.js` | 役の名前と最強の5枚(@pokertools/evaluator) | +| `server/ai.js` | CPU の思考(勝率の見積もり・行動の決定) | | `test/` | チップ処理・役の判定・CPU のテスト(`npm test`) | | `server/config.js` | `.env` の読み込みと各種上限値 | | `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) | diff --git a/package-lock.json b/package-lock.json index 307e4fd..ba20809 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "friends-holdem", "version": "0.1.0", "dependencies": { + "@pokertools/engine": "2.1.0", + "@pokertools/evaluator": "2.1.0", "express": "^4.22.3", "socket.io": "^4.8.4" }, @@ -15,6 +17,47 @@ "node": ">=18" } }, + "node_modules/@pokertools/engine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@pokertools/engine/-/engine-2.1.0.tgz", + "integrity": "sha512-/3kXKpFf6nHygWQuX1AMu8yQa6HW9RhLW7TkOZJnXSESJ/vs00YWs976F2zNzqnlBPK90LvRSw4ZyqwK+9Ajow==", + "license": "MIT", + "dependencies": { + "@pokertools/evaluator": "2.1.0", + "@pokertools/types": "2.1.0" + }, + "engines": { + "node": "^24.15.0 || >=26.0.0", + "npm": ">=12.2.0" + }, + "peerDependencies": { + "@pokertools/evaluator": "2.1.0", + "@pokertools/types": "2.1.0" + } + }, + "node_modules/@pokertools/evaluator": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@pokertools/evaluator/-/evaluator-2.1.0.tgz", + "integrity": "sha512-9ZmmwrA9g2IvKST2y0HrdiwTGIM6uoMoom9fXq0ZfQHGcd24X5ydQJi3S5ciR/LFjSN4T2EakwXqVED6tuWD+w==", + "license": "MIT", + "engines": { + "node": "^24.15.0 || >=26.0.0", + "npm": ">=12.2.0" + } + }, + "node_modules/@pokertools/types": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@pokertools/types/-/types-2.1.0.tgz", + "integrity": "sha512-FBKLd3cczFfHQAH4DY0n+VEcdccEW7C5wa4PaAA/jaBvmYo0OU7PBHduPEhcO3ZpWBAr3KjW1ESzaajhlvTzCw==", + "license": "MIT", + "dependencies": { + "zod": "^4.6.5" + }, + "engines": { + "node": "^24.15.0 || >=26.0.0", + "npm": ">=12.2.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -1078,6 +1121,15 @@ "optional": true } } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index ceafadf..5f20423 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "test": "node --test" }, "engines": { - "node": ">=18" + "node": ">=24" }, "dependencies": { + "@pokertools/engine": "2.1.0", + "@pokertools/evaluator": "2.1.0", "express": "^4.22.3", "socket.io": "^4.8.4" } diff --git a/public/assets/create.js b/public/assets/create.js index 69bc6da..a9c6632 100644 --- a/public/assets/create.js +++ b/public/assets/create.js @@ -60,7 +60,7 @@ const bb = int(bbInput); const on = timerInput.checked; intervalInput.disabled = !on; - scheduleEl.hidden = !on || !(sb > 0 && bb >= sb); + scheduleEl.hidden = !on || !(sb > 0 && bb > sb); if (scheduleEl.hidden) return; const mins = int(intervalInput) > 0 ? int(intervalInput) : 0; scheduleEl.innerHTML = Blinds.schedule(sb, bb, 8) @@ -119,7 +119,7 @@ if (!name) return [nameInput, 'テーブル名を入力してください']; if (!(stack >= 10)) return [stackInput, '開始スタックは10以上の整数にしてください']; if (!(sb >= 1)) return [sbInput, 'SBは1以上の整数にしてください']; - if (!(bb >= sb)) return [bbInput, 'BBはSB以上にしてください']; + if (!(bb > sb)) return [bbInput, 'BBはSBより大きくしてください']; if (bb > stack) return [bbInput, 'BBが開始スタックより大きいです']; if (timerInput.checked && !(interval >= 1 && interval <= 240)) return [intervalInput, 'インターバルは1〜240分にしてください']; return null; diff --git a/server/ai.js b/server/ai.js index 82ed20e..cf31e13 100644 --- a/server/ai.js +++ b/server/ai.js @@ -5,85 +5,13 @@ // ポットオッズ・ポジション・その CPU の性格(攻撃性・ブラフ頻度)から行動を決める。 // 相手の手札は見ない(自分の手札と場札、公開されているベット状況だけを使う)。 -const RANKS = '23456789TJQKA'; -const SUITS = 'shdc'; - -// "As" → 0〜51(rank * 4 + suit) -const toInt = (c) => RANKS.indexOf(c[0]) * 4 + SUITS.indexOf(c[1]); - -// 13ビットのランクの集合から最も高いストレートの上端(0〜12)。無ければ -1 -function straightHigh(mask) { - for (let h = 12; h >= 4; h--) { - const need = 0b11111 << (h - 4); - if ((mask & need) === need) return h; - } - // A-2-3-4-5 - if ((mask & 0b1000000001111) === 0b1000000001111) return 3; - return -1; -} - -// [役, 比較用の数字...] を1つの整数にする(大きいほど強い) -function encode(category, kickers) { - let v = category; - for (let i = 0; i < 5; i++) v = v * 16 + (kickers[i] === undefined ? 0 : kickers[i] + 1); - return v; -} - -// 上位から n 個のランク(mask のビット) -function topRanks(mask, n, exclude = []) { - const out = []; - for (let r = 12; r >= 0 && out.length < n; r--) if (mask & (1 << r) && !exclude.includes(r)) out.push(r); - return out; -} - -// 5〜7枚(整数)の最強の役の強さ。cards.js の bestHand と同じ順序になる(テストで確認) -function evaluate(cards) { - const counts = new Array(13).fill(0); - const suitCount = [0, 0, 0, 0]; - const suitMask = [0, 0, 0, 0]; - let mask = 0; - for (const c of cards) { - const r = c >> 2; - const s = c & 3; - counts[r]++; - suitCount[s]++; - suitMask[s] |= 1 << r; - mask |= 1 << r; - } - - let flushMask = 0; - for (let s = 0; s < 4; s++) if (suitCount[s] >= 5) flushMask = suitMask[s]; - if (flushMask) { - const sf = straightHigh(flushMask); - if (sf >= 0) return encode(8, [sf]); - } - - let quad = -1; - const trips = []; - const pairs = []; - for (let r = 12; r >= 0; r--) { - if (counts[r] === 4) quad = r; - else if (counts[r] === 3) trips.push(r); - else if (counts[r] === 2) pairs.push(r); - } - if (quad >= 0) return encode(7, [quad, topRanks(mask, 1, [quad])[0]]); - if (trips.length && (trips.length > 1 || pairs.length)) { - const pair = Math.max(trips[1] === undefined ? -1 : trips[1], pairs[0] === undefined ? -1 : pairs[0]); - return encode(6, [trips[0], pair]); - } - if (flushMask) return encode(5, topRanks(flushMask, 5)); - const st = straightHigh(mask); - if (st >= 0) return encode(4, [st]); - if (trips.length) return encode(3, [trips[0], ...topRanks(mask, 2, [trips[0]])]); - if (pairs.length >= 2) return encode(2, [pairs[0], pairs[1], topRanks(mask, 1, [pairs[0], pairs[1]])[0]]); - if (pairs.length) return encode(1, [pairs[0], ...topRanks(mask, 3, [pairs[0]])]); - return encode(0, topRanks(mask, 5)); -} +const { evaluate, getCardCode } = require('@pokertools/evaluator'); // hole / board は "As" 形式。opponents 人のランダムな手札を相手にした勝率(引き分けは等分) +// 役の強さは @pokertools/evaluator で求める(スコアは小さいほど強い。カードは 0〜51 の整数) function equity(hole, board, opponents, iterations, rng = Math.random) { - const mine = hole.map(toInt); - const known = board.map(toInt); + const mine = hole.map(getCardCode); + const known = board.map(getCardCode); const used = new Set([...mine, ...known]); const deck = []; for (let c = 0; c < 52; c++) if (!used.has(c)) deck.push(c); @@ -101,18 +29,16 @@ function equity(hole, board, opponents, iterations, rng = Math.random) { } const fullBoard = known.concat(deck.slice(0, needBoard)); const me = evaluate(mine.concat(fullBoard)); - let best = 0; let ties = 0; let lost = false; for (let k = 0; k < opponents; k++) { const o = needBoard + k * 2; const v = evaluate([deck[o], deck[o + 1]].concat(fullBoard)); - if (v > me) { + if (v < me) { lost = true; break; } if (v === me) ties++; - if (v > best) best = v; } if (!lost) score += 1 / (ties + 1); } @@ -210,4 +136,4 @@ function decide(hand, seat, { style = DEFAULT_STYLE, rng = Math.random, iteratio return { type: 'fold' }; } -module.exports = { evaluate, equity, decide, randomStyle, toInt, straightHigh }; +module.exports = { equity, decide, randomStyle }; diff --git a/server/cards.js b/server/cards.js index 09c1d35..38f63ae 100644 --- a/server/cards.js +++ b/server/cards.js @@ -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 }; diff --git a/server/hand.js b/server/hand.js index 79a3432..d55170c 100644 --- a/server/hand.js +++ b/server/hand.js @@ -1,13 +1,19 @@ 'use strict'; -const { newDeck, bestHand, compareScore } = require('./cards'); +const path = require('path'); +const { PokerEngine, ActionType, PlayerStatus } = require('@pokertools/engine'); +// サイドポットの計算はエンジン内部の関数を使う(パッケージの公開 API に無いので、ファイルを直接読む。 +// package.json でバージョンを固定している) +const { calculateSidePots } = require(path.join(path.dirname(require.resolve('@pokertools/engine')), 'rules/side-pots')); +const { bestHand, compareScore } = require('./cards'); -// 1ハンド分の進行(ブラインド・ベッティング・ポット分配)。 +// 1ハンド分の進行。ルール(ブラインド・手番・最低レイズ額・再レイズ権・返金・サイドポット・ +// ショーダウンの判定と分配)は @pokertools/engine に任せ、ここではテーブルとのやり取りと表示用の状態を持つ。 // cards = false: カードは実物で配るので扱わない。ショーダウンでは勝者をホストが選ぶ。 -// cards = true : サーバが配り、ショーダウンで役を判定して自動で分配する。 -// seats は Table.seats への参照で、スタックはここで直接増減する。 +// cards = true : エンジンが配り、ショーダウンで役を判定して自動で分配する。 +// seats は Table.seats への参照で、スタックはエンジンの状態に合わせて書き換える。 // 参加者の席はハンド終了まで空席にならない(退席者は Table 側で予約したまま)。 -const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER']; +const RUNOUT_STREET = { 3: 'FLOP', 4: 'TURN', 5: 'RIVER' }; class Hand { constructor({ number, seats, order, button, sb, bb, cards = false }) { @@ -19,16 +25,21 @@ class Hand { this.bb = bb; this.cards = cards; - // 山札はサーバの外に出さない - this.deck = cards ? newDeck() : null; - this.board = []; + // エンジンには参加者だけを、ボタンを席0として時計回りに座らせる(空席の無い n 席のテーブル) + const n = order.length; + this.toEngine = new Map(order.map((s, k) => [s, (k + 1) % n])); + this.fromEngine = []; + for (const [s, e] of this.toEngine) this.fromEngine[e] = s; + this.engine = new PokerEngine({ smallBlind: sb, bigBlind: bb, maxPlayers: n }); + for (const s of order) this.engine.sit(this.toEngine.get(s), String(s), String(s), seats[s].stack); + + this.board = []; // 公開済みの場札(オールイン後はエンジンが配り終えていても順にめくる) this.revealed = false; // ショーダウン・オールイン時に残った人の手札を公開 this.runout = false; // オールインでこれ以上ベットが無く、残りの場札を順にめくっている this.street = 'PREFLOP'; // PREFLOP / FLOP / TURN / RIVER / SHOWDOWN this.pot = 0; // 前のストリートまでに集めた額 this.currentBet = 0; - this.minRaise = bb; this.raises = 0; // このストリートのベット・レイズの回数(CPU の判断用) this.actions = 0; // アクションの通し番号(CPU の手番の管理用) this.aggressor = null; // このストリートで最後にベット・レイズした席(CPU の判断用) @@ -37,6 +48,10 @@ class Hand { this.finished = false; this.showdown = false; this.results = null; + this.settled = false; // エンジンの中ではハンドが決着した(結果の公開待ちを含む) + this.pots = null; // ショーダウンのポット(同じ参加者のポットはまとめる) + this.engineWon = null; // エンジンが分配した額(席番号 → 額) + this.taken = new Map(); // ハンド中に退席した人が持ち出したチップ(エンジンから外れた分) this.players = new Map(); for (const i of order) { @@ -49,21 +64,21 @@ class Hand { committed: 0, // このハンドで出した合計 folded: false, allIn: false, - acted: false, last: null, // 直前のアクション(表示用) won: 0, }); } - if (cards) for (let r = 0; r < 2; r++) for (const i of order) this.players.get(i).cards.push(this.deck.pop()); - // ヘッズアップはボタンが SB - const headsUp = order.length === 2; + // ヘッズアップはボタンが SB(エンジンも同じ配置にする) + const headsUp = n === 2; this.sbSeat = headsUp ? button : order[0]; this.bbSeat = headsUp ? order[0] : order[1]; - this.post(this.sbSeat, sb); - this.post(this.bbSeat, bb); - this.currentBet = bb; // BB がショートでもコール額は BB - this.setNextActor(this.bbSeat); + this.engine.deal(); + this.sync(); + } + + get state() { + return this.engine.state; } get list() { @@ -84,65 +99,130 @@ class Hand { return this.pot + this.list.reduce((sum, p) => sum + p.bet, 0); } - put(p, amount) { - const s = this.seats[p.seat]; - s.stack -= amount; - p.bet += amount; - p.committed += amount; - if (s.stack === 0) p.allIn = true; - } + // エンジンの状態を写す。ハンドが決着していたら結果を作る + sync() { + if (this.settled) return; + const st = this.state; + for (const p of this.list) { + const e = this.toEngine.get(p.seat); + const ep = st.players[e]; + p.bet = st.currentBets.get(e) || 0; + if (!ep) { + // 退席でエンジンから外れた(フォールド済み) + p.folded = true; + this.seats[p.seat].stack = this.taken.get(p.seat) || 0; + continue; + } + if (this.cards) p.cards = ep.hand || []; + p.committed = ep.totalInvestedThisHand; + p.folded = ep.status === PlayerStatus.FOLDED; + p.allIn = ep.status === PlayerStatus.ALL_IN; + this.seats[p.seat].stack = (this.taken.get(p.seat) || 0) + ep.stack; + } - post(seat, amount) { - const p = this.players.get(seat); - this.put(p, Math.min(amount, this.seats[seat].stack)); - } + const prevStreet = this.street; + const ended = st.winners !== null; + if (ended || st.street !== prevStreet) { + // ストリートが終わったら直前のアクションの表示を消す(フォールドとオールインは残す) + for (const p of this.list) { + if (p.last && p.last.type !== 'FOLD' && p.last.type !== 'ALLIN') p.last = null; + } + this.raises = 0; + this.prevAggressor = this.aggressor; + this.aggressor = null; + } + // 誰もコールしなかった分が返ってオールインでなくなった + for (const p of this.list) if (!p.allIn && p.last && p.last.type === 'ALLIN') p.last = null; - needsToAct(p) { - return !p.folded && !p.allIn && (!p.acted || p.bet < this.currentBet); - } + if (ended) return this.settle(); - bettingClosed() { - const live = this.live(); - if (live.length <= 1) return true; - const actors = live.filter((p) => !p.allIn); - if (actors.length === 0) return true; - // 動ける人が1人だけで、誰かのベットに応じる必要もない - const maxBet = Math.max(...live.map((p) => p.bet)); - if (actors.length === 1 && actors[0].bet >= maxBet) return true; - return actors.every((p) => !this.needsToAct(p)); - } - - setNextActor(fromSeat) { - this.toAct = null; - if (this.bettingClosed()) return this.endStreet(); - const n = this.order.length; - const start = this.order.indexOf(fromSeat); - for (let k = 1; k <= n; k++) { - const p = this.players.get(this.order[(start + k) % n]); - if (!this.needsToAct(p)) continue; - this.toAct = p.seat; - // 離席中・退席済みの人は自動でチェック/フォールド - const s = this.seats[p.seat]; + this.street = st.street; + if (this.cards) this.board = st.board.slice(); + this.pot = st.pots.reduce((sum, pot) => sum + pot.amount, 0); + // コール額(プリフロップは BB がショートでも BB) + this.currentBet = Math.max(0, ...st.currentBets.values(), st.street === 'PREFLOP' ? st.bigBlind : 0); + this.toAct = st.actionTo == null ? null : this.fromEngine[st.actionTo]; + // 離席中・退席済みの人は自動でチェック/フォールド + if (this.toAct != null) { + const s = this.seats[this.toAct]; if (s.sittingOut || s.left) this.autoAct(); + } + } + + // エンジンの中でハンドが決着した + settle() { + const st = this.state; + this.settled = true; + this.toAct = null; + this.engineWon = new Map(); + for (const w of st.winners) this.engineWon.set(this.fromEngine[w.seat], (this.engineWon.get(this.fromEngine[w.seat]) || 0) + w.amount); + // 結果を公開するまでは分配前のスタックを見せる + for (const [s, amount] of this.engineWon) this.seats[s].stack -= amount; + + if (this.live().length <= 1) { + const total = [...this.engineWon.values()].reduce((a, b) => a + b, 0); + this.pot = total; + this.applyEngineWon(); + return this.end(false, [{ amount: total, winners: [...this.engineWon.keys()] }]); + } + + this.pots = this.toPots(calculateSidePots(st)); + this.pot = this.pots.reduce((sum, pot) => sum + pot.amount, 0); + if (!this.cards) { + this.street = 'SHOWDOWN'; return; } - this.endStreet(); + // 手札を公開する。場札が残っていたら Table のタイマーで順にめくる(runoutStep) + this.revealed = true; + if (this.board.length < 5) this.runout = true; + else this.finishShowdown(); + } + + // エンジンのサイドポットを席番号に直す。同じ参加者のポットはまとめる + toPots(enginePots) { + const pots = []; + for (const ep of enginePots) { + const eligible = this.order.filter((s) => ep.eligibleSeats.includes(this.toEngine.get(s))); + const last = pots[pots.length - 1]; + if (last && last.eligible.join() === eligible.join()) last.amount += ep.amount; + else pots.push({ amount: ep.amount, eligible }); + } + return pots; + } + + applyEngineWon() { + for (const [s, amount] of this.engineWon) { + this.seats[s].stack += amount; + this.players.get(s).won += amount; + } + } + + engineAction(seat, type, amount) { + const playerId = String(seat); + if (type === 'fold') return { type: ActionType.FOLD, playerId }; + if (type === 'check') return { type: ActionType.CHECK, playerId }; + if (type === 'call') return { type: ActionType.CALL, playerId }; + // amount はこのストリートでの合計額("raise to") + return { type: this.currentBet === 0 ? ActionType.BET : ActionType.RAISE, playerId, amount }; } // 手番の人の選択肢(手番でなければ null) options(seat) { - if (this.finished || this.toAct !== seat) return null; + if (this.finished || this.settled || this.toAct !== seat) return null; const p = this.players.get(seat); const stack = this.seats[seat].stack; const toCall = Math.min(this.currentBet - p.bet, stack); const maxTo = p.bet + stack; // 他の全員がオールインならレイズしても意味がない const opponentCanAct = this.live().some((q) => q !== p && !q.allIn); + // レイズできるか(足りないオールインのあとの再レイズ権など)はエンジンに聞く + const canRaise = + maxTo > this.currentBet && opponentCanAct && this.engine.validate(this.engineAction(seat, 'raise', maxTo)).valid; return { toCall, canCheck: p.bet >= this.currentBet, - canRaise: !p.acted && stack > toCall && opponentCanAct, - minRaiseTo: Math.min(this.currentBet + this.minRaise, maxTo), + canRaise, + minRaiseTo: Math.min(this.state.minRaise, maxTo), maxRaiseTo: maxTo, isBet: this.currentBet === 0, }; @@ -154,40 +234,39 @@ class Hand { if (!o) return 'あなたの番ではありません'; const p = this.players.get(seat); + let last; if (type === 'fold') { - p.folded = true; - p.last = { type: 'FOLD' }; + last = { type: 'FOLD' }; } else if (type === 'check') { if (!o.canCheck) return 'チェックできません'; - p.last = { type: 'CHECK' }; + last = { type: 'CHECK' }; } else if (type === 'call') { if (o.canCheck || o.toCall <= 0) return 'コールする額がありません'; - this.put(p, o.toCall); - p.last = { type: 'CALL', amount: p.bet }; + last = { type: 'CALL', amount: p.bet + o.toCall }; } else if (type === 'raise') { if (!o.canRaise) return 'レイズできません'; if (!Number.isInteger(amount) || amount < o.minRaiseTo || amount > o.maxRaiseTo) { return `${o.minRaiseTo}〜${o.maxRaiseTo} の額を指定してください`; } - const raiseSize = amount - this.currentBet; - this.put(p, amount - p.bet); - p.last = { type: o.isBet ? 'BET' : 'RAISE', amount }; - this.aggressor = seat; - if (raiseSize >= this.minRaise) { - // 正規のレイズ: 他の人はもう一度アクションできる - this.minRaise = raiseSize; - this.raises++; - for (const q of this.list) if (q !== p) q.acted = false; - } - // 足りないオールインのレイズはアクション済みの人に再レイズ権を与えない - this.currentBet = amount; + last = { type: o.isBet ? 'BET' : 'RAISE', amount }; } else { return '不正なアクションです'; } - if (p.allIn) p.last = { type: 'ALLIN', amount: p.bet }; - p.acted = true; + const allIn = type !== 'fold' && type !== 'check' && last.amount === o.maxRaiseTo; + const fullRaise = type === 'raise' && amount >= this.state.minRaise; + try { + this.engine.act(this.engineAction(seat, type, amount)); + } catch { + // エンジンが受け付けない(チェックできるのに降りてサイドポットの対象者がいなくなる場合など) + return type === 'fold' && o.canCheck ? 'いまはフォールドできません。チェックしてください' : 'そのアクションはできません'; + } + p.last = allIn ? { type: 'ALLIN', amount: last.amount } : last; this.actions++; - this.setNextActor(seat); + if (type === 'raise') { + this.aggressor = seat; + if (fullRaise) this.raises++; + } + this.sync(); return null; } @@ -196,105 +275,53 @@ class Hand { if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0); } - // 退席などで手番に関係なくフォールドさせる + // 退席などで手番に関係なくフォールドさせる。オールインの人の手は最後まで生きている forceFold(seat) { const p = this.players.get(seat); if (!p || p.folded || this.finished) return; - if (this.toAct === seat) return this.act(seat, 'fold', 0); - p.folded = true; + if (this.toAct === seat) { + if (this.act(seat, 'fold', 0)) this.autoAct(); + return; + } + if (this.settled) { + // キャッシュオンリーの勝者選び待ち: 選べる人から外す + if (!this.awaitingWinners) return; + p.folded = true; + p.last = { type: 'FOLD' }; + if (this.live().length === 1) return this.finishFoldWin(); + // 選べる人がいなくなったポットは前のポットにまとめる(メインポットには残った全員が入っている) + const pots = []; + for (const pot of this.pots) { + const eligible = pot.eligible.filter((s) => s !== seat); + const last = pots[pots.length - 1]; + if (last && (eligible.length === 0 || last.eligible.join() === eligible.join())) last.amount += pot.amount; + else pots.push({ amount: pot.amount, eligible }); + } + this.pots = pots; + return; + } + // エンジンは手番以外のフォールドを「席を立つ」として扱う。持ち出したチップは自分で覚えておく。 + // オールインの人などエンジンが受け付けないときは、手番が来たら自動でチェック/フォールドする + const stand = { type: ActionType.STAND, playerId: String(seat) }; + if (!this.engine.validate(stand).valid) return; + const before = this.state.initialChips; + this.engine.stand(String(seat)); + this.taken.set(seat, (this.taken.get(seat) || 0) + before - this.state.initialChips); p.last = { type: 'FOLD' }; - if (this.live().length === 1) { - if (this.awaitingWinners) this.finishFoldWin(); - else this.endStreet(); - } else if (this.toAct != null && this.bettingClosed()) { - this.endStreet(); - } - } - - // 誰もコールしなかった分を返す - returnUncalled() { - const bets = this.list.map((p) => p.bet).sort((a, b) => b - a); - const excess = bets[0] - (bets[1] || 0); - if (excess <= 0) return; - const p = this.list.find((q) => q.bet === bets[0]); - if (p.folded) return; // 退席でフォールドした人のベットはそのままポットに残る - p.bet -= excess; - p.committed -= excess; - this.seats[p.seat].stack += excess; - p.allIn = this.seats[p.seat].stack === 0; - if (!p.allIn && p.last && p.last.type === 'ALLIN') p.last = null; - } - - endStreet() { - this.toAct = null; - this.returnUncalled(); - for (const p of this.list) { - this.pot += p.bet; - p.bet = 0; - p.acted = false; - if (p.last && p.last.type !== 'FOLD' && p.last.type !== 'ALLIN') p.last = null; - } - if (this.live().length === 1) return this.finishFoldWin(); - this.currentBet = 0; - this.minRaise = this.bb; - this.raises = 0; - this.prevAggressor = this.aggressor; - this.aggressor = null; - - const noMoreBetting = this.live().filter((p) => !p.allIn).length < 2; - if (!this.cards) { - // リバーが終わった、または全員オールインでこれ以上ベットが無い → ショーダウン - if (this.street === 'RIVER' || noMoreBetting) { - this.street = 'SHOWDOWN'; - return; - } - this.street = STREETS[STREETS.indexOf(this.street) + 1]; - } else { - if (this.street === 'RIVER') return this.finishShowdown(); - if (noMoreBetting) { - // 手札を公開して、残りの場札は Table のタイマーで順にめくる(runoutStep) - this.revealed = true; - this.runout = true; - return; - } - this.dealStreet(); - } - this.setNextActor(this.button); - } - - dealStreet() { - this.deck.pop(); // バーン - if (this.street === 'PREFLOP') this.board.push(this.deck.pop(), this.deck.pop(), this.deck.pop()); - else this.board.push(this.deck.pop()); - this.street = STREETS[STREETS.indexOf(this.street) + 1]; + this.sync(); } // オールイン後に場札を1ストリート分めくる。リバーまでめくり終えていたらショーダウン runoutStep() { if (!this.runout || this.finished) return; - if (this.street === 'RIVER') this.finishShowdown(); - else this.dealStreet(); - } - - // メインポット・サイドポット(同じ参加者のポットはまとめる) - buildPots() { - const all = this.list; - const levels = [...new Set(all.map((p) => p.committed).filter((c) => c > 0))].sort((a, b) => a - b); - const pots = []; - let prev = 0; - for (const level of levels) { - let amount = 0; - for (const p of all) amount += Math.max(0, Math.min(p.committed, level) - prev); - prev = level; - let eligible = all.filter((p) => !p.folded && p.committed >= level).map((p) => p.seat); - const last = pots[pots.length - 1]; - if (eligible.length === 0) eligible = last ? last.eligible : this.live().map((p) => p.seat); - if (last && last.eligible.join() === eligible.join()) last.amount += amount; - else pots.push({ amount, eligible }); - } - return pots; + const n = this.board.length; + if (n === 5) return this.finishShowdown(); + const next = n === 0 ? 3 : n + 1; + this.board = this.state.board.slice(0, next); + this.street = RUNOUT_STREET[next]; } + // キャッシュオンリーのショーダウン中に1人を残して退席した: 残った人がすべて獲得 finishFoldWin() { const winner = this.live()[0]; winner.won = this.pot; @@ -305,7 +332,7 @@ class Hand { // ショーダウンの勝者を決める。winners[i] = i 番目のポットの勝者の席番号(引き分けは複数) award(winners) { if (!this.awaitingWinners) return 'ショーダウンではありません'; - const pots = this.buildPots(); + const pots = this.pots; if (!Array.isArray(winners) || winners.length !== pots.length) return 'すべてのポットの勝者を選んでください'; for (let i = 0; i < pots.length; i++) { const w = winners[i]; @@ -318,26 +345,28 @@ class Hand { return null; } - // カードありのショーダウン: ポットごとに役を比べて分配する + // カードありのショーダウン: エンジンが分配した結果を公開する。表示用にポットごとの勝者と役を添える finishShowdown() { - this.revealed = true; + this.board = this.state.board.slice(); for (const p of this.live()) p.best = bestHand(p.cards.concat(this.board)); - const results = this.buildPots().map((pot) => { + const results = this.pots.map((pot) => { const contenders = pot.eligible.map((s) => this.players.get(s)); let top = null; - for (const c of contenders) if (!top || compareScore(c.best.score, top) > 0) top = c.best.score; + for (const c of contenders) if (top == null || compareScore(c.best.score, top) > 0) top = c.best.score; const best = contenders.filter((c) => compareScore(c.best.score, top) === 0); return { amount: pot.amount, - winners: this.payout(pot.amount, best.map((c) => c.seat)), + winners: best.map((c) => c.seat), handName: best[0].best.name, bestCards: best[0].best.cards, }; }); + this.applyEngineWon(); this.end(true, results); } // amount を seats で山分けする。割り切れない端数はボタンの左から順に。分配した順の席番号を返す + // (キャッシュオンリーでホストが勝者を選んだときだけ使う) payout(amount, seats) { const ws = this.order.filter((s) => seats.includes(s)); const share = Math.floor(amount / ws.length); @@ -388,7 +417,7 @@ class Hand { toAct: this.toAct, finished: this.finished, showdown: this.showdown, - pots: this.awaitingWinners ? this.buildPots() : null, + pots: this.awaitingWinners ? this.pots : null, results: this.results, players, }; diff --git a/server/index.js b/server/index.js index f9236e2..214a02b 100644 --- a/server/index.js +++ b/server/index.js @@ -123,7 +123,7 @@ app.post('/api/tables', (req, res) => { if (tables.nameTaken(name)) return res.status(409).json({ error: '同じ名前のテーブルがすでにあります' }); if (!intIn(startingStack, 10, 100000000)) return res.status(400).json({ error: '開始スタックが不正です' }); if (!intIn(smallBlind, 1, startingStack)) return res.status(400).json({ error: 'SBが不正です' }); - if (!intIn(bigBlind, smallBlind, startingStack)) return res.status(400).json({ error: 'BBはSB以上・開始スタック以下にしてください' }); + if (!intIn(bigBlind, smallBlind + 1, startingStack)) return res.status(400).json({ error: 'BBはSBより大きく・開始スタック以下にしてください' }); if (blindTimerEnabled && !intIn(blindIntervalMin, 1, 240)) return res.status(400).json({ error: 'インターバルは1〜240分で入力してください' }); if (!MODES.includes(mode)) return res.status(400).json({ error: 'テーブルの種類が不正です' }); if (mode === 'cpu' && !intIn(cpuCount, 1, config.MAX_SEATS - 1)) { diff --git a/test/engine.test.js b/test/engine.test.js index 9e85060..fc949f4 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -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}`); } });