Add optional card mode: server deals cards and settles showdowns automatically

- New "カードを配る" switch on the create-table screen (default off); the lobby
  shows "カードあり" for tables that use it
- Card mode (server/cards.js restored): crypto-shuffled deck kept server-side,
  hole cards sent only to their owner until showdown/all-in reveal, board dealt
  per street with burns, all-in runout on a timer (frozen while paused)
- Showdown is evaluated and every pot (incl. side pots, split pots) is paid out
  automatically; host winner selection is only used when card mode is off
- Play UI: board, hole cards, own cards in the action bar, hand names, winning
  cards highlighted; layout tuned for 7+ seats and short screens
- Playing-card styles use .pcard so they don't clash with the existing .card panel
- Tests for hand evaluation, card privacy, runout, side pots, ties and random play

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-17 16:44:22 +09:00
1 parent 6d33440048
commit 66907345ff
13 files changed
+653 -42

No files matched your search

+225
View File
@@ -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}`);
});