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>
This commit is contained in:
95
packages/games/src/splendor/bot.test.ts
Normal file
95
packages/games/src/splendor/bot.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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 { emptyTokens, splendor, type SplendorOptions, type SplendorState, type Tokens } from './index';
|
||||
|
||||
const tk = (t: Partial<Tokens>): Tokens => ({ ...emptyTokens(), ...t });
|
||||
|
||||
/** Two players, A to move, a fixed board row 1 / 2 and the given nobles. */
|
||||
function mk(opts: Partial<SplendorOptions> = {}): SplendorState {
|
||||
const s = splendor.setup({ players: ['A', 'B'], options: splendor.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(1)), now: 0 });
|
||||
s.order = ['A', 'B'];
|
||||
s.current = 0;
|
||||
s.board[1] = ['1W01', '1U07', '1G08', '1K05'];
|
||||
s.board[2] = ['2W06', '2U05', '2G01', '2K05'];
|
||||
s.board[3] = ['3W02', '3U02', '3G02', '3K02'];
|
||||
s.nobles = ['N01', 'N02', 'N03'];
|
||||
return s;
|
||||
}
|
||||
const decide = (s: SplendorState, p = 'A', seed = 1) => {
|
||||
const r = SeededRng.fromSeed(seedN(seed));
|
||||
return decideBotAction(splendor, s, p, { now: 1, random: () => r.next() });
|
||||
};
|
||||
|
||||
describe('splendor bot', () => {
|
||||
test('buys the affordable card with the most points', () => {
|
||||
const s = mk();
|
||||
// Affordable: 1U07 (3 K, 0 pts) and 2U05 (5 U, 2 pts); 1G08 (4 K, 1 pt) is not.
|
||||
s.players.A!.tokens = tk({ K: 3, R: 4, U: 5 });
|
||||
s.supply = tk({ W: 4, U: 0, G: 4, R: 0, K: 1, gold: 5 });
|
||||
expect(decide(s)).toEqual({ type: 'BUY', from: { tier: 2, slot: 1 } });
|
||||
});
|
||||
|
||||
test('without a buy, takes the colours its target card is missing', () => {
|
||||
const s = mk();
|
||||
s.board[1] = ['1W01', '1R07', '1K05', '1R05'];
|
||||
// 1W01 costs U1 G1 R1 K1; holding R and K, this closest goal needs U and G.
|
||||
s.players.A!.tokens = tk({ R: 1, K: 1 });
|
||||
s.supply = tk({ W: 4, U: 4, G: 4, R: 3, K: 3, gold: 5 });
|
||||
const a = decide(s) as { type: string; colors: string[] };
|
||||
expect(a.type).toBe('TAKE_DIFFERENT');
|
||||
expect(a.colors).toContain('U');
|
||||
expect(a.colors).toContain('G');
|
||||
});
|
||||
|
||||
test('gives back tokens it does not need for its target, never gold first', () => {
|
||||
const s = mk();
|
||||
s.phase = 'discard';
|
||||
// Target 1W01 needs U G R K; 4 W are useless.
|
||||
s.players.A!.tokens = tk({ W: 4, U: 1, G: 1, R: 1, K: 1, gold: 3 });
|
||||
const a = decide(s) as { type: string; tokens: Tokens };
|
||||
expect(a.type).toBe('DISCARD');
|
||||
expect(a.tokens).toEqual(tk({ W: 1 }));
|
||||
});
|
||||
|
||||
test('option modes finish without bot timeouts (10 points, strict 3 colours, 4 players)', () => {
|
||||
const modes: Partial<SplendorOptions>[] = [{ targetPoints: 10 }, { allowFewerTokens: false, turnSeconds: null }, {}];
|
||||
for (const [i, m] of modes.entries()) {
|
||||
for (const n of [2, 4]) {
|
||||
const r = botPlayout(splendor as never, {
|
||||
players: Array.from({ length: n }, (_, k) => `bot:${k}`),
|
||||
options: splendor.optionsSchema.parse(m),
|
||||
seed: seedN(30 + i * 5 + n),
|
||||
isBot: () => true,
|
||||
});
|
||||
expect(r.finished).toBe(true);
|
||||
expect(r.botTimeouts).toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('beats a random player clearly, reaching the target', () => {
|
||||
let wins = 0;
|
||||
let target = 0;
|
||||
for (let g = 0; g < 15; g++) {
|
||||
const rng = SeededRng.fromSeed(seedN(300 + g));
|
||||
const ch = SeededRng.fromSeed(seedN(700 + g));
|
||||
const random = () => ch.next();
|
||||
let s = splendor.setup({ players: ['bot', 'rnd'], options: splendor.defaultOptions, rng, now: 0 });
|
||||
for (let step = 0; step < 3000 && !splendor.result(s); step++) {
|
||||
const p = splendor.activePlayers(s)[0]!;
|
||||
const a =
|
||||
p === 'bot'
|
||||
? decideBotAction(splendor, s, p, { now: 0, random })
|
||||
: randomPolicy({ me: p, view: splendor.view(s, p), legal: splendor.legalActions!(s, p), random });
|
||||
s = splendor.apply(s, p, a as never, { rng, now: 0 }).state;
|
||||
}
|
||||
if (splendor.result(s)?.ranking[0]?.[0] === 'bot') wins++;
|
||||
if (s.endReason === 'target') target++;
|
||||
}
|
||||
expect(wins).toBeGreaterThanOrEqual(13);
|
||||
expect(target).toBeGreaterThanOrEqual(13);
|
||||
});
|
||||
});
|
||||
91
packages/games/src/splendor/bot.ts
Normal file
91
packages/games/src/splendor/bot.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 보석 상인 computer player: buys the best affordable card (points first, then bonuses that nobles and
|
||||
* wanted cards need); otherwise picks a target card (value per missing gem) and takes gems toward it,
|
||||
* reserving it now and then. Uses only its own view (board, nobles, its tokens / bonuses / reservations).
|
||||
*/
|
||||
import type { BotStrategy } from '../bots/types';
|
||||
import { best, pick, randomPolicy } from '../bots/policy';
|
||||
import { COLORS, TOKEN_COLORS, emptyTokens, needFor, type BuyFrom, type Color, type DevCard, type SplendorAction, type SplendorView, type Tokens } from './index';
|
||||
|
||||
interface Spot {
|
||||
card: DevCard;
|
||||
from: BuyFrom;
|
||||
}
|
||||
|
||||
export const splendorBot: BotStrategy<SplendorView, SplendorAction> = (input) => {
|
||||
const { me, view: v, legal, random } = input;
|
||||
const my = v.players[me];
|
||||
const mv = v.me;
|
||||
if (!my || !mv) return randomPolicy(input);
|
||||
const tokens = my.tokens;
|
||||
const bonuses = my.bonuses;
|
||||
|
||||
// How much a card's bonus helps: open nobles still missing that colour (+ a little engine value early on).
|
||||
const bonusUse = (c: Color) => v.nobles.reduce((a, n) => a + (n.req[c] > bonuses[c] ? 1 : 0), 0) + (my.points < 8 ? 0.5 : 0);
|
||||
const value = (card: DevCard) => card.points * 2 + bonusUse(card.bonus) + 1.5;
|
||||
/** Gems still missing for a card after bonuses, tokens and gold. */
|
||||
const short = (card: DevCard): Record<Color, number> => {
|
||||
const need = needFor(card, bonuses);
|
||||
const out = {} as Record<Color, number>;
|
||||
for (const c of COLORS) out[c] = Math.max(0, need[c] - tokens[c]);
|
||||
return out;
|
||||
};
|
||||
const deficit = (card: DevCard) => Math.max(0, COLORS.reduce((a, c) => a + short(card)[c], 0) - tokens.gold);
|
||||
|
||||
const spots: Spot[] = [];
|
||||
for (const tier of [1, 2, 3] as const) v.board[tier].forEach((card, slot) => card && spots.push({ card, from: { tier, slot } }));
|
||||
my.reserved.forEach((r, i) => !r.hidden && spots.push({ card: r, from: { reservedIndex: i } }));
|
||||
const ranked = spots.filter((s) => deficit(s.card) > 0).sort((a, b) => value(b.card) / (deficit(b.card) + 1) ** 2 - value(a.card) / (deficit(a.card) + 1) ** 2);
|
||||
const target = ranked[0];
|
||||
|
||||
if (v.phase === 'noble') return pick(legal, random);
|
||||
|
||||
if (v.phase === 'discard') {
|
||||
// Give back, one by one, the token least needed for the target (never gold while colours are left).
|
||||
const keep = target ? needFor(target.card, bonuses) : null;
|
||||
const left: Tokens = { ...tokens };
|
||||
const out = emptyTokens();
|
||||
for (let k = 0; k < mv.discardCount; k++) {
|
||||
const held = TOKEN_COLORS.filter((c) => left[c] > 0);
|
||||
const c = best(held, (c) => (c === 'gold' ? -100 : left[c] - (keep ? keep[c as Color] * 2 : 0)), random);
|
||||
left[c]--;
|
||||
out[c]++;
|
||||
}
|
||||
return { type: 'DISCARD', tokens: out };
|
||||
}
|
||||
|
||||
// Buy: the best card I can afford.
|
||||
const buys = Object.keys(mv.buy).map((key): Spot => {
|
||||
const from: BuyFrom = key[0] === 'r' ? { reservedIndex: Number(key.slice(1)) } : { tier: Number(key[1]) as 1 | 2 | 3, slot: Number(key.slice(3)) };
|
||||
const card = 'reservedIndex' in from ? (my.reserved[from.reservedIndex] as DevCard) : v.board[from.tier][from.slot]!;
|
||||
return { card, from };
|
||||
});
|
||||
if (buys.length) {
|
||||
const b = best(buys, (s) => s.card.points * 10 + bonusUse(s.card.bonus) * 2 - mv.buy[key(s.from)]!.total * 0.3 + ('reservedIndex' in s.from ? 2 : 0), random);
|
||||
return { type: 'BUY', from: b.from };
|
||||
}
|
||||
|
||||
// Now and then reserve a strong target (also gets a gold), or when my hand of tokens is nearly full.
|
||||
const t = target;
|
||||
if (t && mv.canReserve && 'tier' in t.from && (tokenTotal(tokens) >= 8 || (t.card.points >= 3 && random() < 0.15))) {
|
||||
return { type: 'RESERVE', from: t.from };
|
||||
}
|
||||
|
||||
// Take gems toward the target, then toward the next-best cards.
|
||||
const want = (c: Color) => ranked.slice(0, 3).reduce((a, s, i) => a + short(s.card)[c] * (i === 0 ? 10 : 3 - i), 0);
|
||||
const shortT = t ? short(t.card) : null;
|
||||
const same = mv.takeSame.filter((c) => shortT && shortT[c] >= 2);
|
||||
const diff = mv.takeDifferent;
|
||||
if (diff) {
|
||||
const colors = [...diff.available].sort((a, b) => want(b) - want(a) + (random() - 0.5) * 0.1).slice(0, diff.count);
|
||||
const useful = colors.filter((c) => shortT && shortT[c] > 0).length;
|
||||
if (same.length && useful <= 1) return { type: 'TAKE_SAME', color: same[0]! };
|
||||
return { type: 'TAKE_DIFFERENT', colors };
|
||||
}
|
||||
if (mv.takeSame.length) return { type: 'TAKE_SAME', color: best(mv.takeSame, want, random) };
|
||||
if (t && mv.canReserve && 'tier' in t.from) return { type: 'RESERVE', from: t.from };
|
||||
return randomPolicy(input);
|
||||
};
|
||||
|
||||
const key = (from: BuyFrom) => ('reservedIndex' in from ? `r${from.reservedIndex}` : `b${from.tier}-${from.slot}`);
|
||||
const tokenTotal = (t: Tokens) => TOKEN_COLORS.reduce((a, c) => a + t[c], 0);
|
||||
Reference in New Issue
Block a user