- Showdown winners can now be chosen only by the table host (others see "ホストが勝者を選んでいます"); if the host leaves, the next host does it - START / pause moved into the header to the right of the table name (pause is an icon button), freeing the row they used to occupy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
348 lines
13 KiB
JavaScript
348 lines
13 KiB
JavaScript
'use strict';
|
||
|
||
// node --test で実行(ACCESS_KEY はテスト用のダミー)
|
||
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 T = (n) => 't'.repeat(31) + n; // 32文字のトークン
|
||
|
||
function makeTable(stacks, opts = {}) {
|
||
const table = new Table({
|
||
id: 'x',
|
||
name: 'test',
|
||
startingStack: 1000,
|
||
smallBlind: 10,
|
||
bigBlind: 20,
|
||
blindTimerEnabled: false,
|
||
blindIntervalMin: 10,
|
||
hostToken: T(0),
|
||
...opts,
|
||
});
|
||
stacks.forEach((st, i) => {
|
||
table.join(T(i), 'p' + i);
|
||
table.seats[i].stack = st;
|
||
});
|
||
return table;
|
||
}
|
||
|
||
const tk = (t, seat) => t.seats[seat].token;
|
||
|
||
// ショーダウンまでチェック/コールで進める
|
||
function checkDown(t) {
|
||
const h = t.hand;
|
||
for (let guard = 0; guard < 100 && h.toAct != null; guard++) {
|
||
const o = h.options(h.toAct);
|
||
assert.equal(t.act(tk(t, h.toAct), o.canCheck ? 'check' : 'call', 0), null);
|
||
}
|
||
}
|
||
|
||
test('ヘッズアップ: ボタンがSBで先に動き、フォールドでBBが勝つ', () => {
|
||
const t = makeTable([1000, 1000]);
|
||
assert.equal(t.start(T(0), 0), null);
|
||
const h = t.hand;
|
||
assert.equal(h.sbSeat, h.button);
|
||
assert.equal(h.toAct, h.sbSeat);
|
||
const sb = h.sbSeat;
|
||
const bb = h.bbSeat;
|
||
assert.equal(t.seats[sb].stack, 990);
|
||
assert.equal(t.seats[bb].stack, 980);
|
||
assert.equal(t.act(tk(t, sb), 'fold', 0), null);
|
||
assert.ok(h.finished && !h.showdown);
|
||
assert.equal(t.seats[sb].stack, 990);
|
||
assert.equal(t.seats[bb].stack, 1010);
|
||
});
|
||
|
||
test('BBのオプションとチェックでストリートが進む', () => {
|
||
const t = makeTable([1000, 1000, 1000]);
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
const [sb, bb, btn] = h.order;
|
||
assert.equal(h.toAct, btn); // UTG = ボタン(3人)
|
||
assert.equal(t.act(tk(t, btn), 'call', 0), null);
|
||
assert.equal(t.act(tk(t, sb), 'call', 0), null);
|
||
assert.equal(h.street, 'PREFLOP');
|
||
assert.equal(h.toAct, bb); // BB はまだ動ける
|
||
assert.ok(h.options(bb).canRaise);
|
||
assert.equal(t.act(tk(t, bb), 'check', 0), null);
|
||
assert.equal(h.street, 'FLOP');
|
||
assert.equal(h.pot, 60);
|
||
assert.equal(h.toAct, sb); // ポストフロップは SB から
|
||
checkDown(t);
|
||
assert.equal(t.phase, 'SHOWDOWN');
|
||
assert.deepEqual(h.view().pots, [{ amount: 60, eligible: [sb, bb, btn] }]);
|
||
});
|
||
|
||
test('ミニマムレイズと、足りないオールインは再レイズ権を与えない', () => {
|
||
const t = makeTable([1000, 1000, 1000]);
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
const [sb, bb, btn] = h.order;
|
||
// 最低レイズ額は 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;
|
||
assert.equal(t.act(tk(t, bb), 'raise', 75), null);
|
||
assert.equal(h.currentBet, 75);
|
||
assert.equal(h.toAct, btn);
|
||
assert.equal(h.options(btn).canRaise, false); // コールかフォールドのみ
|
||
assert.equal(t.act(tk(t, btn), 'raise', 200), 'レイズできません');
|
||
assert.equal(t.act(tk(t, btn), 'call', 0), null);
|
||
assert.equal(t.act(tk(t, sb), 'call', 0), null);
|
||
assert.equal(h.street, 'FLOP');
|
||
});
|
||
|
||
test('3人オールイン: すぐショーダウンになり、サイドポットと返金', () => {
|
||
const t = makeTable([100, 300, 500]);
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
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.equal(h.street, 'SHOWDOWN');
|
||
// 席2 の超過 200 は返金済み。メイン 300(全員)、サイド 400(席1・席2)
|
||
assert.equal(t.seats[2].stack, 200);
|
||
assert.deepEqual(
|
||
h.view().pots.map((p) => [p.amount, p.eligible.slice().sort()]),
|
||
[[300, [0, 1, 2]], [400, [1, 2]]]
|
||
);
|
||
// 席0 がメイン、席1 がサイドを獲得
|
||
assert.equal(t.award(T(0), [[0], [1]]), null);
|
||
assert.ok(h.finished && h.showdown);
|
||
assert.equal(t.seats[0].stack, 300);
|
||
assert.equal(t.seats[1].stack, 400);
|
||
assert.equal(t.seats[2].stack, 200);
|
||
});
|
||
|
||
test('勝者の指定: 山分け(端数はボタンの左から)と不正な指定の拒否', () => {
|
||
const t = makeTable([1000, 1000, 1000]);
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
const [sb, bb, btn] = h.order;
|
||
// SB をフォールドさせて、BB とボタンで 50 のポットを争う(SB の 10 + 20 + 20)
|
||
assert.equal(t.act(tk(t, btn), 'call', 0), null);
|
||
assert.equal(t.act(tk(t, sb), 'fold', 0), null);
|
||
assert.equal(t.act(tk(t, bb), 'check', 0), null);
|
||
checkDown(t);
|
||
assert.equal(h.street, 'SHOWDOWN');
|
||
const host = T(0);
|
||
|
||
// ホスト以外は、ショーダウンに残った人でも分配できない
|
||
for (const s of [sb, bb, btn]) {
|
||
if (tk(t, s) !== host) assert.equal(t.award(tk(t, s), [[bb]]), 'ポットの分配はホストのみ行えます');
|
||
}
|
||
assert.ok(t.award(T(8), [[bb]])); // 着席していない
|
||
assert.ok(t.award(host, []));
|
||
assert.ok(t.award(host, [[]]));
|
||
assert.ok(t.award(host, [[sb]])); // フォールドした人は勝てない
|
||
assert.ok(t.award(host, [[bb, bb]]));
|
||
assert.ok(t.award(host, [[bb], [btn]])); // ポットは1つ
|
||
assert.ok(!h.finished);
|
||
|
||
assert.equal(t.award(host, [[bb, btn]]), null);
|
||
// 50 を2人で → 25 ずつ
|
||
assert.equal(t.seats[bb].stack, 1005);
|
||
assert.equal(t.seats[btn].stack, 1005);
|
||
assert.equal(t.award(host, [[bb]]), 'ショーダウンではありません');
|
||
});
|
||
|
||
test('ホストが退席したら次のホストが分配する', () => {
|
||
const t = makeTable([1000, 1000, 1000]);
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
// ホスト(席0)以外の2人と一緒にショーダウンまで進め、ホストが退席する
|
||
checkDown(t);
|
||
assert.equal(h.street, 'SHOWDOWN');
|
||
t.leave(T(0));
|
||
assert.notEqual(t.hostToken, T(0));
|
||
const live = h.live().map((p) => p.seat);
|
||
assert.equal(t.award(T(0), [[live[0]]]), 'ポットの分配はホストのみ行えます');
|
||
assert.equal(t.award(t.hostToken, [[live[0]]]), null);
|
||
assert.ok(h.finished);
|
||
});
|
||
|
||
test('端数はボタンの左から配る', () => {
|
||
const t = makeTable([1000, 1000, 1000], { smallBlind: 5, bigBlind: 10 });
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
const [sb, bb, btn] = h.order;
|
||
t.act(tk(t, btn), 'call', 0);
|
||
t.act(tk(t, sb), 'fold', 0);
|
||
t.act(tk(t, bb), 'check', 0);
|
||
checkDown(t);
|
||
// 25 を2人で → ボタンの左に近い BB が 13、ボタンが 12
|
||
assert.equal(t.award(T(0), [[btn, bb]]), null);
|
||
assert.equal(t.seats[bb].stack, 990 + 13);
|
||
assert.equal(t.seats[btn].stack, 990 + 12);
|
||
});
|
||
|
||
test('ホストはショーダウンに参加していなくても勝者を選べる', () => {
|
||
const t = makeTable([1000, 1000, 1000]);
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
// ホスト(席0)だけフォールドさせる
|
||
for (let guard = 0; guard < 50 && h.toAct != null; guard++) {
|
||
const o = h.options(h.toAct);
|
||
t.act(tk(t, h.toAct), h.toAct === 0 ? 'fold' : o.canCheck ? 'check' : 'call', 0);
|
||
}
|
||
assert.equal(h.street, 'SHOWDOWN');
|
||
const other = h.live().map((p) => p.seat);
|
||
assert.equal(t.canAward(T(0)), true);
|
||
assert.equal(t.award(T(0), [[other[0]]]), null);
|
||
assert.ok(h.finished);
|
||
});
|
||
|
||
test('ショーダウン中に1人を残して退席したら残った人が勝つ', () => {
|
||
const t = makeTable([1000, 1000]);
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
checkDown(t);
|
||
assert.equal(h.street, 'SHOWDOWN');
|
||
const [a, b] = h.order;
|
||
t.leave(tk(t, a));
|
||
assert.ok(h.finished && !h.showdown);
|
||
assert.equal(t.seats[b].stack, 1020);
|
||
});
|
||
|
||
test('離席中の人の手番は自動でチェック/フォールド、次のハンドから外れる', () => {
|
||
const t = makeTable([1000, 1000, 1000]);
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
const first = h.toAct;
|
||
assert.equal(t.setSittingOut(tk(t, first), true), null);
|
||
assert.ok(h.players.get(first).folded);
|
||
assert.notEqual(h.toAct, first);
|
||
t.act(tk(t, h.toAct), 'fold', 0); // 残り1人で終了
|
||
assert.ok(h.finished);
|
||
t.tick(10 ** 6);
|
||
t.tick(10 ** 7);
|
||
assert.equal(t.hand.number, 2);
|
||
assert.ok(!t.hand.players.has(first));
|
||
assert.equal(t.setSittingOut(tk(t, first), false), null);
|
||
assert.ok(t.eligibleSeats().includes(first));
|
||
});
|
||
|
||
test('一時停止中はアクションも勝者の決定も次のハンドもできない', () => {
|
||
const t = makeTable([1000, 1000]);
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
t.togglePause(T(0), 1000);
|
||
assert.equal(t.act(tk(t, h.toAct), 'fold', 0), '一時停止中です');
|
||
t.togglePause(T(0), 2000);
|
||
checkDown(t);
|
||
t.togglePause(T(0), 3000);
|
||
assert.equal(t.award(T(0), [[h.order[0]]]), '一時停止中です');
|
||
t.togglePause(T(0), 4000);
|
||
assert.equal(t.award(T(0), [[h.order[0]]]), null);
|
||
t.togglePause(T(0), 5000);
|
||
t.tick(10 ** 7);
|
||
t.tick(10 ** 8);
|
||
assert.equal(t.hand.number, 1); // 止まっている
|
||
t.togglePause(T(0), 10 ** 8);
|
||
t.tick(10 ** 8 + 10 ** 5);
|
||
t.tick(10 ** 8 + 10 ** 6);
|
||
assert.equal(t.hand.number, 2);
|
||
});
|
||
|
||
test('ハンド中の退席: フォールド扱いで席はハンド終了まで予約', () => {
|
||
const t = makeTable([1000, 1000, 1000]);
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
const [sb, bb, btn] = h.order;
|
||
t.act(tk(t, btn), 'call', 0);
|
||
assert.ok(t.leave(tk(t, sb))); // 手番の SB が退席
|
||
assert.ok(h.players.get(sb).folded);
|
||
assert.ok(t.seats[sb].left);
|
||
assert.equal(t.playerCount, 2);
|
||
// 新しく来た人は予約席には座らない
|
||
assert.notEqual(t.join(T(9), 'new'), sb);
|
||
assert.equal(t.act(tk(t, bb), 'check', 0), null);
|
||
assert.equal(h.street, 'FLOP');
|
||
});
|
||
|
||
test('2人のうち片方が退席したら残った人が勝つ(BB は返らない)', () => {
|
||
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);
|
||
});
|
||
|
||
test('他人の手番では操作できない・不正な額は拒否', () => {
|
||
const t = makeTable([1000, 1000, 1000]);
|
||
t.start(T(0), 0);
|
||
const h = t.hand;
|
||
const notTurn = h.order.find((s) => s !== h.toAct);
|
||
assert.equal(t.act(tk(t, notTurn), 'fold', 0), 'あなたの番ではありません');
|
||
const me = tk(t, h.toAct);
|
||
assert.ok(t.act(me, 'raise', 10 ** 9));
|
||
assert.ok(t.act(me, 'raise', 40.5));
|
||
assert.ok(t.act(me, 'check', 0)); // BB に対してチェックはできない
|
||
assert.ok(t.act(T(8), 'fold', 0)); // 着席していない
|
||
});
|
||
|
||
test('リバイ', () => {
|
||
const t = makeTable([1000, 1000]);
|
||
t.seats[1].stack = 0;
|
||
assert.equal(t.rebuy(tk(t, 0)), 'チップが残っています');
|
||
assert.equal(t.rebuy(tk(t, 1)), null);
|
||
assert.equal(t.seats[1].stack, 1000);
|
||
assert.equal(t.seats[1].buyIns, 2);
|
||
});
|
||
|
||
test('ランダムに遊んでもチップの合計が変わらず、必ず終わる', () => {
|
||
let rnd = 12345;
|
||
const rand = (n) => {
|
||
rnd = (rnd * 1103515245 + 12345) % 2147483648;
|
||
return rnd % n;
|
||
};
|
||
let showdowns = 0;
|
||
let sidePots = 0;
|
||
for (let game = 0; game < 60; game++) {
|
||
const n = 2 + (game % 8);
|
||
const stacks = Array.from({ length: n }, () => 20 + rand(2000));
|
||
const t = makeTable(stacks);
|
||
const sum = stacks.reduce((a, b) => a + b, 0);
|
||
t.start(T(0), 0);
|
||
let now = 0;
|
||
for (let step = 0; step < 20000 && t.hand; step++) {
|
||
const h = t.hand;
|
||
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 if (h.awaitingWinners) {
|
||
const pots = h.view().pots;
|
||
showdowns++;
|
||
if (pots.length > 1) sidePots++;
|
||
const winners = pots.map((p) => {
|
||
const w = p.eligible.filter(() => rand(3) === 0);
|
||
return w.length ? w : [p.eligible[rand(p.eligible.length)]];
|
||
});
|
||
assert.equal(t.award(T(0), winners), null);
|
||
} else {
|
||
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 && sidePots > 20, `showdowns=${showdowns} sidePots=${sidePots}`);
|
||
});
|