386 lines
18 KiB
JavaScript
386 lines
18 KiB
JavaScript
// 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;
|
||
}
|
||
}
|