'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 { bestHand, compareScore } = require('../server/cards'); 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; } 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}`); }); // ---------- カード機能あり ---------- // 山札の上(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], { mode: 'friends' }); 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], { mode: 'friends' }); 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], { mode: 'friends' }); 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], { mode: 'friends' }); 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], { mode: 'friends' }); 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, { mode: 'friends' }); 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}`); }); // ---------- CPU ---------- const ai = require('../server/ai'); const { newDeck } = require('../server/cards'); // 再現できる乱数 function seeded(seed) { let x = seed; return () => { x = (x * 1103515245 + 12345) % 2147483648; return x / 2147483648; }; } test('CPU: 高速な役判定は bestHand と同じ強さの順になる', () => { for (let i = 0; i < 3000; i++) { const d = newDeck(); const a = d.slice(0, 7); const b = d.slice(7, 14); const fast = Math.sign(ai.evaluate(a.map(ai.toInt)) - ai.evaluate(b.map(ai.toInt))); const slow = Math.sign(compareScore(bestHand(a).score, bestHand(b).score)); assert.equal(fast, slow, `${a} / ${b}`); } }); test('CPU: 勝率の見積もり', () => { const rng = seeded(42); const near = (v, target) => assert.ok(Math.abs(v - target) < 0.03, `${v} ≉ ${target}`); near(ai.equity(['As', 'Ah'], [], 1, 4000, rng), 0.85); near(ai.equity(['7s', '2h'], [], 1, 4000, rng), 0.35); // ナッツは負けない assert.equal(ai.equity(['As', 'Ks'], ['Qs', 'Js', 'Ts'], 3, 500, rng), 1); }); test('CPU: 強い手はレイズし、弱い手は大きなベットに降りる', () => { const t = makeTable([1000, 1000, 1000], { mode: 'friends' }); t.start(T(0), 0); const h = t.hand; const [sb, bb, btn] = h.order; rig(h, [['7c', '2d'], ['8h', '3s'], ['As', 'Ah']], ['Kd', '9c', '4s', '5h', 'Jd']); const rng = seeded(1); const d = ai.decide(h, btn, { rng }); assert.equal(d.type, 'raise'); t.act(tk(t, btn), 'raise', 1000); // オールイン assert.equal(ai.decide(h, sb, { rng }).type, 'fold'); }); test('CPU: どんな状況でも正しいアクションを返す', () => { const rng = seeded(7); let actions = 0; for (let game = 0; game < 12; game++) { const n = 2 + (game % 8); const stacks = Array.from({ length: n }, () => 20 + Math.floor(rng() * 2000)); const t = makeTable(stacks, { mode: 'friends' }); const sum = stacks.reduce((a, b) => a + b, 0); t.start(T(0), 0); let now = 0; for (let step = 0; step < 5000 && t.hand && t.hand.number <= 15; step++) { const h = t.hand; if (!h.finished && h.toAct != null) { const style = ai.randomStyle(rng); const d = ai.decide(h, h.toAct, { style, rng, iterations: 60 }); assert.equal(h.act(h.toAct, d.type, d.amount), null, JSON.stringify(d)); actions++; } else { t.tick((now += 10 ** 5)); } const inPot = t.hand && !t.hand.finished ? t.hand.potTotal : 0; assert.equal(t.seats.reduce((a, s) => a + (s ? s.stack : 0), 0) + inPot, sum); } } assert.ok(actions > 300, `actions=${actions}`); }); test('CPU戦: CPU が席に着き、ホストは人間のまま、CPU は自動で行動する', () => { const t = new Table({ id: 'c', name: 'cpu', startingStack: 1000, smallBlind: 10, bigBlind: 20, blindTimerEnabled: false, blindIntervalMin: 10, hostToken: T(0), mode: 'cpu', cpuCount: 3, }); assert.equal(t.cardsEnabled, true); assert.deepEqual(t.seats.map((s) => Boolean(s && s.bot)), [false, true, true, true, false, false, false, false, false]); assert.equal(t.join(T(0), 'me'), 0); assert.equal(t.isHost(T(0)), true); const v = t.publicState(T(0), new Set([T(0)])); assert.equal(v.mode, 'cpu'); assert.ok(v.seats[1].bot && v.seats[1].connected); assert.equal(v.seats[0].bot, false); assert.equal(t.start(T(0), 0), null); // 人間の手番まで CPU が進める(人間の手番は代わりに動かない) let now = 0; for (let k = 0; k < 200 && t.hand.toAct !== 0 && !t.hand.finished; k++) t.tick((now += 5000)); if (!t.hand.finished) { const before = t.hand.actions; t.tick((now += 60000)); t.tick((now += 60000)); assert.equal(t.hand.toAct, 0); assert.equal(t.hand.actions, before); } // 人間が離席すると CPU だけで次のハンドは始まらない t.setSittingOut(T(0), true); for (let k = 0; k < 400 && t.hand; k++) t.tick((now += 5000)); assert.equal(t.hand, null); t.setSittingOut(T(0), false); // チップがなくなった CPU は次のハンドでリバイ t.seats[2].stack = 0; for (let k = 0; k < 10 && !t.hand; k++) t.tick((now += 5000)); assert.ok(t.hand); assert.equal(t.seats[2].buyIns, 2); assert.ok(t.hand.players.has(2)); // ホストが退席しても CPU はホストにならない t.leave(T(0)); assert.equal(t.hostToken, null); t.join(T(5), 'friend'); assert.equal(t.isHost(T(5)), true); });