Rooms take up to twelve seats and the host begins the duel with whoever is seated; a full table begins by itself. The lobby is a roster with a bot seat and the host's begin button. The ledger pins the turn and your own columns and scrolls sideways to the rest; the phone bar shows every wizard's health; a wizard who is out watches the rest; each charmed wizard gets their own gesture; an unnamed spell goes at whoever hurt you last, else the weakest opponent; a stab has a target from the start. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
462 lines
15 KiB
TypeScript
462 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 { likelyTarget } from './targets';
|
|
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,
|
|
opponentsOf,
|
|
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;
|
|
}
|
|
|
|
/** The opponent to attack: the weakest still standing. */
|
|
function pickFoe(state: GameState, me: WizardId): WizardId {
|
|
const standing = opponentsOf(state, me);
|
|
if (standing.length === 0) return enemyOf(state, me);
|
|
return [...standing].sort((a, b) => state.wizards[a].hp - state.wizards[b].hp)[0];
|
|
}
|
|
|
|
function assess(state: GameState, me: WizardId, rng: () => number): Situation {
|
|
const foe = pickFoe(state, me);
|
|
const foes = opponentsOf(state, me);
|
|
const seqOf = (id: WizardId): Record<Hand, HandTurn[]> => {
|
|
const hist = visibleHistory(state, me, id);
|
|
return { left: handSequence(hist, 'left'), right: handSequence(hist, 'right') };
|
|
};
|
|
const foeSeq = seqOf(foe);
|
|
let threatNow = 0;
|
|
let threatNowCounterable = 0;
|
|
let threatNext = 0;
|
|
let missileNow = false;
|
|
let foeFirePlan = false;
|
|
let foeIcePlan = false;
|
|
// Danger comes from every opponent, not only the one being attacked.
|
|
for (const id of foes.length ? foes : [foe]) {
|
|
const seq = id === foe ? foeSeq : seqOf(id);
|
|
const start = state.wizards[id].sequenceStart;
|
|
for (const hand of HANDS) {
|
|
for (const c of completableIn(seq[hand], start, 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(seq[hand], start, 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], seq[hand], start);
|
|
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 !== me && !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.me);
|
|
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 {
|
|
choice.target = likelyTarget(s.state, s.me, spell);
|
|
if (spell.id === 'summon_elemental') choice.elemental = s.state.wizards[s.me].resistCold ? 'ice' : 'fire';
|
|
if (spell.id === 'paralysis' || spell.id === 'charm_person') choice.chosenHand = foeDangerousHand(s);
|
|
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 wizard's hand. */
|
|
function charmGesture(s: Situation, who: WizardId): Gesture {
|
|
const foeW = s.state.wizards[who];
|
|
const charmedHand = foeW.constraints.charmed?.hand ?? 'left';
|
|
const hist = visibleHistory(s.state, s.me, who);
|
|
const theirs = handSequence(hist, charmedHand);
|
|
let best: Gesture = 'P';
|
|
let bestScore = Infinity;
|
|
for (const g of MAGIC_GESTURES) {
|
|
const seq = [...theirs, { 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 !== s.me && 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 !== me && 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 };
|
|
}
|
|
}
|
|
const charmed = state.seats.filter((id) => state.wizards[id].constraints.charmed?.by === me);
|
|
if (charmed.length) input.charmGestures = Object.fromEntries(charmed.map((id) => [id, charmGesture(s, id)]));
|
|
return input;
|
|
}
|