254 lines
10 KiB
JavaScript
254 lines
10 KiB
JavaScript
// 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 {};
|
||
}
|
||
}
|