// A heuristic opponent. Each turn it scores every pair of gestures by the // spells they finish, the spells they move toward, and what the human's // visible gestures threaten, then casts the most valuable spells it completed. // Spells it has cast recently are discounted so it does not fall into a rut, // and each named opponent weights the spell book a little differently. import { cellsOverlap, completableIn, completedSpells, handSequence, progressToward, usedCells, type Completion, type HandTurn, type TurnGestures } from './gestures'; import { allowedGestures, forcedGesture } from './resolve'; import { GESTURES, HANDS, HOSTILE_SPELLS, MAGIC_GESTURES, SPELLS, SPELL_BY_ID, isElemental, type Gesture, type Hand, type Spell, type SpellId } from './spells'; import { MAX_HP, enemyOf, isEnchanted, visibleHistory, type CastChoice, type GameState, type TargetId, type TurnInput, type WizardId } from './state'; const THREAT_VALUE: Partial> = { missile: 1, finger_of_death: 10, lightning_bolt: 5, lightning_bolt_quick: 5, cause_light_wounds: 2, cause_heavy_wounds: 3, fireball: 5, fire_storm: 5, ice_storm: 5, amnesia: 2, confusion: 1.5, charm_person: 2.5, charm_monster: 3, paralysis: 2.5, fear: 1.5, anti_spell: 2, disease: 4, poison: 4, blindness: 3, summon_goblin: 2, summon_ogre: 3, summon_troll: 4, summon_giant: 5, summon_elemental: 4 }; /** Defensive spells whose worth is purely situational, so repeating them is fine. */ const NO_REPEAT_DISCOUNT: SpellId[] = ['shield', 'counter_spell', 'magic_mirror', 'dispel_magic', 'cure_light_wounds', 'cure_heavy_wounds']; interface Situation { state: GameState; me: WizardId; foe: WizardId; foeSeq: Record; /** Hostile spells the foe could finish this very turn, by value. */ threatNow: number; threatNowCounterable: number; /** Hostile spells the foe could finish next turn. */ threatNext: number; foeMonsterDamage: number; missileOrMonsterNow: boolean; foeFirePlan: boolean; foeIcePlan: boolean; iceElemental: boolean; fireElemental: boolean; rng: () => number; } /** A stable 0..1 number from a string, so each opponent has its own leanings. */ function hash01(text: string): number { let h = 2166136261; for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); } return ((h >>> 0) % 1000) / 1000; } function assess(state: GameState, me: WizardId, rng: () => number): Situation { const foe = enemyOf(me); const foeW = state.wizards[foe]; const hist = visibleHistory(state, me, foe); const foeSeq: Record = { left: handSequence(hist, 'left'), right: handSequence(hist, 'right') }; let threatNow = 0; let threatNowCounterable = 0; let threatNext = 0; let missileNow = false; let foeFirePlan = false; let foeIcePlan = false; for (const hand of HANDS) { for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 1)) { if (!HOSTILE_SPELLS.includes(c.spell.id)) continue; const v = THREAT_VALUE[c.spell.id] ?? 1; threatNow = Math.max(threatNow, v); if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v); if (c.spell.id === 'missile') missileNow = true; } for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 2)) { if (!HOSTILE_SPELLS.includes(c.spell.id)) continue; threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1); } for (const spell of SPELLS) { const k = progressToward(spell.tokens[0], foeSeq[hand], foeW.sequenceStart); if (k >= 2 && (spell.id === 'fireball' || spell.id === 'fire_storm')) foeFirePlan = true; if (k >= 2 && spell.id === 'ice_storm') foeIcePlan = true; } } const foeMonsterDamage = state.monsters .filter((m) => m.owner === foe && !isElemental(m.kind)) .reduce((sum, m) => sum + m.attack, 0); return { state, me, foe, foeSeq, threatNow, threatNowCounterable, threatNext, foeMonsterDamage, missileOrMonsterNow: missileNow || foeMonsterDamage > 0, foeFirePlan, foeIcePlan, iceElemental: state.monsters.some((m) => m.kind === 'ice_elemental'), fireElemental: state.monsters.some((m) => m.kind === 'fire_elemental'), rng }; } /** How much the bot would like to finish this spell right now, before its leanings and recent habits. */ function baseValue(s: Situation, spell: Spell): number { const meW = s.state.wizards[s.me]; const foeW = s.state.wizards[s.foe]; const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe); const foeEnchanted = isEnchanted(foeW); switch (spell.id) { case 'fireball': if (s.iceElemental) return 6; return foeW.resistHeat ? 0.2 : 5; case 'lightning_bolt': return 5; case 'lightning_bolt_quick': return meW.usedQuickLightning ? 0 : 5.2; case 'cause_heavy_wounds': return 3.2; case 'cause_light_wounds': return 2.2; case 'missile': return 1.1; case 'finger_of_death': return 9; case 'summon_goblin': return 2.2; case 'summon_ogre': return 3.2; case 'summon_troll': return 4; case 'summon_giant': return 4.6; case 'summon_elemental': return meW.resistHeat || meW.resistCold ? 4.5 : 0.8; case 'paralysis': return 2.4; case 'amnesia': return 2; case 'confusion': return 1.6; case 'fear': return 1.8; case 'charm_person': return 2.6; case 'charm_monster': return foeMonsters.some((m) => !isElemental(m.kind)) ? 3.5 : 0; case 'anti_spell': { let longest = 0; for (const hand of HANDS) { for (const sp of SPELLS) { longest = Math.max(longest, progressToward(sp.tokens[0], s.foeSeq[hand], foeW.sequenceStart)); } } return 1 + longest * 0.6; } case 'disease': return 3.5; case 'poison': return 3.8; case 'blindness': return 3; case 'invisibility': return 2.5 + s.foeMonsterDamage; case 'haste': return meW.hasteTurns > 0 ? 0 : 3.5; case 'time_stop': return 3; case 'delayed_effect': return meW.banked || meW.delayedTurns > 0 ? 0 : 1.5; case 'permanency': return meW.permanencyTurns > 0 ? 0 : 2.5; case 'shield': if (meW.protectionTurns > 0) return 0; if (s.foeMonsterDamage > 0) return 1.5 + s.foeMonsterDamage; if (s.missileOrMonsterNow) return 1.4; return 0.3; case 'counter_spell': return Math.max(0.4, s.threatNowCounterable); case 'magic_mirror': return s.threatNowCounterable >= 3 ? s.threatNowCounterable : 0.3; case 'dispel_magic': return Math.max(0.5, s.foeMonsterDamage + (foeEnchanted ? 2 : 0) + (s.threatNow >= 5 ? s.threatNow : 0)); case 'remove_enchantment': return foeEnchanted ? 2.5 : foeMonsters.length > 0 ? 1 + s.foeMonsterDamage : 0.3; case 'protection_from_evil': return meW.protectionTurns > 1 ? 0 : 2 + s.foeMonsterDamage; case 'resist_heat': if (s.fireElemental) return 4; return s.foeFirePlan && !meW.resistHeat ? 3.5 : meW.resistHeat ? 0 : 0.6; case 'resist_cold': if (s.iceElemental) return 4; return s.foeIcePlan && !meW.resistCold ? 3.5 : meW.resistCold ? 0 : 0.5; case 'cure_light_wounds': return meW.hp < MAX_HP ? 1 : 0; case 'cure_heavy_wounds': return meW.hp < MAX_HP - 1 ? 2 : meW.diseaseTurns !== null ? 5 : 0; case 'raise_dead': return meW.hp <= MAX_HP - 5 ? 4 : 0; case 'fire_storm': return meW.resistHeat && !foeW.resistHeat ? 5 : 0; case 'ice_storm': return meW.resistCold && !foeW.resistCold ? 5 : 0; default: return 0; } } function spellValue(s: Situation, spell: Spell): number { let v = baseValue(s, spell); if (v <= 0) return 0; const meW = s.state.wizards[s.me]; // Leanings: each opponent favours some spells over others. v *= 0.7 + 0.6 * hash01(meW.name + spell.id); // Habit: a spell cast in the last few turns is worth less again. if (!NO_REPEAT_DISCOUNT.includes(spell.id)) { const recent = meW.casts.filter((c) => c.spellId === spell.id && c.turn >= s.state.turn - 4).length; v /= 1 + recent; } return v; } interface HandEval { score: number; planned?: SpellId; } function evaluateHand(s: Situation, seq: HandTurn[], start: number, otherPlanned?: SpellId): HandEval { let best = 0; for (const c of completedSpells(seq, start)) { best = Math.max(best, spellValue(s, c.spell)); } let progress = 0; let planned: SpellId | undefined; for (const spell of SPELLS) { const value = spellValue(s, spell); if (value <= 0) continue; for (const tokens of spell.tokens) { const k = progressToward(tokens, seq, start); if (k === 0) continue; let v = value * Math.pow(k / tokens.length, 1.3); if (k >= 2) v += 0.3; // Being one gesture from a counter-spell is worth a lot when a blow is coming next turn. if (spell.id === 'counter_spell' && k === tokens.length - 1) v = Math.max(v, s.threatNext * 0.8); if (spell.id === otherPlanned) v *= 0.4; if (v > progress) { progress = v; planned = spell.id; } } } return { score: best + progress * 0.9, planned }; } function bestCasts(s: Situation, completions: Record, turnCount: number): CastChoice[] { const options: { hand: Hand; completion: Completion; value: number }[] = []; for (const hand of HANDS) { for (const completion of completions[hand]) { const value = spellValue(s, completion.spell); if (value > 0.25) options.push({ hand, completion, value }); } } options.sort((a, b) => b.value - a.value); const chosen: typeof options = []; for (const o of options) { if (chosen.some((c) => c.hand === o.hand)) continue; const cells = usedCells(o.completion, o.hand, turnCount); if (chosen.some((c) => cellsOverlap(cells, usedCells(c.completion, c.hand, turnCount)))) continue; chosen.push(o); } return chosen.map((o) => toChoice(s, o.hand, o.completion)); } function foeDangerousHand(s: Situation): Hand { const foeW = s.state.wizards[s.foe]; let bestHand: Hand = 'left'; let bestK = -1; for (const hand of HANDS) { for (const spell of SPELLS) { if (!HOSTILE_SPELLS.includes(spell.id)) continue; const k = progressToward(spell.tokens[0], s.foeSeq[hand], foeW.sequenceStart); if (k > bestK) { bestK = k; bestHand = hand; } } } return bestHand; } function targetFor(s: Situation, spell: Spell, choice: CastChoice): void { const meW = s.state.wizards[s.me]; const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe); const strongestFoeMonster = [...foeMonsters].sort((a, b) => b.attack - a.attack)[0]; const iceEl = s.state.monsters.find((m) => m.kind === 'ice_elemental'); const fireEl = s.state.monsters.find((m) => m.kind === 'fire_elemental'); switch (spell.id) { case 'fireball': choice.target = iceEl ? iceEl.id : s.foe; break; case 'resist_cold': choice.target = iceEl ? iceEl.id : s.me; break; case 'resist_heat': choice.target = fireEl ? fireEl.id : s.me; break; case 'charm_monster': choice.target = strongestFoeMonster?.id ?? s.foe; break; case 'remove_enchantment': { const foeW = s.state.wizards[s.foe]; choice.target = isEnchanted(foeW) || !strongestFoeMonster ? s.foe : strongestFoeMonster.id; break; } case 'summon_elemental': choice.target = s.me; choice.elemental = meW.resistCold ? 'ice' : 'fire'; break; case 'paralysis': case 'charm_person': choice.target = s.foe; choice.chosenHand = foeDangerousHand(s); break; default: choice.target = spell.usualTarget === 'self' ? s.me : s.foe; } if (spell.category === 'summons') choice.monsterTarget = s.foe; } function toChoice(s: Situation, hand: Hand, completion: Completion): CastChoice { const choice: CastChoice = { hand, spellId: completion.spell.id, seqIndex: completion.seqIndex }; targetFor(s, completion.spell, choice); return choice; } /** The gesture that most disrupts a charmed hand's sequences. */ function charmGesture(s: Situation): Gesture { const foeW = s.state.wizards[s.foe]; const charmedHand = foeW.constraints.charmed?.hand ?? 'left'; let best: Gesture = 'P'; let bestScore = Infinity; for (const g of MAGIC_GESTURES) { const seq = [...s.foeSeq[charmedHand], { own: g, other: '-' as Gesture }]; let score = 0; for (const spell of SPELLS) { if (!HOSTILE_SPELLS.includes(spell.id)) continue; score += progressToward(spell.tokens[0], seq, foeW.sequenceStart); } if (score < bestScore || (score === bestScore && s.rng() < 0.5)) { bestScore = score; best = g; } } return best; } interface PairChoice { left: Gesture; right: Gesture; casts: CastChoice[]; } /** Pick one pair of gestures given the history so far, and what to cast with them. */ function choosePair(s: Situation, history: TurnGestures[], timeStopped: boolean): PairChoice { const meW = s.state.wizards[s.me]; const foe = s.foe; const optionsFor = (hand: Hand): Gesture[] => { if (timeStopped) return GESTURES; const forced = forcedGesture(meW, hand); if (forced) return [forced]; const allowed = allowedGestures(meW, hand); return allowed.length ? allowed : ['-']; }; const start = meW.sequenceStart; let bestPair: { left: Gesture; right: Gesture; score: number } | undefined; for (const left of optionsFor('left')) { for (const right of optionsFor('right')) { const next = [...history, { left, right }]; const seqL = handSequence(next, 'left'); const seqR = handSequence(next, 'right'); const evalL = evaluateHand(s, seqL, start); const evalR = evaluateHand(s, seqR, start, evalL.planned); let score = evalL.score + evalR.score; if (left === 'P' && right === 'P') score -= 100; if (left === '>' && right === '>') score -= 50; if (left === '-' && right === '-') score -= 3; if (left === '>' || right === '>') score += s.state.monsters.some((m) => m.owner === foe && m.hp === 1) ? 1.5 : 0.15; score += s.rng() * 0.5; if (!bestPair || score > bestPair.score) bestPair = { left, right, score }; } } const pair = bestPair ?? { left: '-' as Gesture, right: '-' as Gesture, score: 0 }; const next = [...history, { left: pair.left, right: pair.right }]; const completions: Record = { left: completedSpells(handSequence(next, 'left'), start), right: completedSpells(handSequence(next, 'right'), start) }; return { left: pair.left, right: pair.right, casts: bestCasts(s, completions, next.length) }; } export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number = Math.random): TurnInput { const s = assess(state, me, rng); const meW = state.wizards[me]; const foe = s.foe; const timeStopped = state.timeStops[0] === me; const first = choosePair(s, meW.history, timeStopped); const weakFoeMonster = state.monsters.find((m) => m.owner === foe && m.hp === 1); const monsterOrders: Record = {}; for (const m of state.monsters) if (m.owner === me) monsterOrders[m.id] = foe; const input: TurnInput = { left: first.left, right: first.right, casts: first.casts, stabTarget: weakFoeMonster?.id ?? foe, monsterOrders }; if (meW.hasteTurns > 0 && !timeStopped) { const second = choosePair(s, [...meW.history, { left: first.left, right: first.right }], timeStopped); input.second = { left: second.left, right: second.right, casts: second.casts, stabTarget: input.stabTarget }; } if (meW.banked) { // Loose a banked spell as soon as it is worth anything; keep a shield for a rainy day. const spell = SPELL_BY_ID[meW.banked.spellId]; const worth = baseValue(s, spell); if (worth > 0.5 && (spell.id !== 'shield' || s.missileOrMonsterNow)) { const choice: CastChoice = { hand: 'left', spellId: spell.id }; targetFor(s, spell, choice); input.release = { target: choice.target, monsterTarget: choice.monsterTarget }; } } if (state.wizards[foe].constraints.charmed?.by === me) input.charmGesture = charmGesture(s); return input; }