From 527820ee95e6751b559518d55c1b499c1c4f0237 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 22 Sep 2026 16:07:21 -0400 Subject: [PATCH] A hand forced by amnesia or paralysis still casts what it completes The default cast was chosen only when a gesture was clicked, so a hand bound to repeat its last gesture finished its spell and let it pass. Defaults are now chosen at the start of every turn. Co-Authored-By: Claude Fable 5.1 --- src/lib/game/duel.svelte.ts | 17 +++++++++++-- src/lib/game/duel.test.ts | 51 +++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/lib/game/duel.test.ts diff --git a/src/lib/game/duel.svelte.ts b/src/lib/game/duel.svelte.ts index 32ed704..1b7e729 100644 --- a/src/lib/game/duel.svelte.ts +++ b/src/lib/game/duel.svelte.ts @@ -228,9 +228,11 @@ export class Duel { /** Opponent gestures to light up in the ledger. */ highlight = $state(null); - constructor() { + constructor(initial?: GameState) { const saved = typeof localStorage === 'undefined' ? null : loadSaved(); - if (saved) { + if (initial) { + this.state = initial; + } else if (saved) { this.state = saved.state; this.pins = saved.pins ?? { left: null, right: null }; this.sets = saved.sets ?? blankSets(); @@ -238,6 +240,16 @@ export class Duel { this.monsterOrders = saved.monsterOrders ?? {}; this.release = saved.release ?? ''; } + this.syncAll(); + } + + /** + * Choose each hand's default cast from its gestures. A hand bound by amnesia or + * paralysis can finish a spell without the player touching it, so this must run + * at the start of every turn, not only after a click. + */ + syncAll(): void { + for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h); } /** Rename the player for this duel and every duel after it. An empty name goes back to a drawn one. */ @@ -631,6 +643,7 @@ export class Duel { private resetDrafts(): void { this.sets = blankSets(); + this.syncAll(); this.stabTarget = FOE; // Monsters keep attacking what they attacked last, until told otherwise or the target is gone. const orders: Record = {}; diff --git a/src/lib/game/duel.test.ts b/src/lib/game/duel.test.ts new file mode 100644 index 0000000..7869c1d --- /dev/null +++ b/src/lib/game/duel.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { Duel } from './duel.svelte'; +import { resolveTurn } from './resolve'; +import { createGame, type GameState, type TurnInput } from './state'; +import type { Gesture } from './spells'; + +const noop = (): TurnInput => ({ left: '-', right: '-', casts: [] }); + +/** A is one gesture from a lightning bolt and has just been struck by amnesia. */ +function amnesiaBeforeLightning(): GameState { + let s = createGame({ A: 'You', B: 'Foe' }, 1); + const left = 'DFFD'; + const foe = '-DPP'; + for (let i = 0; i < 4; i++) { + s = resolveTurn(s, { + A: { ...noop(), left: left[i] as Gesture }, + B: { ...noop(), left: foe[i] as Gesture, casts: i === 3 ? [{ hand: 'left', spellId: 'amnesia' }] : [] } + }); + } + expect(s.wizards.A.constraints.amnesia).toBe(true); + return s; +} + +describe('the duel store', () => { + it('casts the spell a forced repeat completes, without a click', () => { + const duel = new Duel(amnesiaBeforeLightning()); + expect(duel.forced.left).toBe('D'); + expect(duel.completions.main.left.map((c) => c.spell.id)).toContain('lightning_bolt'); + expect(duel.sets.main.left.spellId).toBe('lightning_bolt'); + expect(duel.ready).toBe(true); + duel.reveal(); + expect(duel.foe.hp).toBe(10); + }); + + it('chooses a default cast at the start of the next turn as well', () => { + let s = createGame({ A: 'You', B: 'Foe' }, 1); + // A's left hand made D-F-F-D; B's amnesia lands as A makes the fourth gesture. + const left = 'DFFD'; + const foe = '-DPP'; + for (let i = 0; i < 3; i++) { + s = resolveTurn(s, { A: { ...noop(), left: left[i] as Gesture }, B: { ...noop(), left: foe[i] as Gesture } }); + } + const duel = new Duel(s); + duel.choose('main', 'left', 'D'); + duel.choose('main', 'right', '-'); + // The bot is replaced by a scripted foe for this test: resolve directly, then hand the store the result. + const next = resolveTurn(s, { A: { ...noop(), left: 'D' }, B: { ...noop(), left: 'P', casts: [{ hand: 'left', spellId: 'amnesia' }] } }); + const fresh = new Duel(next); + expect(fresh.sets.main.left.spellId).toBe('lightning_bolt'); + }); +});