import { describe, expect, test } from 'bun:test'; import { SeededRng, seedN } from '@bg/engine'; import { decideBotAction } from '../bots/index'; import { botPlayout } from '../bots/playout'; import { randomPolicy } from '../bots/policy'; import { marble, type Level, type MarbleOptions, type MarbleState } from './index'; const isBot = (p: string) => p.startsWith('bot'); /** 2-player game, 'bot' to move. */ function make(opts: Partial = {}): MarbleState { const s = marble.setup({ players: ['bot', 'H'], options: marble.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); s.order = ['bot', 'H']; s.current = 0; return s; } const own = (s: MarbleState, pos: number, owner: string, level: Level = 0) => (s.owners[pos] = { owner, level }); /** Bot standing on `pos` deciding whether to buy it. */ function offered(pos: number, cash: number): MarbleState { const s = make(); Object.assign(s.players.bot!, { pos, cash }); Object.assign(s, { phase: 'decide', decision: { pos, canBuy: true, canBuild: null, canTakeover: null, boughtThisVisit: false } }); return s; } const decide = (s: MarbleState, who = 'bot') => decideBotAction(marble, s, who, { now: 1, random: () => 0.5 }); describe('marble bot', () => { test('buys a city with cash to spare, passes when it would be left without a reserve against big tolls', () => { expect(decide(offered(11, 300))).toEqual({ type: 'BUY' }); const s = offered(11, 60); own(s, 36, 'H', 3); // 뉴욕 hotel: toll 88 expect(decide(s)).toEqual({ type: 'PASS' }); }); test('covers a debt with the smallest sale that is enough', () => { const s = make(); own(s, 39, 'bot', 2); // 서울 빌딩: top sale 20, whole land 50 own(s, 1, 'bot', 0); // 타이베이 land: 3 own(s, 6, 'bot', 1); // 방콕 별장: top sale 2 Object.assign(s.players.bot!, { cash: 0 }); Object.assign(s, { phase: 'debt', debt: { debtor: 'bot', amount: 15, to: { kind: 'bank' }, reason: 'card', queue: [], then: { k: 'land' } } }); expect(decide(s)).toEqual({ type: 'SELL', pos: 39, what: 'top' }); }); test('accepts a generous trade and declines a bad one', () => { const s = make({ trade: true }); own(s, 39, 'bot', 0); own(s, 1, 'H', 0); Object.assign(s, { phase: 'tradeResponse', pendingTrade: { from: 'H', to: 'bot', give: { lands: [1], cash: 0 }, take: { lands: [39], cash: 0 } } }); expect(decide(s)).toEqual({ type: 'TRADE_RESPOND', accept: false }); s.pendingTrade = { from: 'H', to: 'bot', give: { lands: [], cash: 100 }, take: { lands: [1], cash: 0 } }; own(s, 1, 'bot', 0); expect(decide(s)).toEqual({ type: 'TRADE_RESPOND', accept: true }); }); test('space travel avoids an enemy hotel and goes for something useful', () => { const s = make(); own(s, 39, 'H', 3); Object.assign(s.players.bot!, { pos: 30, spacePending: true, cash: 300 }); const a = decide(s) as { type: string; dest: number }; expect(a.type).toBe('SPACE_TRAVEL'); expect(a.dest).not.toBe(39); expect([0, 10, 38]).not.toContain(a.dest); }); test('beats a random player clearly in 1-on-1', () => { let wins = 0; const games = 16; for (let g = 1; g <= games; g++) { const rng = SeededRng.fromSeed(seedN(g)); const chooser = SeededRng.fromSeed(seedN(g + 300)); const random = () => chooser.next(); let s = marble.setup({ players: ['bot', 'rnd'], options: marble.defaultOptions, rng, now: 0 }); for (let i = 0; i < 5000 && !marble.result(s); i++) { const p = marble.activePlayers(s)[0]!; const a = p === 'bot' ? decideBotAction(marble, s, p, { now: 1, random }) : randomPolicy({ me: p, view: null, legal: marble.legalActions!(s, p), random }); s = marble.apply(s, p, a as never, { rng, now: 1 }).state; } if (marble.result(s)?.ranking[0]!.includes('bot')) wins++; } expect(wins).toBeGreaterThanOrEqual(10); }); for (const opts of [ { trade: true, takeover: true, landmark: true, buildOnPurchase: true }, { monopolyWin: true, islandFee: 20 as const, islandTurns: 1 as const }, { maxRounds: 0 as const, startMoney: 200 as const, salary: 10 as const }, ]) { test(`option mode ${JSON.stringify(opts)}: games with a timing-out human finish`, () => { const options = marble.optionsSchema.parse(opts); for (let g = 1; g <= 3; g++) { const r = botPlayout(marble, { players: ['human', 'bot:1', 'bot:2', 'bot:3'], options, seed: seedN(g), isBot, maxSteps: 30_000 }); expect(r.finished).toBe(true); expect(r.botTimeouts).toBe(0); expect(r.maxBotMs).toBeLessThan(100); } }); } });