diff --git a/src/lib/game/bot.ts b/src/lib/game/bot.ts index 572d391..46df338 100644 --- a/src/lib/game/bot.ts +++ b/src/lib/game/bot.ts @@ -16,6 +16,7 @@ import { 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, @@ -330,41 +331,9 @@ function foeDangerousHand(s: Situation): Hand { } 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; - } + 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; } diff --git a/src/lib/game/duel.svelte.ts b/src/lib/game/duel.svelte.ts index 99fb83e..08303c4 100644 --- a/src/lib/game/duel.svelte.ts +++ b/src/lib/game/duel.svelte.ts @@ -14,6 +14,7 @@ import { } from './gestures'; import { glyph } from './glyphs'; import { allowedGestures, forcedGesture, resolveTurn } from './resolve'; +import { likelyTarget } from './targets'; import { GESTURES, HANDS, @@ -72,11 +73,6 @@ export interface PlannedCast { turnCount: number; } -function defaultTarget(spell: Spell | undefined): TargetId | '' { - if (!spell) return ''; - return spell.usualTarget === 'self' ? YOU : FOE; -} - function blankDraft(): HandDraft { return { gesture: null, spellId: '', target: '', elemental: 'fire', chosenHand: 'left' }; } @@ -541,12 +537,22 @@ export class Duel { ); bankedSpell = $derived(this.you.banked ? SPELL_BY_ID[this.you.banked.spellId] : undefined); + /** Where a spell goes unless the player says otherwise. */ + defaultTarget(spell: Spell | undefined): TargetId | '' { + return spell ? likelyTarget(this.state, YOU, spell) : ''; + } + + /** Every possible subject, the likeliest first, creatures marked by whose they are. */ targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] { const list: { id: TargetId; label: string }[] = [ { id: YOU, label: 'yourself' }, { id: FOE, label: this.foe.name } ]; - for (const m of this.state.monsters) list.push({ id: m.id, label: m.name }); + for (const m of this.state.monsters) { + list.push({ id: m.id, label: `${m.name} (${m.owner === YOU ? 'yours' : `${this.foe.name}'s`})` }); + } + const likely = this.defaultTarget(spell); + list.sort((a, b) => Number(b.id === likely) - Number(a.id === likely)); if (spell && spell.id !== 'fire_storm' && spell.id !== 'ice_storm') list.push({ id: NOWHERE, label: 'nowhere (let it fade)' }); return list; } @@ -563,13 +569,13 @@ export class Duel { const options = this.completions[set][hand]; if (!options.some((c) => c.spell.id === draft.spellId)) { draft.spellId = options[0]?.spell.id ?? ''; - draft.target = defaultTarget(options[0]?.spell); + draft.target = this.defaultTarget(options[0]?.spell); } } setSpell(set: SetName, hand: Hand, id: SpellId | ''): void { this.sets[set][hand].spellId = id; - this.sets[set][hand].target = defaultTarget(id ? SPELL_BY_ID[id] : undefined); + this.sets[set][hand].target = this.defaultTarget(id ? SPELL_BY_ID[id] : undefined); } private castFor(p: PlannedCast): CastChoice { @@ -578,7 +584,7 @@ export class Duel { hand: p.hand, spellId: spell.id, seqIndex: p.completion.seqIndex, - target: p.draft.target || defaultTarget(spell) + target: p.draft.target || this.defaultTarget(spell) }; if (spell.id === 'summon_elemental') cast.elemental = p.draft.elemental; if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand; diff --git a/src/lib/game/targets.test.ts b/src/lib/game/targets.test.ts new file mode 100644 index 0000000..4a80fd2 --- /dev/null +++ b/src/lib/game/targets.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { SPELL_BY_ID } from './spells'; +import { createGame } from './state'; +import { likelyTarget } from './targets'; +import { runSequence } from './test-helpers'; + +describe('the likeliest target', () => { + it('sends a fireball and resist cold at an ice elemental when one is out', () => { + let s = createGame({ A: 'Black', B: 'White' }, 1); + expect(likelyTarget(s, 'A', SPELL_BY_ID.fireball)).toBe('B'); + s = runSequence(s, 'B', 'CSWWS', 'C----', { casts: [{ hand: 'left', spellId: 'summon_elemental', elemental: 'ice' }] }); + const elemental = s.monsters.find((m) => m.kind === 'ice_elemental')!; + expect(likelyTarget(s, 'A', SPELL_BY_ID.fireball)).toBe(elemental.id); + expect(likelyTarget(s, 'A', SPELL_BY_ID.resist_cold)).toBe(elemental.id); + expect(likelyTarget(s, 'A', SPELL_BY_ID.resist_heat)).toBe('A'); + }); + + it('aims remove enchantment at the wizard only when they carry an enchantment', () => { + let s = createGame({ A: 'Black', B: 'White' }, 1); + s = runSequence(s, 'B', 'SFW', '---', { casts: [{ hand: 'left', spellId: 'summon_goblin' }] }); + const goblin = s.monsters[0]; + expect(likelyTarget(s, 'A', SPELL_BY_ID.remove_enchantment)).toBe(goblin.id); + expect(likelyTarget(s, 'A', SPELL_BY_ID.charm_monster)).toBe(goblin.id); + s = runSequence(s, 'B', 'WWFP', '----', { casts: [{ hand: 'left', spellId: 'resist_heat' }] }); + expect(likelyTarget(s, 'A', SPELL_BY_ID.remove_enchantment)).toBe('B'); + expect(likelyTarget(s, 'A', SPELL_BY_ID.counter_spell)).toBe('A'); + }); +}); diff --git a/src/lib/game/targets.ts b/src/lib/game/targets.ts new file mode 100644 index 0000000..9c15c9f --- /dev/null +++ b/src/lib/game/targets.ts @@ -0,0 +1,28 @@ +// Where a spell most likely goes, given what is on the field. Both the bot and +// the target dropdown start from this; the player can always aim elsewhere. + +import { isElemental, type Spell } from './spells'; +import { enemyOf, isEnchanted, type GameState, type TargetId, type WizardId } from './state'; + +export function likelyTarget(state: GameState, me: WizardId, spell: Spell): TargetId { + const foe = enemyOf(me); + const monsters = state.monsters; + const foeMonsters = monsters.filter((m) => m.owner === foe && !isElemental(m.kind)); + const strongestFoeMonster = [...foeMonsters].sort((a, b) => b.attack - a.attack)[0]; + const iceElemental = monsters.find((m) => m.kind === 'ice_elemental'); + const fireElemental = monsters.find((m) => m.kind === 'fire_elemental'); + switch (spell.id) { + case 'fireball': + return iceElemental?.id ?? foe; + case 'resist_cold': + return iceElemental?.id ?? me; + case 'resist_heat': + return fireElemental?.id ?? me; + case 'charm_monster': + return strongestFoeMonster?.id ?? foe; + case 'remove_enchantment': + return isEnchanted(state.wizards[foe]) || !strongestFoeMonster ? foe : strongestFoeMonster.id; + default: + return spell.usualTarget === 'self' ? me : foe; + } +}