diff --git a/server/cards.js b/server/cards.js
new file mode 100644
index 0000000..09c1d35
--- /dev/null
+++ b/server/cards.js
@@ -0,0 +1,96 @@
+'use strict';
+
+const crypto = require('crypto');
+
+// カードは "As"(スペードのA), "Td"(ダイヤの10)のような2文字
+const RANKS = '23456789TJQKA';
+const SUITS = 'shdc';
+
+// 暗号論的乱数で 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 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枚を選ぶ
+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) };
+}
+
+module.exports = { newDeck, score5, compareScore, bestHand, handName };
diff --git a/server/config.js b/server/config.js
index 40da189..d2ae451 100644
--- a/server/config.js
+++ b/server/config.js
@@ -61,8 +61,11 @@ module.exports = {
// 誰も接続していないテーブルを削除するまでの時間
EMPTY_TABLE_TTL_MS: 30 * 60 * 1000,
- // ハンド終了後、次のハンドまで結果を表示する時間
+ // オールインで残りの場札をめくる間隔(カード機能あり)
+ RUNOUT_STEP_MS: 1500,
+ // ハンド終了後、次のハンドまで結果を表示する時間(カードありは手札を見比べられるよう長め)
SHOWDOWN_MS: 4000,
+ CARD_SHOWDOWN_MS: 6000,
FOLD_WIN_MS: 2500,
// 参加者が2人揃ってから次のハンドを始めるまで
NEXT_HAND_DELAY_MS: 2000,
diff --git a/server/hand.js b/server/hand.js
index be8ba6d..1be408a 100644
--- a/server/hand.js
+++ b/server/hand.js
@@ -1,19 +1,29 @@
'use strict';
-// 1ハンド分のチップの動き(ブラインド・ベッティング・ポット分配)。
-// カードは実物で配るので扱わない。ショーダウンでは勝者をプレイヤーが選ぶ。
+const { newDeck, bestHand, compareScore } = require('./cards');
+
+// 1ハンド分の進行(ブラインド・ベッティング・ポット分配)。
+// cards = false: カードは実物で配るので扱わない。ショーダウンでは勝者をホストが選ぶ。
+// cards = true : サーバが配り、ショーダウンで役を判定して自動で分配する。
// seats は Table.seats への参照で、スタックはここで直接増減する。
// 参加者の席はハンド終了まで空席にならない(退席者は Table 側で予約したまま)。
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
class Hand {
- constructor({ number, seats, order, button, sb, bb }) {
+ constructor({ number, seats, order, button, sb, bb, cards = false }) {
this.number = number;
this.seats = seats;
this.order = order; // 参加者の席番号。ボタンの次から時計回り
this.button = button;
this.sb = sb;
this.bb = bb;
+ this.cards = cards;
+
+ // 山札はサーバの外に出さない
+ this.deck = cards ? newDeck() : null;
+ this.board = [];
+ this.revealed = false; // ショーダウン・オールイン時に残った人の手札を公開
+ this.runout = false; // オールインでこれ以上ベットが無く、残りの場札を順にめくっている
this.street = 'PREFLOP'; // PREFLOP / FLOP / TURN / RIVER / SHOWDOWN
this.pot = 0; // 前のストリートまでに集めた額
@@ -29,6 +39,8 @@ class Hand {
this.players.set(i, {
seat: i,
token: seats[i].token,
+ cards: [],
+ best: null, // ショーダウンでの最強の5枚
bet: 0, // このストリートで出した額
committed: 0, // このハンドで出した合計
folded: false,
@@ -38,6 +50,7 @@ class Hand {
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;
@@ -53,8 +66,9 @@ class Hand {
return this.order.map((i) => this.players.get(i));
}
+ // 勝者の選択待ち(カードありのときは自動で判定するので来ない)
get awaitingWinners() {
- return this.street === 'SHOWDOWN' && !this.finished;
+ return !this.cards && this.street === 'SHOWDOWN' && !this.finished;
}
live() {
@@ -212,15 +226,41 @@ class Hand {
this.currentBet = 0;
this.minRaise = this.bb;
- // リバーが終わった、または全員オールインでこれ以上ベットが無い → ショーダウン
- if (this.street === 'RIVER' || this.live().filter((p) => !p.allIn).length < 2) {
- this.street = 'SHOWDOWN';
- return;
+ 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.street = STREETS[STREETS.indexOf(this.street) + 1];
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];
+ }
+
+ // オールイン後に場札を1ストリート分めくる。リバーまでめくり終えていたらショーダウン
+ runoutStep() {
+ if (!this.runout || this.finished) return;
+ if (this.street === 'RIVER') this.finishShowdown();
+ else this.dealStreet();
+ }
+
// メインポット・サイドポット(同じ参加者のポットはまとめる)
buildPots() {
const all = this.list;
@@ -258,38 +298,71 @@ class Hand {
if (new Set(w).size !== w.length || !w.every((s) => pots[i].eligible.includes(s))) return '勝者の指定が不正です';
}
- const results = [];
- pots.forEach((pot, i) => {
- // 割り切れない端数はボタンの左から順に
- const ws = this.order.filter((s) => winners[i].includes(s));
- const share = Math.floor(pot.amount / ws.length);
- let rest = pot.amount - share * ws.length;
- for (const s of ws) {
- const amount = share + (rest > 0 ? 1 : 0);
- if (rest > 0) rest--;
- this.players.get(s).won += amount;
- this.seats[s].stack += amount;
- }
- results.push({ amount: pot.amount, winners: ws });
- });
+ const results = pots.map((pot, i) => ({ amount: pot.amount, winners: this.payout(pot.amount, winners[i]) }));
this.end(true, results);
return null;
}
+ // カードありのショーダウン: ポットごとに役を比べて分配する
+ finishShowdown() {
+ this.revealed = true;
+ for (const p of this.live()) p.best = bestHand(p.cards.concat(this.board));
+ const results = this.buildPots().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;
+ 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)),
+ handName: best[0].best.name,
+ bestCards: best[0].best.cards,
+ };
+ });
+ 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);
+ let rest = amount - share * ws.length;
+ for (const s of ws) {
+ const a = share + (rest > 0 ? 1 : 0);
+ if (rest > 0) rest--;
+ this.players.get(s).won += a;
+ this.seats[s].stack += a;
+ }
+ return ws;
+ }
+
end(showdown, results) {
this.finished = true;
+ this.runout = false;
this.showdown = showdown;
this.toAct = null;
this.results = results;
}
- view() {
+ // 閲覧者に見せてよい情報だけを返す(他人の手札は公開時のみ)
+ view(viewerSeat, viewerToken) {
const players = {};
for (const p of this.list) {
- players[p.seat] = { bet: p.bet, folded: p.folded, allIn: p.allIn, last: p.last, won: p.won };
+ const v = { bet: p.bet, folded: p.folded, allIn: p.allIn, last: p.last, won: p.won };
+ if (this.cards) {
+ const mine = p.seat === viewerSeat && p.token === viewerToken;
+ const shown = mine || (this.revealed && !p.folded);
+ v.cards = shown ? p.cards : null;
+ v.handName =
+ shown && !p.folded && this.board.length >= 3 ? (p.best || bestHand(p.cards.concat(this.board))).name : null;
+ }
+ players[p.seat] = v;
}
return {
number: this.number,
+ cards: this.cards,
+ board: this.board,
+ runout: this.runout,
street: this.street,
pot: this.pot,
potTotal: this.pot + this.list.reduce((sum, p) => sum + p.bet, 0),
diff --git a/server/index.js b/server/index.js
index 05d244c..660a98f 100644
--- a/server/index.js
+++ b/server/index.js
@@ -114,6 +114,7 @@ app.post('/api/tables', (req, res) => {
const bigBlind = b.bigBlind;
const blindTimerEnabled = b.blindTimerEnabled === true;
const blindIntervalMin = b.blindIntervalMin;
+ const cardsEnabled = b.cardsEnabled === true;
const hostToken = cleanToken(b.token);
if (tables.tables.size >= config.MAX_TABLES) return res.status(429).json({ error: 'テーブル数が上限に達しています' });
@@ -132,6 +133,7 @@ app.post('/api/tables', (req, res) => {
bigBlind,
blindTimerEnabled,
blindIntervalMin: blindTimerEnabled ? blindIntervalMin : 10,
+ cardsEnabled,
hostToken,
});
broadcastLobby();
@@ -276,7 +278,7 @@ io.on('connection', (socket) => {
});
});
-// ブラインドタイマー・持ち時間・次のハンドなど時間で進む処理
+// ブラインドタイマー・オールイン後の場札・次のハンドなど時間で進む処理
setInterval(() => {
const now = Date.now();
for (const table of tables.tables.values()) {
diff --git a/server/tables.js b/server/tables.js
index 984bda3..a28e9a7 100644
--- a/server/tables.js
+++ b/server/tables.js
@@ -14,6 +14,8 @@ class Table {
this.baseBigBlind = opts.bigBlind;
this.timerEnabled = opts.blindTimerEnabled;
this.intervalMin = opts.blindIntervalMin;
+ // カード機能: サーバがカードを配り、ショーダウンの勝敗も自動で判定する(オフなら実物のカードで遊ぶ)
+ this.cardsEnabled = opts.cardsEnabled === true;
this.hostToken = opts.hostToken || null;
this.maxSeats = config.MAX_SEATS;
@@ -28,6 +30,7 @@ class Table {
this.handNumber = 0;
this.buttonSeat = null;
this.nextHandAt = null;
+ this.runoutAt = null; // オールイン後、次の場札をめくる時刻
this.levelEndsAt = null;
this.levelRemainingMs = this.intervalMs;
@@ -139,7 +142,16 @@ class Table {
this.handNumber++;
const { sb, bb } = this.blinds;
this.nextHandAt = null;
- this.hand = new Hand({ number: this.handNumber, seats: this.seats, order, button: this.buttonSeat, sb, bb });
+ this.runoutAt = null;
+ this.hand = new Hand({
+ number: this.handNumber,
+ seats: this.seats,
+ order,
+ button: this.buttonSeat,
+ sb,
+ bb,
+ cards: this.cardsEnabled,
+ });
}
// ---- プレイヤー操作(エラー時はメッセージを返す) ----
@@ -202,8 +214,9 @@ class Table {
if (!this.isHost(token)) return 'ホストのみ操作できます';
if (!this.started) return 'まだ開始していません';
if (this.paused) {
- // 止めていた時間だけ次のハンドの開始を後ろにずらす
+ // 止めていた時間だけ次のハンドの開始・場札をめくる時刻を後ろにずらす
if (this.nextHandAt != null) this.nextHandAt += now - this.pausedAt;
+ if (this.runoutAt != null) this.runoutAt += now - this.pausedAt;
if (this.timerEnabled) this.levelEndsAt = now + this.levelRemainingMs;
this.paused = false;
this.pausedAt = null;
@@ -218,7 +231,7 @@ class Table {
return null;
}
- // 時間で進む処理(ブラインドタイマー・次のハンドの開始)
+ // 時間で進む処理(ブラインドタイマー・オールイン後の場札・次のハンドの開始)
// 状態が変わったら true
tick(now) {
let changed = false;
@@ -229,9 +242,20 @@ class Table {
}
if (this.paused) return changed;
+ const h = this.hand;
+ if (h && h.runout) {
+ if (this.runoutAt == null) this.runoutAt = now + config.RUNOUT_STEP_MS;
+ else if (now >= this.runoutAt) {
+ h.runoutStep();
+ this.runoutAt = h.runout ? now + config.RUNOUT_STEP_MS : null;
+ changed = true;
+ }
+ }
+
if (this.hand && this.hand.finished) {
if (this.nextHandAt == null) {
- this.nextHandAt = now + (this.hand.showdown ? config.SHOWDOWN_MS : config.FOLD_WIN_MS);
+ const showdownMs = this.cardsEnabled ? config.CARD_SHOWDOWN_MS : config.SHOWDOWN_MS;
+ this.nextHandAt = now + (this.hand.showdown ? showdownMs : config.FOLD_WIN_MS);
} else if (now >= this.nextHandAt) {
if (this.canDeal()) this.startNewHand();
else {
@@ -267,18 +291,19 @@ class Table {
paused: this.paused,
timerEnabled: this.timerEnabled,
intervalMin: this.intervalMin,
+ cardsEnabled: this.cardsEnabled,
createdAt: this.createdAt,
};
}
- // token = 閲覧者のトークン(他人のトークンは送らない)
+ // token = 閲覧者のトークン(他人のトークンや手札は送らない)
publicState(token, connectedTokens) {
const now = Date.now();
const { sb, bb } = this.blinds;
const next = Blinds.blindsAt(this.baseSmallBlind, this.baseBigBlind, this.level + 1);
const mySeat = this.seatOf(token);
const h = this.hand;
- const hv = h ? h.view() : null;
+ const hv = h ? h.view(mySeat, token) : null;
const me = mySeat >= 0 ? this.seats[mySeat] : null;
const inLiveHand = Boolean(this.handActive && h.players.has(mySeat) && !h.players.get(mySeat).folded);
@@ -287,6 +312,7 @@ class Table {
name: this.name,
startingStack: this.startingStack,
maxSeats: this.maxSeats,
+ cardsEnabled: this.cardsEnabled,
level: this.level,
smallBlind: sb,
bigBlind: bb,
diff --git a/test/engine.test.js b/test/engine.test.js
index f17598a..a137c98 100644
--- a/test/engine.test.js
+++ b/test/engine.test.js
@@ -6,6 +6,8 @@ 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文字のトークン
@@ -345,3 +347,226 @@ test('ランダムに遊んでもチップの合計が変わらず、必ず終
}
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], { cardsEnabled: true });
+ 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], { cardsEnabled: true });
+ 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], { cardsEnabled: true });
+ 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], { cardsEnabled: true });
+ 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], { cardsEnabled: true });
+ 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, { cardsEnabled: true });
+ 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}`);
+});