fertig-classic-games/src/games/dungeonboss/DungeonBossAI.js

386 lines
18 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.

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