'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 { bestHand, compareScore } = require('../server/cards'); const { Table } = require('../server/tables'); const config = require('../server/config'); 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; } // 山札の上(pop される順)に指定のカードを積む 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 = []; seq.push('X', board[0], board[1], board[2], 'X', board[3], 'X', board[4]); const filler = rest.slice(); hand.deck = filler.slice(0, filler.length - 8).concat(seq.map((c) => (c === 'X' ? filler.pop() : c)).reverse()); } 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('ヘッズアップ: ボタンが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(t.seats[sb].token, 'fold', 0, 1), null); assert.ok(h.finished); assert.equal(t.seats[sb].stack, 990); assert.equal(t.seats[bb].stack, 1010); // 手札は公開されない assert.equal(h.view(sb, t.seats[sb].token).players[bb].cards, null); }); 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(t.seats[btn].token, 'call', 0, 1), null); assert.equal(t.act(t.seats[sb].token, 'call', 0, 1), null); assert.equal(h.street, 'PREFLOP'); assert.equal(h.toAct, bb); // BB はまだ動ける assert.ok(h.options(bb).canRaise); assert.equal(t.act(t.seats[bb].token, 'check', 0, 1), null); assert.equal(h.street, 'FLOP'); assert.equal(h.board.length, 3); assert.equal(h.pot, 60); assert.equal(h.toAct, sb); // ポストフロップは SB から }); test('ミニマムレイズと、足りないオールインは再レイズ権を与えない', () => { const t = makeTable([1000, 1000, 1000]); t.start(T(0), 0); const h = t.hand; const [sb, bb, btn] = h.order; const tk = (s) => t.seats[s].token; // 最低レイズ額は 40(20 + 20) assert.ok(t.act(tk(btn), 'raise', 39, 1)); assert.equal(t.act(tk(btn), 'raise', 60, 1), null); // +40 のレイズ assert.equal(h.options(sb).minRaiseTo, 100); assert.equal(t.act(tk(sb), 'call', 0, 1), null); // BB は 75 しか持っていないとする → 75 オールインは 100 未満なので不完全なレイズ t.seats[bb].stack = 75 - 20; assert.equal(t.act(tk(bb), 'raise', 75, 1), null); assert.equal(h.currentBet, 75); assert.equal(h.toAct, btn); assert.equal(h.options(btn).canRaise, false); // コールかフォールドのみ assert.equal(t.act(tk(btn), 'raise', 200, 1), 'レイズできません'); assert.equal(t.act(tk(btn), 'call', 0, 1), null); assert.equal(t.act(tk(sb), 'call', 0, 1), null); assert.equal(h.street, 'FLOP'); }); test('3人オールイン: サイドポットと返金', () => { const t = makeTable([100, 300, 500]); t.start(T(0), 0); const h = t.hand; const tk = (s) => t.seats[s].token; // 席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(h.toAct), o.canRaise ? 'raise' : 'call', o.canRaise ? o.maxRaiseTo : 0, 1), null); } assert.ok(h.revealed); // 残りのカードを順にめくる for (let now = 2; !h.finished && now < 100; now++) t.tick(now * config.RUNOUT_STEP_MS); assert.ok(h.finished && h.showdown); assert.equal(h.board.length, 5); // メイン 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]), [[300, [0]], [400, [1]]]); }); test('引き分けは山分け、端数はボタンの左から', () => { const t = makeTable([1000, 1000, 1000]); t.start(T(0), 0); const h = t.hand; const tk = (s) => t.seats[s].token; const [sb, bb, btn] = h.order; rig(h, [['2c', '3d'], ['2d', '3c'], ['4h', '5h']], ['As', 'Ks', 'Qs', 'Js', 'Ts']); // 全員ロイヤル t.seats[sb].stack = 0; // SB は 10 だけ入れてオールイン扱いにする h.players.get(sb).allIn = true; t.act(tk(btn), 'call', 0, 1); t.act(tk(bb), 'check', 0, 1); for (let now = 2; !h.finished && now < 100; now++) { if (h.toAct != null) t.act(tk(h.toAct), 'check', 0, now); } assert.ok(h.finished); // メイン 30 を3人で 10 ずつ、サイド 20 を2人で 10 ずつ assert.equal(t.seats[sb].stack, 10); assert.equal(t.seats[bb].stack, 1000); assert.equal(t.seats[btn].stack, 1000); }); test('持ち時間切れで自動フォールドし離席扱い、次のハンドから外れる', () => { const t = makeTable([1000, 1000, 1000]); t.start(T(0), 0); const h = t.hand; const first = h.toAct; t.tick(config.ACTION_TIME_MS + 1); assert.ok(h.players.get(first).folded); assert.ok(t.seats[first].sittingOut); assert.notEqual(h.toAct, first); // 残りの2人で終わらせる while (!h.finished) { const o = h.options(h.toAct); t.act(t.seats[h.toAct].token, o.canCheck ? 'check' : 'call', 0, 2); } 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(t.seats[first].token, 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(t.seats[h.toAct].token, 'fold', 0, 1500), '一時停止中です'); t.tick(10 ** 6); assert.ok(!h.finished); t.togglePause(T(0), 10 ** 6); t.tick(10 ** 6 + config.ACTION_TIME_MS - 2000); assert.ok(!h.finished); t.tick(10 ** 6 + config.ACTION_TIME_MS); assert.ok(h.finished); }); test('ハンド中の退席: フォールド扱いで席はハンド終了まで予約', () => { const t = makeTable([1000, 1000, 1000]); t.start(T(0), 0); const h = t.hand; const [sb, bb, btn] = h.order; const leaver = t.seats[sb].token; t.act(t.seats[btn].token, 'call', 0, 1); assert.ok(t.leave(leaver, 1)); // 手番の SB が退席 assert.ok(h.players.get(sb).folded); assert.ok(t.seats[sb].left); assert.equal(t.playerCount, 2); // 新しく来た人は予約席には座らない const seat = t.join(T(9), 'new'); assert.notEqual(seat, sb); assert.equal(t.act(t.seats[bb].token, 'check', 0, 1), null); assert.equal(h.street, 'FLOP'); // ホストが抜けたら次の人へ if (leaver === T(0)) assert.notEqual(t.hostToken, T(0)); }); 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(t.seats[other].token, 1); assert.ok(h.finished); // BB の 20 は返らずそのまま勝者へ 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(t.seats[notTurn].token, 'fold', 0, 1), 'あなたの番ではありません'); const tk = t.seats[h.toAct].token; assert.ok(t.act(tk, 'raise', 10 ** 9, 1)); assert.ok(t.act(tk, 'raise', 40.5, 1)); assert.ok(t.act(tk, 'check', 0, 1)); // BB に対してチェックはできない assert.ok(t.act(T(8), 'fold', 0, 1)); // 着席していない }); test('リバイ', () => { const t = makeTable([1000, 1000]); t.seats[1].stack = 0; assert.equal(t.rebuy(t.seats[0].token), 'チップが残っています'); assert.equal(t.rebuy(t.seats[1].token), 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; }; for (let game = 0; game < 30; 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++) { now += 100; const h = t.hand; if (h && !h.finished && h.toAct != null) { const o = h.options(h.toAct); const r = rand(10); const tk = t.seats[h.toAct].token; let err; if (r < 2) err = t.act(tk, 'fold', 0, now); else if (r < 6) err = t.act(tk, o.canCheck ? 'check' : 'call', 0, now); else if (o.canRaise) { const amt = o.minRaiseTo + rand(o.maxRaiseTo - o.minRaiseTo + 1); err = t.act(tk, 'raise', amt, now); } else err = t.act(tk, o.canCheck ? 'check' : 'call', 0, now); assert.equal(err, null); } else { t.tick(now + 10 ** 5); 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(-1).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; } } });