fertig-classic-games/src/games/swdbg/SWDBGAI.js

357 lines
15 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.

// SWDBGAI.js — synchronous heuristics over the engine's public view (opponent
// hand/decks are stubs; the AI never sees hidden information). One handler per
// decision kind, routed through decide(). Skill 1–5 follows the house shape:
// widening top-N with score noise and a blunder chance at low skill.
import { cardDef, baseDef, getData } from './SWDBGData.js';
import { legalActions, entryAttack, forceWith, purchaseCost } from './SWDBGLogic.js';
const SKILL_PROFILES = {
1: { topN: 4, blunder: 0.35, noise: 3.0, actChance: 0.35, bountyChance: 0.25, delay: [650, 1100] },
2: { topN: 3, blunder: 0.20, noise: 2.0, actChance: 0.55, bountyChance: 0.50, delay: [600, 1000] },
3: { topN: 3, blunder: 0.08, noise: 1.2, actChance: 0.80, bountyChance: 0.75, delay: [550, 950] },
4: { topN: 2, blunder: 0.02, noise: 0.6, actChance: 0.95, bountyChance: 0.90, delay: [480, 860] },
5: { topN: 1, blunder: 0.00, noise: 0.0, actChance: 1.00, bountyChance: 1.00, delay: [420, 800] },
};
function profileFor(skill) {
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
}
export function nextThinkDelay(skill) {
const [lo, hi] = profileFor(skill).delay;
return lo + Math.random() * (hi - lo);
}
function pickScored(cands, skill, rnd) {
if (!cands.length) return null;
const prof = profileFor(skill);
if (rnd() < prof.blunder) return cands[Math.floor(rnd() * cands.length)].choice;
const jittered = cands.map((c) => ({ choice: c.choice, s: c.s + (rnd() * 2 - 1) * prof.noise }));
jittered.sort((a, b) => b.s - a.s);
return jittered[Math.floor(rnd() * Math.min(prof.topN, jittered.length))].choice;
}
const me = (view, seat) => view.players[seat];
const foe = (view, seat) => view.players[1 - seat];
// Deck-building quality of a card — used for buys, discards, and exiles.
export function cardValue(def) {
let v = (def.attack || 0) + (def.resources || 0) + (def.force || 0) * 0.7;
if (def.ability) v += 1.2;
if (def.type === 'capital') v += (def.hp || 0) * 0.25 + (def.resources || 0) * 1.3;
if (def.unique) v += 0.5;
return v;
}
function worstHandCard(view, seat, { exclude = null } = {}) {
const hand = me(view, seat).hand.filter((c) => c.uid !== exclude);
if (!hand.length) return null;
return hand.slice().sort((a, b) => cardValue(cardDef(a)) - cardValue(cardDef(b)))[0];
}
// ── turn ─────────────────────────────────────────────────────────────────────
function chooseTurn(view, seat, skill, rnd) {
const prof = profileFor(skill);
const p = me(view, seat);
const o = foe(view, seat);
const legal = legalActions(view, seat);
// 1. Play out the hand. Hold one cheap card back as Jabba/exile-cost fodder
// when an unused exileFromHand ability is on the table.
if (legal.plays.length) {
const needsFodder = [...p.inPlay, ...p.capitals].some((e) => {
const ab = cardDef(e.card).ability;
return ab?.timing === 'action' && (ab.cost?.exileFromHand || ab.cost?.discardFromHand) && !e.used;
});
let playable = p.hand;
if (needsFodder && p.hand.length === 1) playable = [];
if (playable.length) {
// Worst first, so kept fodder is the cheapest card.
const pick = playable.slice().sort((a, b) => cardValue(cardDef(b)) - cardValue(cardDef(a)))[0];
return { type: 'play', uid: pick.uid };
}
}
// 2. Abilities (skill-gated).
if (legal.abilities.length && rnd() < prof.actChance) {
const a = legal.abilities[0];
const action = { type: 'ability', zone: a.zone, uid: a.uid };
// Supply the exile cost card when needed.
const def = a.zone === 'base' ? baseDef(p.faction, p.base.id) : cardDef(findInPlay(p, a.uid).card);
if (def.ability?.cost?.exileFromHand || def.ability?.cost?.discardFromHand) {
const fodder = worstHandCard(view, seat);
if (!fodder) return nextAfterAbilities(view, seat, skill, rnd, legal);
action.costUid = fodder.uid;
}
return action;
}
return nextAfterAbilities(view, seat, skill, rnd, legal);
}
function findInPlay(p, uid) {
return p.inPlay.find((e) => e.card.uid === uid) || p.capitals.find((e) => e.card.uid === uid);
}
function nextAfterAbilities(view, seat, skill, rnd, legal) {
const prof = profileFor(skill);
const p = me(view, seat);
const o = foe(view, seat);
// 3. Bounty hunting / sabotage — spend the smallest sufficient attacker set
// when the reward + denial beats the base damage forfeited.
if (legal.rowTargets.length && rnd() < prof.bountyChance) {
const options = [];
for (const t of legal.rowTargets) {
const target = view.galaxy.row.find((c) => c.uid === t.uid);
const tdef = cardDef(target);
const picked = pickBountySquad(view, seat, tdef.target);
if (!picked) continue;
const r = tdef.reward || {};
const rewardV = (r.resources || 0) + (r.force || 0) * 1.2 + (r.draw || 0) * 1.8
+ (r.exile || 0) * 0.8 + (r.freePurchase ? 2.5 : 0);
const denialV = cardValue(tdef) * 0.45; // enemy can no longer buy it
const spent = picked.total * 0.7; // base damage forfeited
const s = rewardV + denialV - spent;
if (s > -0.5) options.push({ choice: { type: 'attackRow', targetUid: t.uid, uids: picked.uids }, s });
}
const pick = pickScored(options, skill, rnd);
if (pick) return pick;
}
// 4. Buys, best value per resource.
if (legal.buys.length) {
const options = [{ choice: null, s: 0.4 }]; // skipping junk is allowed
for (const b of legal.buys) {
const def = b.uid === 'outerrim' ? getData().json.outerRimPilot
: cardDef(view.galaxy.row.find((c) => c.uid === b.uid));
let s = cardValue(def) - b.cost * 0.45;
if (def.faction === p.faction) s += 0.5; // faction synergy & denial-proofing
if (def.type === 'capital' && o.lostBases >= 2) s -= 1; // late capitals rarely pay off
if (view.turnCount <= 6 && def.cost <= 2 && def.ability) s += 0.4; // early thinning/tempo
options.push({ choice: { type: 'buy', uid: b.uid }, s });
}
const pick = pickScored(options, skill, rnd);
if (pick) return pick;
}
// 5. Attack the base with everything still uncommitted.
if (legal.baseAttackers.length) {
const total = legal.baseAttackers.reduce((a, uid) => a + entryAttack(view, seat, findInPlay(p, uid)), 0);
if (total > 0) {
const order = o.capitals.slice()
.sort((a, b) => (cardDef(a.card).hp - a.damage) - (cardDef(b.card).hp - b.damage))
.map((e) => e.card.uid);
return { type: 'attackBase', uids: legal.baseAttackers, capitalOrder: order };
}
}
return { type: 'endTurn' };
}
// Smallest set of free units whose row attack meets the target.
function pickBountySquad(view, seat, target) {
const p = me(view, seat);
const free = p.inPlay.filter((e) => !e.committed)
.map((e) => ({ uid: e.card.uid, atk: entryAttack(view, seat, e, { vsRow: true }) }))
.filter((e) => e.atk > 0)
.sort((a, b) => a.atk - b.atk);
const total = free.reduce((a, e) => a + e.atk, 0);
if (total < target) return null;
// Greedy from the top down, then trim from the bottom.
const picked = [];
let sum = 0;
for (let i = free.length - 1; i >= 0 && sum < target; i--) { picked.push(free[i]); sum += free[i].atk; }
for (let i = picked.length - 1; i >= 0; i--) {
if (sum - picked[i].atk >= target) { sum -= picked[i].atk; picked.splice(i, 1); }
}
return { uids: picked.map((e) => e.uid), total: sum };
}
// ── other decisions ──────────────────────────────────────────────────────────
function chooseBase(view, seat, skill, rnd, decision) {
const p = me(view, seat);
const options = decision.options.map((id) => {
const b = baseDef(p.faction, id);
let s = b.hp * 0.5;
const ab = b.ability;
if (ab) {
if (ab.op === 'draw') s += 2.5;
if (ab.op === 'gainResources') s += 2;
if (ab.op === 'capitalDiscount') s += view.turnCount <= 8 ? 2 : 0.5;
if (ab.op === 'onPlayTraitAttack') s += 1.5;
if (ab.op === 'damageBase') s += 2.5;
if (ab.op === 'exileCards') s += view.turnCount <= 8 ? 1.5 : 0.3;
}
// Behind on bases? Prefer raw hit points.
if (p.lostBases > foe(view, seat).lostBases) s += b.hp * 0.25;
return { choice: id, s };
});
return pickScored(options, skill, rnd);
}
function chooseTarget(view, seat, skill, rnd, decision) {
const p = me(view, seat);
const cands = decision.candidates || [];
const scored = [];
const skipOk = decision.optional;
switch (decision.op) {
case 'discardRow':
for (const c of cands) {
const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid));
const enemy = def.faction !== 'neutral' && def.faction !== p.faction;
scored.push({ choice: c, s: enemy ? cardValue(def) : cardValue(def) * 0.2 - 1 });
}
if (skipOk) scored.push({ choice: null, s: 1.5 });
break;
case 'destroyCapital':
for (const c of cands) {
if (c.kind === 'capital') {
const e = foe(view, seat).capitals.find((x) => x.card.uid === c.uid);
scored.push({ choice: c, s: e ? cardValue(cardDef(e.card)) + 2 : 0 });
} else {
const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid));
scored.push({ choice: c, s: def.faction !== p.faction && def.faction !== 'neutral' ? cardValue(def) : 0.5 });
}
}
break;
case 'exileCards':
for (const c of cands) {
const zone = c.zone === 'hand' ? p.hand : p.discard;
const def = cardDef(zone.find((x) => x.uid === c.uid));
// Exile starters and junk; never exile good cards.
scored.push({ choice: c, s: 2.2 - cardValue(def) });
}
if (skipOk) scored.push({ choice: null, s: 0 });
break;
case 'recoverDiscard':
for (const c of cands) {
const def = cardDef(p.discard.find((x) => x.uid === c.uid));
scored.push({ choice: c, s: cardValue(def) });
}
break;
case 'freePurchase':
for (const c of cands) {
const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid));
scored.push({ choice: c, s: cardValue(def) });
}
break;
case 'lookTop': case 'revealTopDiscard': {
const def = decision.peekId ? cardDef(decision.peekId) : null;
const enemy = def && def.faction !== 'neutral' && def.faction !== p.faction;
const junk = def && cardValue(def) < 2;
const discard = enemy || junk;
return cands.find((c) => c.v === (discard ? 'discard' : 'keep')) || cands[0];
}
case 'dealDamage': {
const o = foe(view, seat);
for (const c of cands) {
if (c.kind === 'oppBase') { scored.push({ choice: c, s: 2 }); continue; }
const e = o.capitals.find((x) => x.card.uid === c.uid);
if (!e) continue;
const def = cardDef(e.card);
const kills = e.damage + 2 >= def.hp; // Y-Wing deals 2
scored.push({ choice: c, s: kills ? cardValue(def) + 2 : 1 });
}
break;
}
case 'buyGalaxyDiscard':
for (const c of cands) {
const def = cardDef(view.galaxy.discard.find((x) => x.uid === c.uid));
scored.push({ choice: c, s: cardValue(def) - def.cost * 0.35 });
}
if (skipOk) scored.push({ choice: null, s: 0.5 });
break;
case 'takeRowTemp':
// The card is played this turn and exiled after — raw tempo only.
for (const c of cands) {
const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid));
scored.push({ choice: c, s: (def.attack || 0) + (def.resources || 0) + (def.force || 0) });
}
if (skipOk) scored.push({ choice: null, s: 0.5 });
break;
case 'oppTopdeck':
// Strand the opponent's best card on top of their deck.
for (const c of cands) scored.push({ choice: c, s: cardValue(cardDef(c.id)) });
break;
case 'lookTopSwap': {
const top = decision.peekId ? cardDef(decision.peekId) : null;
for (const c of cands) {
if (c.kind === 'opt') { scored.push({ choice: c, s: 0.5 }); continue; }
if (!top) continue;
const row = cardDef(view.galaxy.row.find((x) => x.uid === c.uid));
const topMine = top.faction === p.faction;
const rowEnemy = row.faction !== 'neutral' && row.faction !== p.faction;
const rowMine = row.faction === p.faction;
let s = 0;
if (topMine) s += cardValue(top) * 0.7; // surface a card I can buy
if (rowEnemy) s += cardValue(row) * 0.4; // bury an enemy option
if (rowMine) s -= cardValue(row) * 0.8; // don't bury my own buys
scored.push({ choice: c, s });
}
break;
}
default:
return decision.optional ? null : cands[0] || null;
}
const pick = pickScored(scored, skill, rnd);
return pick === undefined ? null : pick;
}
function chooseOppDiscard(view, seat) {
const worst = worstHandCard(view, seat);
return worst ? worst.uid : null;
}
// Victim picks the least painful oppChoice option (Duros Spy, B-Wing).
function chooseOppChoice(view, seat, decision) {
let best = 0, bestPain = Infinity;
decision.options.forEach((option, i) => {
let pain;
switch (option.kind) {
case 'discardSelf': {
const worst = worstHandCard(view, seat);
pain = worst ? cardValue(cardDef(worst)) : Infinity;
break;
}
case 'giveForce': pain = (option.n || 1) * 1.2; break;
case 'attackBoost': pain = (option.n || 1) * 0.9; break;
default: pain = 1;
}
if (pain < bestPain) { bestPain = pain; best = i; }
});
return best;
}
function chooseOption(view, seat, skill, rnd, decision) {
const p = me(view, seat);
const o = foe(view, seat);
const scored = decision.options.map((ab, i) => {
let s = 1;
if (ab.op === 'draw') s = (ab.n || 1) * 1.4;
if (ab.op === 'oppDiscard') s = o.hand.length ? 1.6 : 0;
if (ab.op === 'gainResources') s = (ab.n || 1) * 1.0;
if (ab.op === 'gainAttack') s = (ab.n || 1) * 0.9;
if (ab.op === 'gainForce') s = (ab.n || 1) * 0.8;
if (ab.op === 'repairBase') s = Math.min(ab.n || 1, p.base ? p.base.damage : 0) * 0.7;
return { choice: i, s };
});
return pickScored(scored, skill, rnd) ?? 0;
}
// ── Router ──────────────────────────────────────────────────────────────────
// Returns the argument for the matching act* mutator:
// turn → action object · chooseBase → baseId · target → ref|null ·
// oppDiscard → uid · oppChoice → idx · chooseOption → idx
export function decide(view, decision, skill, rnd = Math.random) {
const seat = decision.seat;
switch (decision.kind) {
case 'turn': return chooseTurn(view, seat, skill, rnd);
case 'chooseBase': return chooseBase(view, seat, skill, rnd, decision);
case 'target': return chooseTarget(view, seat, skill, rnd, decision);
case 'oppDiscard': return chooseOppDiscard(view, seat);
case 'oppChoice': return chooseOppChoice(view, seat, decision);
case 'chooseOption': return chooseOption(view, seat, skill, rnd, decision);
default: return null;
}
}