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