Files
joke-app/packages/games/src/marble/bot.test.ts
EJClaw a3f81ded37 M9: 컴퓨터 상대 — 게임 22종 컴퓨터 전략, 서버 구동(BotDriver), 연습 방
- 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>
2026-10-06 13:32:28 +09:00

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);
}
});
}
});