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:
1 parent
6d33440048
commit
66907345ff
13 files changed
+653
-42
No files matched your search
@@ -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}`);
|
||||
});
|
||||
Reference in new issue
Block a user