/** * 스플렌더 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 = (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 => { const need = needFor(card, bonuses); const out = {} as Record; 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);