Files
waving-hands/src/lib/game/bot.ts
T
Eric WagonerandClaude Fable 5.1 58d7db106e A table for a dozen wizards, begun by its host
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
2026-09-23 09:34:21 -04:00

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