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:
EJClaw
2026-10-06 13:32:28 +09:00
parent c27cb0a11a
commit a3f81ded37
67 changed files with 5113 additions and 18 deletions

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

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