Compare commits

...
2 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5.1 81acbd2172 The target dropdown starts on the likeliest subject
One module decides where a spell most likely goes given the field (a
fireball at an ice elemental, charm monster at their strongest creature,
remove enchantment at the wizard only if enchanted); the bot and the
dropdown both use it. The dropdown lists that subject first and marks
creatures by whose they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 16:42:44 -04:00
Eric WagonerandClaude Fable 5.1 dd6ff93606 A counter-spell aimed at the other wizard is explained, and named when it blocks
Blocked blows now say what blocked them (shield, counter-spell, dispel
magic or protection from evil), the first counter-spell cast at the
other wizard earns a one-time explanation that it protects its subject,
and the spell's description says to cast it on yourself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 16:41:25 -04:00
7 changed files with 112 additions and 55 deletions

No files matched your search

+4 -35
View File
@@ -16,6 +16,7 @@ import {
type TurnGestures type TurnGestures
} from './gestures'; } from './gestures';
import { allowedGestures, forcedGesture } from './resolve'; 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 { GESTURES, HANDS, HOSTILE_SPELLS, MAGIC_GESTURES, SPELLS, SPELL_BY_ID, isElemental, type Gesture, type Hand, type Spell, type SpellId } from './spells';
import { import {
MAX_HP, MAX_HP,
@@ -330,41 +331,9 @@ function foeDangerousHand(s: Situation): Hand {
} }
function targetFor(s: Situation, spell: Spell, choice: CastChoice): void { function targetFor(s: Situation, spell: Spell, choice: CastChoice): void {
const meW = s.state.wizards[s.me]; choice.target = likelyTarget(s.state, s.me, spell);
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe); if (spell.id === 'summon_elemental') choice.elemental = s.state.wizards[s.me].resistCold ? 'ice' : 'fire';
const strongestFoeMonster = [...foeMonsters].sort((a, b) => b.attack - a.attack)[0]; if (spell.id === 'paralysis' || spell.id === 'charm_person') choice.chosenHand = foeDangerousHand(s);
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; if (spell.category === 'summons') choice.monsterTarget = s.foe;
} }
+15 -9
View File
@@ -14,6 +14,7 @@ import {
} from './gestures'; } from './gestures';
import { glyph } from './glyphs'; import { glyph } from './glyphs';
import { allowedGestures, forcedGesture, resolveTurn } from './resolve'; import { allowedGestures, forcedGesture, resolveTurn } from './resolve';
import { likelyTarget } from './targets';
import { import {
GESTURES, GESTURES,
HANDS, HANDS,
@@ -72,11 +73,6 @@ export interface PlannedCast {
turnCount: number; turnCount: number;
} }
function defaultTarget(spell: Spell | undefined): TargetId | '' {
if (!spell) return '';
return spell.usualTarget === 'self' ? YOU : FOE;
}
function blankDraft(): HandDraft { function blankDraft(): HandDraft {
return { gesture: null, spellId: '', target: '', elemental: 'fire', chosenHand: 'left' }; 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); 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 }[] { targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] {
const list: { id: TargetId; label: string }[] = [ const list: { id: TargetId; label: string }[] = [
{ id: YOU, label: 'yourself' }, { id: YOU, label: 'yourself' },
{ id: FOE, label: this.foe.name } { 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)' }); if (spell && spell.id !== 'fire_storm' && spell.id !== 'ice_storm') list.push({ id: NOWHERE, label: 'nowhere (let it fade)' });
return list; return list;
} }
@@ -563,13 +569,13 @@ export class Duel {
const options = this.completions[set][hand]; const options = this.completions[set][hand];
if (!options.some((c) => c.spell.id === draft.spellId)) { if (!options.some((c) => c.spell.id === draft.spellId)) {
draft.spellId = options[0]?.spell.id ?? ''; 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 { setSpell(set: SetName, hand: Hand, id: SpellId | ''): void {
this.sets[set][hand].spellId = id; 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 { private castFor(p: PlannedCast): CastChoice {
@@ -578,7 +584,7 @@ export class Duel {
hand: p.hand, hand: p.hand,
spellId: spell.id, spellId: spell.id,
seqIndex: p.completion.seqIndex, 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 === 'summon_elemental') cast.elemental = p.draft.elemental;
if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand; if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand;
+12 -1
View File
@@ -289,7 +289,7 @@ describe('haste, time stop, delayed effect and permanency', () => {
// The snap made in the stopped moment still counts toward a missile. // The snap made in the stopped moment still counts toward a missile.
s = play(s, { right: 'D', casts: [{ hand: 'right', spellId: 'missile' }] }); s = play(s, { right: 'D', casts: [{ hand: 'right', spellId: 'missile' }] });
expect(s.wizards.B.hp).toBe(14); expect(s.wizards.B.hp).toBe(14);
expect(s.log.some((l) => l.text.includes('shield turns the missile aside'))).toBe(true); expect(s.log.some((l) => l.text.includes('protection from evil turns the missile aside'))).toBe(true);
}); });
it('delayed effect banks the next spell until it is released', () => { it('delayed effect banks the next spell until it is released', () => {
@@ -394,4 +394,15 @@ describe('lessons', () => {
s = runSequence(s, 'A', 'SSDD', '----', { casts: [{ hand: 'left', spellId: 'fireball' }] }); s = runSequence(s, 'A', 'SSDD', '----', { casts: [{ hand: 'left', spellId: 'fireball' }] });
expect(s.lastTurn?.lessons.some((l) => l.includes('reused gestures from the earlier Paralysis'))).toBe(true); expect(s.lastTurn?.lessons.some((l) => l.includes('reused gestures from the earlier Paralysis'))).toBe(true);
}); });
it('explains a counter-spell aimed at the other wizard, who it then protects', () => {
let s = createGame({ A: 'Black', B: 'White' }, 1);
s = runSequence(s, 'A', 'SFW', '---', { casts: [{ hand: 'left', spellId: 'summon_goblin', monsterTarget: 'B' }] });
expect(s.wizards.B.hp).toBe(14);
// The goblin lands twice more while the counter-spell is made, then the counter-spell shields White from it.
s = runSequence(s, 'A', 'WWS', '---', { casts: [{ hand: 'left', spellId: 'counter_spell', target: 'B' }] });
expect(s.wizards.B.hp).toBe(12);
expect(s.log.some((l) => l.text === "Goblin's blows glance off White's counter-spell.")).toBe(true);
expect(s.lastTurn?.lessons.some((l) => l.includes('protects its subject, not its caster'))).toBe(true);
});
}); });
+24 -9
View File
@@ -200,7 +200,9 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
overlaps: [] as { name: string; spell: string; earlier: string }[], overlaps: [] as { name: string; spell: string; earlier: string }[],
broken: [] as { name: string; hand: Hand }[], broken: [] as { name: string; hand: Hand }[],
curedAndHurt: [] as { name: string; hurt: number; healed: number }[], curedAndHurt: [] as { name: string; hurt: number; healed: number }[],
newMonsterAttacked: '' newMonsterAttacked: '',
/** A counter-spell aimed at the other wizard, who is then protected by it. */
counterAimedAway: '' as string
}; };
// ---- 1. Gestures, after last turn's enchantments have their say ---- // ---- 1. Gestures, after last turn's enchantments have their say ----
@@ -276,7 +278,8 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
// ---- 2. Which completed spells are actually loosed ---- // ---- 2. Which completed spells are actually loosed ----
const casts: ActiveCast[] = []; const casts: ActiveCast[] = [];
const shielded = new Set<TargetId>(); /** Who is shielded this turn, and by what, so a blocked blow can name it. */
const shielded = new Map<TargetId, string>();
for (const id of acting) { for (const id of acting) {
const w = W[id]; const w = W[id];
const accepted: ActiveCast[] = []; const accepted: ActiveCast[] = [];
@@ -364,7 +367,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (dispels.length > 0) { if (dispels.length > 0) {
log('Dispel magic sweeps the field: every other spell fails, all enchantments end, and every monster will fall after this turn.', 'spell'); log('Dispel magic sweeps the field: every other spell fails, all enchantments end, and every monster will fall after this turn.', 'spell');
for (const c of casts) if (!c.nullified && c.spell.id !== 'dispel_magic') c.nullified = 'dispelled'; for (const c of casts) if (!c.nullified && c.spell.id !== 'dispel_magic') c.nullified = 'dispelled';
for (const d of dispels) shielded.add(d.target); for (const d of dispels) shielded.set(d.target, 'dispel magic');
for (const id of IDS) clearEnchantments(W[id]); for (const id of IDS) clearEnchantments(W[id]);
for (const m of state.monsters) { for (const m of state.monsters) {
clearMonsterEnchantments(m); clearMonsterEnchantments(m);
@@ -441,11 +444,17 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
// ---- 6. Who is shielded this turn ---- // ---- 6. Who is shielded this turn ----
for (const c of live()) { for (const c of live()) {
if (['shield', 'counter_spell', 'dispel_magic', 'protection_from_evil'].includes(c.spell.id)) shielded.add(c.target); if (c.spell.id === 'shield') shielded.set(c.target, 'shield');
if (c.spell.id === 'counter_spell') shielded.set(c.target, 'counter-spell');
if (c.spell.id === 'protection_from_evil') shielded.set(c.target, 'protection from evil');
if (c.spell.id === 'counter_spell' && isWizardId(c.target) && c.target !== c.caster) {
facts.counterAimedAway = `${W[c.caster].name}'s counter-spell on ${W[c.target].name}`;
} }
for (const id of IDS) if (W[id].protectionTurns > 0) shielded.add(id); }
for (const m of state.monsters) if (m.protectionTurns > 0) shielded.add(m.id); for (const id of IDS) if (W[id].protectionTurns > 0 && !shielded.has(id)) shielded.set(id, 'protection from evil');
for (const m of state.monsters) if (m.protectionTurns > 0 && !shielded.has(m.id)) shielded.set(m.id, 'protection from evil');
const isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id); const isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id);
const guard = (id: TargetId) => `${beingName(state, id)}'s ${shielded.get(id) ?? 'shield'}`;
// ---- 7. Summons, then storms and elementals ---- // ---- 7. Summons, then storms and elementals ----
for (const c of live()) { for (const c of live()) {
@@ -731,7 +740,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
case 'missile': case 'missile':
if (isShielded(t)) { if (isShielded(t)) {
facts.shieldBlocked.add(t); facts.shieldBlocked.add(t);
log(`${beingName(state, t)}'s shield turns the missile aside.`, 'fizzle'); log(`${guard(t)} turns the missile aside.`, 'fizzle');
} else hurt(t, 1, 'a missile'); } else hurt(t, 1, 'a missile');
break; break;
case 'finger_of_death': case 'finger_of_death':
@@ -826,7 +835,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
} }
if (isShielded(target)) { if (isShielded(target)) {
facts.shieldBlocked.add(target); facts.shieldBlocked.add(target);
log(`${m.name}'s blows glance off ${beingName(state, target)}'s shield.`, 'fizzle'); log(`${m.name}'s blows glance off ${guard(target)}.`, 'fizzle');
} else if (isWizardId(target) && W[target].invisibleTurns > 0 && !frozen(target)) { } else if (isWizardId(target) && W[target].invisibleTurns > 0 && !frozen(target)) {
log(`${m.name} flails at the air where ${W[target].name} used to be.`, 'fizzle'); log(`${m.name} flails at the air where ${W[target].name} used to be.`, 'fizzle');
} else { } else {
@@ -846,7 +855,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
log(`${w.name} stabs at nothing.`, 'fizzle'); log(`${w.name} stabs at nothing.`, 'fizzle');
} else if (isShielded(target)) { } else if (isShielded(target)) {
facts.shieldBlocked.add(target); facts.shieldBlocked.add(target);
log(`${w.name}'s knife skids off ${beingName(state, target)}'s shield.`, 'fizzle'); log(`${w.name}'s knife skids off ${guard(target)}.`, 'fizzle');
} else { } else {
hurt(target, 1, `${w.name}'s knife`); hurt(target, 1, `${w.name}'s knife`);
} }
@@ -960,6 +969,12 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
text: `A shield stops only creatures, missiles and stabs, so it did nothing against ${facts.throughShield[0].spell}. Counter-spell is the defence against other spells.` text: `A shield stops only creatures, missiles and stabs, so it did nothing against ${facts.throughShield[0].spell}. Counter-spell is the defence against other spells.`
}); });
} }
if (facts.counterAimedAway) {
lessons.push({
key: 'counter-aim',
text: `A counter-spell protects its subject, not its caster: ${facts.counterAimedAway} cancelled spells aimed at them and shielded them from creatures, missiles and stabs. To stop what the other wizard casts at you, cast the counter-spell on yourself.`
});
}
if (facts.fingerThroughCounter) { if (facts.fingerThroughCounter) {
lessons.push({ key: 'counter-finger', text: 'A counter-spell cannot stop finger of death. Only dispel magic can, or an anti-spell before the eight gestures are finished.' }); lessons.push({ key: 'counter-finger', text: 'A counter-spell cannot stop finger of death. Only dispel magic can, or an anti-spell before the eight gestures are finished.' });
} else if (facts.counterSmothered > 0) { } else if (facts.counterSmothered > 0) {
+1 -1
View File
@@ -116,7 +116,7 @@ export const SPELLS: Spell[] = [
spell('shield', 'Shield', 'protection', ['P'], 'self', 'Blocks monsters, missiles and stabs against the subject this turn.'), spell('shield', 'Shield', 'protection', ['P'], 'self', 'Blocks monsters, missiles and stabs against the subject this turn.'),
spell('remove_enchantment', 'Remove enchantment', 'protection', ['P-D-W-P'], 'enemy', 'Ends every enchantment on the subject. Destroys a monster after it attacks.'), spell('remove_enchantment', 'Remove enchantment', 'protection', ['P-D-W-P'], 'enemy', 'Ends every enchantment on the subject. Destroys a monster after it attacks.'),
spell('magic_mirror', 'Magic mirror', 'protection', ['C-(w'], 'self', 'Spells cast at the subject this turn rebound on their caster. Undone by a counter-spell.'), spell('magic_mirror', 'Magic mirror', 'protection', ['C-(w'], 'self', 'Spells cast at the subject this turn rebound on their caster. Undone by a counter-spell.'),
spell('counter_spell', 'Counter-spell', 'protection', ['W-P-P', 'W-W-S'], 'self', 'Other spells cast at the subject this turn fail, and the subject is shielded. Cannot stop finger of death or dispel magic.'), spell('counter_spell', 'Counter-spell', 'protection', ['W-P-P', 'W-W-S'], 'self', 'Every other spell cast at the subject this turn fails, and the subject is shielded. Cast it on yourself to stop what the other wizard casts at you. Cannot stop finger of death or dispel magic.'),
spell('dispel_magic', 'Dispel magic', 'protection', ['C-D-P-W'], 'self', 'Every other spell this turn fails, all enchantments end, all monsters die after attacking. Shields the subject.'), spell('dispel_magic', 'Dispel magic', 'protection', ['C-D-P-W'], 'self', 'Every other spell this turn fails, all enchantments end, all monsters die after attacking. Shields the subject.'),
spell('raise_dead', 'Raise dead', 'protection', ['D-W-W-F-W-C'], 'self', 'Heals 5 damage and removes all enchantments from the subject.'), spell('raise_dead', 'Raise dead', 'protection', ['D-W-W-F-W-C'], 'self', 'Heals 5 damage and removes all enchantments from the subject.'),
spell('cure_light_wounds', 'Cure light wounds', 'protection', ['D-F-W'], 'self', 'Heals 1 damage.'), spell('cure_light_wounds', 'Cure light wounds', 'protection', ['D-F-W'], 'self', 'Heals 1 damage.'),
+28
View File
@@ -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');
});
});
+28
View File
@@ -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;
}
}