Duplicated logic and styling that had drifted apart is unified: the gesture-sharing check, the hostile spell list, the enchanted test, the plan limits, and the shared button, link, field and disabled styles. Dead plumbing is gone: the always-true implemented flag, the unread cast length, an unreachable guard, an impossible time-stop condition, the unused sequence formatter and utility class, the template placeholder. The counter-spell tests now put a counter-spell in front of a spell, the test helpers live in one file, and the resolver's sections are numbered in order. The deploy script's cache headers now do what its comment says, and the README describes the screen rather than listing features in the order they arrived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
477 lines
15 KiB
TypeScript
477 lines
15 KiB
TypeScript
// 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<Record<SpellId, number>> = {
|
|
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<Hand, HandTurn[]>;
|
|
/** 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<Hand, HandTurn[]> = {
|
|
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<Hand, Completion[]>, 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<Hand, Completion[]> = {
|
|
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<string, TargetId> = {};
|
|
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;
|
|
}
|