// 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; } }