From 44c14f5ce1b2fd3c067608af58ae3ca6bd9ec8bb Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 13:45:52 -0400 Subject: [PATCH] The interruption window opens for actual casting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OPPORTUNITY FIRE and INTERRUPT bought a moment the client would not honor: every selection and targeting gate asked "is it your turn?", and mid-interruption it is not — so the window's holder could click attack cards into silence while the engine sat ready to accept the cast. A window is now yourMoment: hand selection, player/creature/ cell/edge targeting, and the dimming aid all open exactly as on your own turn, and the slip says what to do with it ("pick an attack card and a target") instead of the cryptic "cast now". Co-Authored-By: Claude Fable 5 --- packages/web/src/App.svelte | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 82f8711..8d1d2e1 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -144,6 +144,11 @@ view !== null && view.phase === "playing" && view.activePlayerId === view.you && !view.stack, ); const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you); + /** An interruption window (INTERRUPT / OPPORTUNITY FIRE) is your moment: + * casting and targeting open up as if the turn were yours. */ + const yourMoment = $derived( + isYourTurn || (view != null && view.outOfTurnWindow?.playerId === view.you), + ); const attackNoticeKey = $derived( youMustRespond && view?.stack ? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? view.stack.creatureId ?? "punch"}:${view.stack.counters.length}` @@ -379,7 +384,7 @@ return; } if (actionsSpent) return; // picking up an object ended the turn's actions - if (!isYourTurn) { + if (!isYourTurn && !yourMoment) { // Live interruption: Interrupt / Opportunity Fire may be played during // another player's turn (when no attack is pending). if (!view.stack && (card.cardId === "interrupt" || card.cardId === "opportunity-fire")) { @@ -497,7 +502,7 @@ clearSelection(); return; } - if (!isYourTurn) { peekAt(cell); return; } + if (!yourMoment) { peekAt(cell); return; } if (pendingCellFor && selectedCard) { // Stage 2 of teleport-opponent: destination chosen. dispatch({ @@ -727,7 +732,7 @@ if (!view) return; const creature = view.creatures.find((c) => c.id === creatureId); if (!creature) return; - if (!isYourTurn) { peekAt(creature.position); return; } + if (!yourMoment) { peekAt(creature.position); return; } if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) { // Cell-target spells aimed at a creature (DISPEL CREATION on a wraith) // land on its square — the engine finds the creature there. @@ -762,7 +767,7 @@ } function clickPlayer(playerId: string) { - if (!view || !isYourTurn) return; + if (!view || !yourMoment) return; if (selectedCreature) { dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId }); selectedCreature = null; @@ -824,7 +829,7 @@ ); /** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */ const litCells = $derived.by(() => { - if (!view || !selectedDef || !isYourTurn) return null; + if (!view || !selectedDef || !yourMoment) return null; if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future if (!me) return null; if (CELL_CARDS.has(selectedCard!.cardId)) { @@ -1308,7 +1313,7 @@
Waiting on {view.stack.waitingOn}… {:else if view.outOfTurnWindow?.playerId === view.you} -
Your interruption — cast now, or
+
Your interruption — pick an attack card and a target, or
{:else if isYourTurn}
Your turn — round {view.turn.round}.