// Dungeon Boss AI — synchronous heuristics over the engine's public view // (publicView in DungeonBossLogic redacts rival hands and pending builds; the // AI never sees hidden information). One handler per decision kind, routed // through decide(). Skill 1–5 follows the AzulAI shape: widening top-N with // score noise and a blunder chance at low skill. import { BOSSES, ROOMS, SPELLS, HEROES, CLASSES, heroSouls, roomDef, spellDef, heroDef, } from './DungeonBossData.js'; import { legalBuilds, windowActions, treasureCount, dungeonDamage, baitTargets, } from './DungeonBossLogic.js'; const SKILL_PROFILES = { 1: { topN: 5, blunder: 0.40, noise: 10, actChance: 0.25, delay: [700, 1200] }, 2: { topN: 4, blunder: 0.22, noise: 7, actChance: 0.45, delay: [650, 1100] }, 3: { topN: 3, blunder: 0.10, noise: 4, actChance: 0.70, delay: [600, 1000] }, 4: { topN: 2, blunder: 0.03, noise: 2, actChance: 0.90, delay: [520, 900] }, 5: { topN: 1, blunder: 0.00, noise: 0, actChance: 1.00, delay: [440, 820] }, }; 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); } // rnd: a plain ()=>[0,1) function (seeded in verify, Math.random in the scene). function pickScored(cands, skill, rnd) { const prof = profileFor(skill); if (!cands.length) return null; 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); const n = Math.min(prof.topN, jittered.length); return jittered[Math.floor(rnd() * n)].choice; } const me = (view, seat) => view.players[seat]; const rivals = (view, seat) => view.players.filter((p) => p.alive && p.seat !== seat); const soulLeader = (view, seat) => rivals(view, seat).sort((a, b) => b.souls - a.souls)[0] || null; const canKill = (view, seat, hero) => dungeonDamage(view, seat) >= hero.hp; function roomValue(def) { const icons = Object.values(def.treasure || {}).reduce((a, b) => a + b, 0); return def.dmg * 2 + icons * 1.5 + (def.effects || []).length + (def.passive ? 1 : 0); } function handCardValue(inst) { const r = ROOMS[inst.id]; if (r) return roomValue(r) + (r.advanced ? 1 : 0); return 5; // spells are versatile — keep them over weak rooms } // ── Decision handlers ──────────────────────────────────────────────────────── function chooseSetupDiscard(view, seat) { const p = me(view, seat); const all = [...p.hand.rooms, ...p.hand.spells] .map((c) => ({ uid: c.uid, v: handCardValue(c) + (ROOMS[c.id] && ROOMS[c.id].advanced ? -2 : 0) })) .sort((a, b) => a.v - b.v); // Never discard down to zero ordinary rooms — the setup build needs one. const isOrdinary = (uid) => { const c = p.hand.rooms.find((x) => x.uid === uid); return c && !ROOMS[c.id].advanced; }; const picked = []; for (const cand of all) { if (picked.length === 2) break; const remainingOrdinary = p.hand.rooms.filter((c) => !ROOMS[c.id].advanced && !picked.includes(c.uid) && c.uid !== cand.uid).length; if (isOrdinary(cand.uid) && remainingOrdinary === 0) continue; picked.push(cand.uid); } // Fallback (hand was nearly all ordinary): take the two cheapest regardless. while (picked.length < 2) { const next = all.find((c) => !picked.includes(c.uid)); picked.push(next.uid); } return picked; } function scoreBuild(view, seat, b) { const p = me(view, seat); const def = roomDef(p.hand.rooms.find((c) => c.uid === b.roomUid)); let s = 0; const isNew = b.slotIdx >= p.dungeon.length; // Value of the room itself, minus what a build-over buries. s += def.dmg * 1.5 + Object.values(def.treasure || {}).reduce((a, c) => a + c, 0); if (!isNew) { const buried = roomDef(p.dungeon[b.slotIdx].room); s -= roomValue(buried) * 0.6; if (buried.dmg <= 1 && !buried.passive) s += 2; // upgrading a weak room is fine } else { s += 2; if (p.dungeon.length === 4 && !p.boss.leveledUp) s += 6; // level-up on the 5th room } if (def.advanced) s += 2; // Bait consequences: compare who I attract (and whether I can kill them) // before and after this build — existing icons already pull heroes, so // exposure to unkillable heroes matters as much as new pulls. const curDmg = dungeonDamage(view, seat); const projDmg = curDmg + def.dmg + (isNew ? 0 : -roomDef(p.dungeon[b.slotIdx].room).dmg); for (const hero of view.town) { const h = heroDef(hero); if (h.fool) continue; const curMine = treasureCount(view, seat, h.cls); const mine = curMine + (def.treasure?.[h.cls] || 0) - (isNew ? 0 : ((roomDef(p.dungeon[b.slotIdx].room).treasure || {})[h.cls] || 0)); const bestRival = Math.max(0, ...rivals(view, seat).map((r) => treasureCount(view, r.seat, h.cls))); const wasMax = curMine > bestRival; const nowMax = mine > bestRival; const killCur = curDmg >= hero.hp; const killNow = projDmg >= hero.hp; if (!wasMax && nowMax) { s += killNow ? 4 + heroSouls(h) * 3 : -(6 + (h.epic ? 6 : 0)); } else if (wasMax && nowMax) { if (!killCur && killNow) s += 8; // this build saves me a wound else if (!killNow) s -= 2; // still doomed; prefer damage elsewhere } else if (wasMax && !nowMax) { s += killCur ? -4 : 5; // dodged an unkillable hero / lost a soul } } return s; } function chooseBuild(view, seat, skill, rnd, decision) { const builds = legalBuilds(view, seat); if (decision.setup) { let best = null; let bestV = -1e9; for (const b of builds) { const def = roomDef(me(view, seat).hand.rooms.find((c) => c.uid === b.roomUid)); if (def.advanced) continue; // advanced can't open a dungeon anyway const v = roomValue(def); if (v > bestV) { bestV = v; best = b; } } return best || builds[0] || null; } const cands = builds.map((b) => ({ choice: b, s: scoreBuild(view, seat, b) })); cands.push({ choice: null, s: -1 }); // passing is rarely right return pickScored(cands, skill, rnd); } // One scored list across castable spells + activatable rooms + pass. function chooseWindow(view, seat, skill, rnd, decision) { const prof = profileFor(skill); const acts = windowActions(view, seat); if ((!acts.spells.length && !acts.rooms.length) || rnd() > prof.actChance) return { pass: true }; const p = me(view, seat); const isAdv = decision.window === 'advStart'; const myTurn = isAdv && decision.advSeat === seat; const leader = soulLeader(view, seat); const cands = [{ choice: { pass: true }, s: 1 }]; const survivor = (who) => who.entrance.find((h) => h.hp > dungeonDamage(view, who.seat)); const dying = (who) => who.entrance.find((h) => h.hp <= dungeonDamage(view, who.seat)); for (const sp of acts.spells) { const def = spellDef({ id: sp.id }); const op = def.op.op; const push = (target, s) => cands.push({ choice: { spellUid: sp.uid, target }, s }); if (op === 'dmgBoostRoom' && myTurn && survivor(p)) { // Boost my biggest matching room if it flips a survivor to a kill. const own = (sp.targets || []).filter((t) => t.seat === seat); const best = own.sort((a, b) => roomDef(p.dungeon[b.slotIdx].room).dmg - roomDef(p.dungeon[a.slotIdx].room).dmg)[0]; if (best && survivor(p).hp <= dungeonDamage(view, seat) + def.op.amount) push(best, 12); else if (best) push(best, 3); } else if (op === 'healHero' && isAdv && !myTurn && leader) { const t = (sp.targets || []).find((t2) => { const owner = view.players.find((q) => q.entrance.some((h) => h.uid === t2.uid)); if (!owner || owner.seat === seat) return false; const hero = owner.entrance.find((h) => h.uid === t2.uid); return hero.hp <= dungeonDamage(view, owner.seat) && hero.hp + def.op.amount > dungeonDamage(view, owner.seat); }); if (t) push(t, 11); } else if (op === 'damageHero' && myTurn) { const sv = survivor(p); if (sv && sv.hp - p.dungeon.length <= dungeonDamage(view, seat)) push({ kind: 'hero', uid: sv.uid }, 11); } else if (op === 'teleportHero' && myTurn) { const sv = survivor(p); if (sv && sv.hp <= dungeonDamage(view, seat) * 2) push({ kind: 'hero', uid: sv.uid }, 10); } else if (op === 'fearHero' && myTurn) { const sv = survivor(p); if (sv) push({ kind: 'hero', uid: sv.uid }, 9); // shoo away a hero I can't kill } else if (op === 'deactivateRoom' && !isAdv && leader) { const t = (sp.targets || []).filter((t2) => t2.seat === leader.seat) .sort((a, b) => roomDef(leader.dungeon[b.slotIdx].room).dmg - roomDef(leader.dungeon[a.slotIdx].room).dmg)[0]; if (t) push(t, 6); } else if (op === 'lureHero') { const t = (sp.targets || []).find((t2) => { const hero = view.town.find((h) => h.uid === t2.uid); return hero && canKill(view, seat, hero); }); if (t) push(t, 8 + (heroDef(view.town.find((h) => h.uid === t.uid)).epic ? 4 : 0)); } else if (op === 'resurrectAttack' && leader) { const t = (sp.targets || []).find((t2) => { if (t2.seat !== leader.seat) return false; const hero = leader.soulCards.find((h) => h.uid === t2.uid); return hero && hero.hpMax + (def.op.hpBonus || 0) > dungeonDamage(view, leader.seat); }); if (t) push(t, 9); } else if (op === 'blockHeroes') { const t = (sp.targets || [])[0]; if (t) push(t, 7); } else if (op === 'caveIn' && myTurn) { const sv = survivor(p); if (sv) { const t = (sp.targets || []).sort((a, b) => roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0]; if (t) push(t, 8); } } else if (op === 'sacrificeSoulDraw') { if (p.hand.spells.length <= 1 && p.souls > 2 && p.souls < 9) push((sp.targets || [])[0], 3); } else if (op === 'handReset') { if (p.hand.rooms.length + p.hand.spells.length <= 2) push(null, 4); } else if (op === 'noBuildRound') { const rivalNearLevel = rivals(view, seat).some((r) => r.dungeon.length === 4 && !r.boss.leveledUp); if (rivalNearLevel) push(null, 5); } else if (op === 'extraBuild') { if (p.hand.rooms.length) push(null, 6); } else if (op === 'dmgBoostAllRooms' && myTurn && survivor(p)) { const t = (sp.targets || [])[0]; if (t) push(t, 6); } } for (const ra of acts.rooms) { const def = roomDef(p.dungeon[ra.slotIdx].room); const eff = (def.effects || [])[ra.effIdx]; const push = (target, s, extra = {}) => cands.push({ choice: { slotIdx: ra.slotIdx, effIdx: ra.effIdx, target, ...extra }, s }); if (eff.op === 'pitTrap' && myTurn) { const sv = survivor(p); if (sv) push(null, 12); // guaranteed kill beats keeping a 1-damage trap } else if (eff.op === 'rampTrap' && myTurn) { const sv = survivor(p); if (sv && sv.hp <= dungeonDamage(view, seat) + 5) { const t = (ra.targets || []).sort((a, b) => roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0]; if (t) push(t, 10); } } else if (eff.op === 'dmgBoostAllRooms' && myTurn && survivor(p)) { const t = (ra.targets || []).sort((a, b) => roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0]; if (t) push(t, 9); } else if (eff.op === 'doubleTreasure' && !isAdv) { const killable = view.town.filter((h) => !heroDef(h).fool && canKill(view, seat, h)); const bt = baitTargets(view); if (killable.some((h) => bt[h.uid] !== seat)) push(null, 7); } else if (eff.op === 'drawSpell' && eff.cost && eff.cost.discardMonsterRoom) { if (p.hand.rooms.filter((c) => roomDef(c).type === 'monster').length > 2) push(null, 2); } else if (eff.op === 'recoverDiscard') { const bestRoom = (ra.targets || []).map((t) => view.roomDiscard.find((c) => c.uid === t.uid)) .filter(Boolean).sort((a, b) => roomValue(roomDef(b)) - roomValue(roomDef(a)))[0]; if (bestRoom && roomValue(roomDef(bestRoom)) >= 8 && p.hand.rooms.length >= (eff.cost?.discardRooms || 0) + 1) { push({ kind: 'card', seat: -1, uid: bestRoom.uid }, 3); } } else if (eff.op === 'opponentDiscardRandom' && leader) { const t = (ra.targets || []).find((t2) => t2.seat === leader.seat); if (t && p.souls >= 6) push(t, 2); } else if (eff.op === 'resurrectAttack' && leader) { const t = (ra.targets || []).find((t2) => { if (t2.seat !== leader.seat) return false; const hero = leader.soulCards.find((h) => h.uid === t2.uid); return hero && hero.hpMax > dungeonDamage(view, leader.seat); }); if (t) push(t, 8); } } return pickScored(cands, skill, rnd) || { pass: true }; } function chooseReact(view, seat, skill, rnd, decision) { const prof = profileFor(skill); if (rnd() > prof.actChance) return null; const t = decision.target; const aimedAtMe = t && (t.seat === seat || (t.kind === 'hero' && me(view, seat).entrance.some((h) => h.uid === t.uid))); const casterIsLeader = soulLeader(view, seat) && decision.casterSeat === soulLeader(view, seat).seat; if (!aimedAtMe && !(casterIsLeader && rnd() < 0.3)) return null; const p = me(view, seat); // Prefer the All-Seeing Eye (costs a spare spell) over burning Counterspell. for (let slotIdx = 0; slotIdx < p.dungeon.length; slotIdx++) { const slot = p.dungeon[slotIdx]; if (slot.deactivated) continue; const effIdx = (roomDef(slot.room).effects || []).findIndex((e) => e.trigger === 'reaction'); if (effIdx >= 0 && !slot.usedOnce[effIdx] && p.hand.spells.length > 1) { const spare = p.hand.spells.find((c) => spellDef(c).op.op !== 'counterSpell') || p.hand.spells[0]; return { type: 'allseeingeye', slotIdx, discardSpellUid: spare.uid }; } } const cs = p.hand.spells.find((c) => spellDef(c).op.op === 'counterSpell'); if (cs && aimedAtMe) return { type: 'counterspell', spellUid: cs.uid }; return null; } function chooseTarget(view, seat, skill, rnd, decision) { const cands = decision.candidates || []; const p = me(view, seat); const best = (scoreFn) => { let top = null; let topS = -1e9; for (const c of cands) { const s = scoreFn(c); if (s > topS) { topS = s; top = c; } } return { top, topS }; }; switch (decision.op) { case 'destroyOwnRoom': // forced (Robobo) — lose the least return best((c) => -roomValue(roomDef(p.dungeon[c.slotIdx].room))).top; case 'recoverDiscard': { const { top } = best((c) => { const r = view.roomDiscard.find((x) => x.uid === c.uid); if (r) return roomValue(roomDef(r)); return 4; // an unknown spell is a decent pull }); return top; } case 'revealTake': { // Level-up peek: real hands are exposed by the candidate list via uids the // engine validated; without card identity here, take from the soul leader. const leader = soulLeader(view, seat); return best((c) => (leader && c.seat === leader.seat ? 2 : 1) + rnd()).top; } case 'stealRandom': case 'opponentDiscardRandom': { const leader = soulLeader(view, seat); return best((c) => (leader && c.seat === leader.seat ? 2 : 1)).top; } case 'tutorAdvanced': return best((c) => { const inst = [...(view.roomDiscard || [])].find((x) => x.uid === c.roomUid); return inst ? roomValue(roomDef(inst)) : 5 + rnd(); }).top; case 'killHeroTown': return best((c) => { const hero = view.town.find((h) => h.uid === c.uid); return hero ? heroSouls(heroDef(hero)) * 4 + hero.hp : 0; }).top; case 'lureHero': { const { top, topS } = best((c) => { const hero = view.town.find((h) => h.uid === c.uid) || [(view.deckCounts ? null : null)].filter(Boolean)[0]; if (!hero) return -1; // deck heroes are unknown to the view; skip them const kill = canKill(view, seat, hero); return (kill ? 8 : -8) + heroSouls(heroDef(hero)) * 2; }); if (decision.optional && topS <= 0) return null; return top; } case 'swapRooms': return decision.optional ? null : cands[0]; default: { const pick = cands[Math.floor(rnd() * cands.length)]; return decision.optional && rnd() < 0.5 ? pick : (decision.optional ? pick : pick); } } } function chooseDiscard(view, seat, decision) { const p = me(view, seat); const pool = decision.cardType === 'spell' ? p.hand.spells : p.hand.rooms; return pool.slice().sort((a, b) => handCardValue(a) - handCardValue(b)) .slice(0, decision.n).map((c) => c.uid); } function chooseRoomDraw(view, seat) { return me(view, seat).hand.spells.length < 2 ? 'spell' : 'room'; } // ── Router ────────────────────────────────────────────────────────────────── // Returns the argument for the matching act* mutator: // setupDiscard → [uidA, uidB] · build → {roomUid,slotIdx}|null · // window → {spellUid,target}|{slotIdx,effIdx,target,costUids}|{pass:true} · // react → response|null · target → targetRef|null · discard → [uids] · // roomDraw → 'room'|'spell' export function decide(view, decision, skill, rnd = Math.random) { const seat = decision.seat; switch (decision.kind) { case 'setupDiscard': return chooseSetupDiscard(view, seat); case 'build': return chooseBuild(view, seat, skill, rnd, decision); case 'window': return chooseWindow(view, seat, skill, rnd, decision); case 'react': return chooseReact(view, seat, skill, rnd, decision); case 'target': return chooseTarget(view, seat, skill, rnd, decision); case 'discard': return chooseDiscard(view, seat, decision); case 'roomDraw': return chooseRoomDraw(view, seat); default: return null; } }