- packages/shared/src/bots.ts: 컴퓨터 id(bot:<n>:<ulid>)·프로필(컴퓨터 n)·BOT_GAMES
(도블·할리갈리·그림 맞히기·마피아 제외)
- packages/games/src/bots: 전략 계약(자기 view + 합법 수만), decideBotAction(validate 재검사),
게임별 <게임>/bot.ts 22개 + 행동 테스트, 전 게임 판 끝까지 돌리는 공통 테스트
- 서버: Room.addBot/removeBot(방장·대기실만, 방은 친구만), RoomManager.createPractice
(POST /api/rooms {practice:true}), BotDriver(사람 같은 지연, 사람과 같은 창이면 더 천천히,
재시작 후 다시 예약), 연습 게임은 전적 제외(result_json.practice), 사람이 모두 나가면 무효
- 프로토콜 addBot/removeBot, 문서 03·06 §12·08·09 §8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
4.6 KiB
TypeScript
100 lines
4.6 KiB
TypeScript
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<MarbleOptions> = {}): 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);
|
|
}
|
|
});
|
|
}
|
|
});
|