// Dominion — AI decisions (pure). Big Money with light engine sense, scaled by // per-opponent skill (1–5). The scene drives an AI turn step by step: // • repeatedly call chooseAction() until it returns null → play that action // • play all treasures, then repeatedly call chooseBuy() → buy that card // • whenever state.pending targets this AI, call resolvePending() // Every function is read-only on state and returns a plain choice. import { getCard, isType, } from './DominionCards.js'; import { legalActionIids, allCards, handCoinValue, affordableSupply, canGain, } from './DominionLogic.js'; function countOwned(state, seat, id) { return allCards(state, seat).filter((c) => c.id === id).length; } // ── Action phase ────────────────────────────────────────────────────────────── // Higher = play first among non-cantrip terminals. const TERMINAL_PRIORITY = { witch: 95, councilroom: 90, smithy: 85, militia: 80, library: 75, bandit: 70, bureaucrat: 65, mine: 60, remodel: 55, moneylender: 50, workshop: 45, throneroom: 40, chapel: 35, cellar: 30, artisan: 25, vassal: 20, }; export function chooseAction(state, seat) { const legal = legalActionIids(state); if (legal.length === 0) return null; const p = state.players[seat]; const byIid = new Map(p.hand.map((c) => [c.iid, c])); // Play cantrips / villages first (they replace the action they cost). const cantrips = legal.filter((iid) => getCard(byIid.get(iid).id).plus.actions >= 1); if (cantrips.length > 0) { // Prefer the one drawing the most cards, then the cheapest, to keep the chain alive. cantrips.sort((a, b) => { const ca = getCard(byIid.get(a).id), cb = getCard(byIid.get(b).id); return (cb.plus.cards - ca.plus.cards) || (ca.cost - cb.cost); }); return cantrips[0]; } // Only terminals remain (one action left). Throne Room needs another action to be worth it. const terminals = legal.slice(); terminals.sort((a, b) => { const ida = byIid.get(a).id, idb = byIid.get(b).id; return (TERMINAL_PRIORITY[idb] ?? 0) - (TERMINAL_PRIORITY[ida] ?? 0); }); const best = terminals[0]; if (byIid.get(best).id === 'throneroom') { const hasOther = p.hand.some((c) => c.iid !== best && isType(c.id, 'action')); if (!hasOther) { // Skip Throne Room; play the next-best terminal instead, if any. const alt = terminals.find((iid) => byIid.get(iid).id !== 'throneroom'); return alt ?? best; } } return best; } // ── Buy phase ───────────────────────────────────────────────────────────────── // Engine/attack cards worth buying, best first, with an ownership cap each. const ENGINE_BUYS = [ { id: 'witch', cap: 1 }, { id: 'laboratory', cap: 4 }, { id: 'market', cap: 4 }, { id: 'festival', cap: 3 }, { id: 'village', cap: 4 }, { id: 'smithy', cap: 2 }, { id: 'councilroom', cap: 2 }, { id: 'mine', cap: 1 }, { id: 'militia', cap: 1 }, { id: 'moneylender', cap: 1 }, { id: 'poacher', cap: 2 }, { id: 'merchant', cap: 2 }, { id: 'sentry', cap: 2 }, { id: 'harbinger', cap: 1 }, { id: 'remodel', cap: 1 }, { id: 'workshop', cap: 1 }, ]; export function chooseBuy(state, seat, skill = 3) { const p = state.players[seat]; const coins = p.coins; if (p.buys <= 0) return null; const provincesLeft = state.supply.province ?? 0; if (coins >= 8 && provincesLeft > 0) return 'province'; // Late-game greening — thresholds widen with skill (better players green sooner). const greenAt = 3 + Math.round(skill / 2); if (provincesLeft <= greenAt) { if (coins >= 5 && (state.supply.duchy ?? 0) > 0) return 'duchy'; if (coins >= 2 && provincesLeft <= 2 && (state.supply.estate ?? 0) > 0) return 'estate'; } // Engine building (mid coins; never skip Gold/Province). if (skill >= 3 && coins <= 5) { for (const { id, cap } of ENGINE_BUYS) { if (!state.kingdom.includes(id)) continue; if (getCard(id).cost > coins) continue; if ((state.supply[id] ?? 0) <= 0) continue; if (countOwned(state, seat, id) >= cap) continue; // Keep terminals roughly balanced against villages at higher skill. return id; } } if (coins >= 6 && (state.supply.gold ?? 0) > 0) return 'gold'; if (coins >= 3 && (state.supply.silver ?? 0) > 0) return 'silver'; return null; } // ── Pending decisions ────────────────────────────────────────────────────────── // Disposability: higher = discard / get rid of first. function discardRank(id) { if (id === 'curse') return 100; if (isType(id, 'victory')) return 90; if (id === 'copper') return 50; if (isType(id, 'action')) return 30; if (id === 'silver') return 10; if (id === 'gold') return 5; return 20; } function trashRank(id, copperKeep) { if (id === 'curse') return 100; if (id === 'estate') return 80; if (id === 'copper') return copperKeep ? 0 : 60; return 0; // never auto-trash anything else } function bestGain(state, seat, maxCost, filterTreasure) { const options = affordableSupply(state, maxCost, filterTreasure); if (options.length === 0) return null; // Gain value ordering. const rank = (id) => { if (id === 'province') return 1000; if (id === 'gold') return 900; if (id === 'duchy' && (state.supply.province ?? 0) <= 4) return 850; const def = getCard(id); if (isType(id, 'action')) return 400 + def.cost * 10; if (id === 'silver') return 300; if (isType(id, 'treasure')) return 200 + def.cost; return def.cost; // victory/curse low unless flagged above }; options.sort((a, b) => rank(b) - rank(a)); return options[0]; } export function resolvePending(state, skill = 3) { const pend = state.pending; if (!pend) return {}; const seat = pend.seat; const p = state.players[seat]; switch (pend.kind) { case 'cellarDiscard': { // Dump dead cards (victory/curse) to dig for fresh ones. const iids = p.hand.filter((c) => isType(c.id, 'victory') || c.id === 'curse').map((c) => c.iid); return { iids }; } case 'chapelTrash': { const copperCount = countOwned(state, seat, 'copper'); const keepCoppers = copperCount <= 3; const ranked = p.hand .map((c) => ({ c, r: trashRank(c.id, keepCoppers) })) .filter((x) => x.r > 0) .sort((a, b) => b.r - a.r) .slice(0, pend.max ?? 4); return { iids: ranked.map((x) => x.c.iid) }; } case 'harbingerTopdeck': { // Topdeck the strongest non-dead card from the discard. const cand = p.discard .filter((c) => !isType(c.id, 'victory') && c.id !== 'curse' && c.id !== 'copper') .sort((a, b) => getCard(b.id).cost - getCard(a.id).cost); return { iid: cand[0]?.iid ?? null }; } case 'vassalPlay': return { play: true }; case 'gainFromSupply': { const id = bestGain(state, seat, pend.maxCost, pend.filterTreasure); return { id }; } case 'artisanTopdeck': { // Prefer topdecking an action we'll replay; else the most disposable card. const action = p.hand.filter((c) => isType(c.id, 'action')) .sort((a, b) => getCard(b.id).cost - getCard(a.id).cost)[0]; if (action) return { iid: action.iid }; const worst = p.hand.slice().sort((a, b) => discardRank(b.id) - discardRank(a.id))[0]; return { iid: worst?.iid ?? null }; } case 'remodelTrash': { const greening = (state.supply.province ?? 0) <= 4; if (greening) { const gold = p.hand.find((c) => c.id === 'gold'); if (gold && canGain(state, 'province', getCard('gold').cost + 2)) return { iid: gold.iid }; } const estate = p.hand.find((c) => c.id === 'estate'); if (estate) return { iid: estate.iid }; const copper = p.hand.find((c) => c.id === 'copper'); if (copper) return { iid: copper.iid }; // Fall back to the cheapest card. const cheapest = p.hand.slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost)[0]; return { iid: cheapest?.iid ?? null }; } case 'mineTrash': { const silver = p.hand.find((c) => c.id === 'silver'); if (silver) return { iid: silver.iid }; const copper = p.hand.find((c) => c.id === 'copper'); if (copper) return { iid: copper.iid }; return { iid: null }; } case 'moneylenderTrash': return { confirm: p.hand.some((c) => c.id === 'copper') }; case 'throneChoose': { const actions = p.hand.filter((c) => isType(c.id, 'action') && c.id !== 'throneroom') .sort((a, b) => (TERMINAL_PRIORITY[b.id] ?? getCard(b.id).cost) - (TERMINAL_PRIORITY[a.id] ?? getCard(a.id).cost)); return { iid: actions[0]?.iid ?? null }; } case 'libraryKeep': // Keep the action if we still have actions to play it; otherwise set aside. return { keep: p.actions > 0 }; case 'sentry': { const look = pend.cards ?? []; const trash = [], discard = [], top = []; for (const c of look) { if (c.id === 'curse' || c.id === 'estate' || (c.id === 'copper' && countOwned(state, seat, 'copper') > 3)) trash.push(c.iid); else if (isType(c.id, 'victory')) discard.push(c.iid); else top.push(c.iid); } return { trash, discard, top }; } case 'poacherDiscard': { const ranked = p.hand.slice().sort((a, b) => discardRank(b.id) - discardRank(a.id)); return { iids: ranked.slice(0, pend.count).map((c) => c.iid) }; } case 'discardDownTo': { const ranked = p.hand.slice().sort((a, b) => discardRank(b.id) - discardRank(a.id)); return { iids: ranked.slice(0, pend.count).map((c) => c.iid) }; } case 'moatReveal': return { reveal: true }; case 'banditTrash': { // Keep the more valuable treasure; trash the cheaper of the options. const opts = (pend.options ?? []).slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost); return { iid: opts[0]?.iid ?? null }; } case 'bureaucratTopdeck': { // Topdeck the cheapest victory card to lose the least. const opts = (pend.options ?? []).slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost); return { iid: opts[0]?.iid ?? null }; } default: return {}; } }