fertig-classic-games/public/src/games/dominion/DominionAI.js

254 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Dominion — AI decisions (pure). Big Money with light engine sense, scaled by
// per-opponent skill (15). 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 {};
}
}