import { describe, expect, test } from 'bun:test'; import { SeededRng, seedN, type PlayerId } from '@bg/engine'; import { decideBotAction } from '../bots/index'; import { botPlayout } from '../bots/playout'; import { gostop, type FloorGroup, type GostopAction, type GostopOptions, type GostopState } from './index'; // Card ids: month m → (m-1)*4 + slot. 1월 0광 1홍단 2·3피, 2월 4열 5홍단 6·7피, 3월 8광 9홍단 10·11피, // 5월 16열 17초단 18·19피, 6월 20열 21청단 22·23피, 7월 24열 25초단 26·27피, 8월 28광 29열 30·31피, 11월 40광 41쌍피 42·43피. /** A play-phase state for players A (bot under test), B, C… with exactly these cards. */ function rigged(n: number, spec: { hands: number[][]; floor: number[]; deck: number[]; captured?: number[][] }, opts: Partial = {}): GostopState { const players = ['A', 'B', 'C', 'D', 'E'].slice(0, n); const s = gostop.setup({ players, options: gostop.optionsSchema.parse({ chongtong: 'off', ...opts }), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); s.dealer = 0; const r = s.round!; const zeros = () => Array.from({ length: n }, () => 0); const lists = () => Array.from({ length: n }, (): number[] => []); Object.assign(r, { participants: Array.from({ length: Math.min(n, 3) }, (_, i) => i), sellOrder: [], sellCursor: 0, sellDecisions: [], sellers: [], step: 'play', turn: 0, pending: null, stopBy: null, chongtongBy: null, bombDummies: zeros(), revealed: lists(), shakenMonths: lists(), goCount: zeros(), lastGoScore: zeros(), goOrder: [], shakes: zeros(), bombs: zeros(), ppeokCount: zeros(), ppeokTurns: lists(), turnsTaken: zeros(), instant: zeros(), }); r.hands = Array.from({ length: n }, (_, i) => [...(spec.hands[i] ?? [])]); r.deck = [...spec.deck]; r.captured = Array.from({ length: n }, (_, i) => [...(spec.captured?.[i] ?? [])]); const groups: FloorGroup[] = []; for (const c of spec.floor) { const m = Math.floor(c / 4) + 1; const g = groups.find((x) => x.month === m); if (g) g.cards.push(c); else groups.push({ month: m, cards: [c], ppeokBy: null, natural: false }); } r.floor = groups.sort((a, b) => a.month - b.month); return s; } const decide = (s: GostopState) => decideBotAction(gostop, s, 'A', { now: 0, random: () => 0.5 }) as GostopAction; describe('gostop bot', () => { test('plays the card that captures a 광 rather than a plain 피', () => { const s = rigged(2, { hands: [[9, 7, 42], [43]], floor: [8, 6, 20], deck: [24, 25, 26, 27, 16, 17] }); expect(decide(s)).toEqual({ type: 'play', card: 9 }); }); test('picks the 광 when two different floor cards of the month can be taken', () => { const s = rigged(2, { hands: [[3], [43]], floor: [0, 2], deck: [24, 25, 26, 27] }); expect(decide(s)).toEqual({ type: 'play', card: 3, target: 0 }); }); test('with no match it throws away its cheapest card, not a 광', () => { const s = rigged(2, { hands: [[8, 42, 16], [43]], floor: [20, 24], deck: [26, 27, 30, 31] }); expect(decide(s)).toEqual({ type: 'play', card: 42 }); }); test('go while the opponents have nothing, stop when one of them is close', () => { const deck = [16, 17, 18, 19, 20, 21, 22, 23]; const hands = [[24, 25, 26, 27], [30, 31, 42, 43], [40, 41, 36, 37]]; const s = rigged(3, { hands, floor: [], deck, captured: [[0, 8, 28], [], []] }); s.round!.step = 'goStop'; expect(decide(s)).toEqual({ type: 'goStop', decision: 'go' }); const t = rigged(3, { hands, floor: [], deck, captured: [[0, 8, 28], [1, 5, 9], []] }); t.round!.step = 'goStop'; expect(decide(t)).toEqual({ type: 'goStop', decision: 'stop' }); }); test('bot-only games finish in every mode (맞고, 3인, 광팔기 4~5인, 국진 직접 선택, 총통, 보너스 3+1)', () => { const isBot = (p: PlayerId) => p.startsWith('bot:'); const cases: [number, Partial][] = [ [2, { rounds: 5 }], [3, { rounds: 5, gukjinMode: 'ask', bonusCards: '3+1' }], [4, { rounds: 5, chongtong: 'win' }], [5, { rounds: 5, gwangSellCards: 'gwangPlus', gukjinMode: 'ask' }], ]; for (const [i, [n, o]] of cases.entries()) { const players = Array.from({ length: n }, (_, k) => `bot:${k}:x`); const r = botPlayout(gostop, { players, options: gostop.optionsSchema.parse(o), seed: seedN(40 + i), isBot, maxSteps: 30_000 }); expect(r.finished).toBe(true); expect(r.botTimeouts).toBe(0); } }); });