From 825a73b154b4fa62138a9a58365a8057005d7909 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 27 Aug 2026 19:15:55 -0400 Subject: [PATCH] The cockpit's spells fire: fx timers survive the state message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lightning blasts at an adjacent wizard drew nothing in first person. The fx pipeline staged the bolt correctly — then died unfired: the server sends `events` and `state` back to back, the state message re-derives `me`, and the fx effect tracked it, so Svelte ran the cleanup — which cleared every pending timer and stripped in-flight sprites — before the bolt's zero-delay start could fire. Every live cast effect in the pane died this way since the pane was born. The effect now tracks only the batch; everything else reads under untrack, and timers are cleared only when the component goes down. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/web/src/LiveFirstPerson.svelte | 40 +++++++++++++++---------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/web/src/LiveFirstPerson.svelte b/packages/web/src/LiveFirstPerson.svelte index d55a910..28c0cd9 100644 --- a/packages/web/src/LiveFirstPerson.svelte +++ b/packages/web/src/LiveFirstPerson.svelte @@ -224,25 +224,35 @@ let actorPos = $state>({}); // --- The fx: each batch plays once, on its own beat. ----------------- + // The effect must track ONLY the batch: the server sends `events` then + // `state` back to back, so anything else in the dependency set (me, the + // view) re-runs this before a zero-delay timer can fire — and an eager + // cleanup would cancel every spell before it left the wand. Timers die + // only with the component. let playedBatch = 0; + const fxTimers = new Set>(); $effect(() => { const b = batch; - if (!b || b.n === playedBatch || !me) return; - playedBatch = b.n; - const timers: ReturnType[] = []; - const started: number[] = []; - for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) { - timers.push(setTimeout(() => { - started.push(fx.id); - fpFx = [...fpFx, { ...fx, t0: performance.now() }]; - timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80)); - }, delay)); - } - return () => { - timers.forEach(clearTimeout); - started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id))); - }; + if (!b) return; + untrack(() => { + if (b.n === playedBatch || !me) return; + playedBatch = b.n; + for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) { + const starter = setTimeout(() => { + fxTimers.delete(starter); + fpFx = [...fpFx, { ...fx, t0: performance.now() }]; + const ender = setTimeout(() => { + fxTimers.delete(ender); + fpFx = fpFx.filter((f) => f.id !== fx.id); + }, fx.dur + 80); + fxTimers.add(ender); + }, delay); + fxTimers.add(starter); + } + }); + return undefined; }); + $effect(() => () => fxTimers.forEach(clearTimeout)); // --- Other bodies glide between views. ------------------------------- let prevView: GameView | null = null;