- 보석 상인 → 스플렌더, 벽 쌓기 길찾기 → 쿼리도, 숫자 타일 → 루미큐브, 주사위 땅따먹기 → 부루마불 - deploy/docker-compose.proxy.yml: Caddy 없이 앱만(.5:2970), HTTPS·헤더는 NPMplus - deploy/npmplus-advanced.conf: Caddyfile과 같은 보안 헤더 - 문서: 2026-10-06 결정, 배포 7절 (e2e 구십구 0 카드 방향 버튼 반영 포함) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
4.7 KiB
TypeScript
92 lines
4.7 KiB
TypeScript
/**
|
|
* 스플렌더 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);
|