357 lines
15 KiB
JavaScript
357 lines
15 KiB
JavaScript
// 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;
|
||
}
|
||
}
|