From eaa299f683e9c8ae9463210ae0aeebb4c8eb9ba7 Mon Sep 17 00:00:00 2001 From: Indiana Date: Mon, 27 Jul 2026 15:47:39 +0000 Subject: [PATCH] fix: stale-frame race, stuck verdict buttons, and 3 more frontend defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seance.tsx / JudgmentPanel.tsx: neither `ritual_complete` nor `judgment_result` carries an entity id, and both were applied unconditionally — so a response still in flight when the seeker summoned a fresh entity landed on whatever entity happened to be current when it arrived, leaking the *previous* entity's hidden traits into the new one's revealed-traits UI. Both frames are now gated on our still waiting for one (ritual.status === 'in_progress' / judgmentPending), which the 'entity' case clears the moment a new presence arrives, so a late answer for the old entity is dropped instead of misattributed. JudgmentPanel also had `pending` in component-local state that only cleared when judgmentResult became a *new* truthy object. If the entity changed while judgmentResult was already null, the reset was a no-op (null === null) and pending stayed stuck, permanently disabling all four verdict buttons. It now reads the shared judgmentPending flag, which the reducer resets. coldSpot.ts: severity was ungated by `warm` while isColdSpot/ isPressureAnomaly were correctly gated. ColdSpotPanel feeds severity straight into the composite disturbance gauge with no boolean gate of its own, so a freshly-paired device could show "disturbance rising" off its 2nd reading — exactly what the minSamples warm-up exists to prevent. PlanchetteBoard.tsx: the first GOODBYE deadline was a bare randomBetween(120,300) compared against `t`, which is seconds since performance.timeOrigin (page load), not since mount. Every later reschedule correctly offsets from `t`. On a tab open >5min before the board mounted (or any remount via navigation), t was already past the deadline and the planchette snapped to GOODBYE on the first frame. sdr.ts: close() and setFrequency() inside the sweep loop were not wrapped in withTimeout despite the file's own header claiming every stalling USB call is. A dongle going unresponsive mid-sweep or during teardown hung forever — the same silent-hang symptom withTimeout was added to eliminate. InventoryPanel.tsx: essence was decremented client-side using a possibly stale fallback price and never reconciled. The server already returns the real post-purchase balance in PurchaseOut; use it. Co-Authored-By: Claude Opus 5 --- .../src/components/InventoryPanel.test.tsx | 33 +++++++- frontend/src/components/InventoryPanel.tsx | 12 ++- .../src/components/JudgmentPanel.test.tsx | 26 +++++-- frontend/src/components/JudgmentPanel.tsx | 15 ++-- frontend/src/components/PlanchetteBoard.tsx | 8 +- frontend/src/lib/coldSpot.ts | 11 ++- frontend/src/lib/sdr.ts | 12 +-- frontend/src/state/seance.test.ts | 75 ++++++++++++++++--- frontend/src/state/seance.tsx | 37 ++++++++- 9 files changed, 194 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/InventoryPanel.test.tsx b/frontend/src/components/InventoryPanel.test.tsx index 07707cf..5efb3bd 100644 --- a/frontend/src/components/InventoryPanel.test.tsx +++ b/frontend/src/components/InventoryPanel.test.tsx @@ -10,6 +10,10 @@ function mockFetch(handlers: { catalog?: unknown catalogStatus?: number purchaseStatus?: number + /** Body for a successful purchase — shaped like the backend's real + * PurchaseOut (app/schemas.py), which is the seeker's actual post-purchase + * essence balance, not something the client recomputes. */ + purchaseBody?: { unlock_key: string; essence: number } }) { vi.stubGlobal( 'fetch', @@ -31,7 +35,13 @@ function mockFetch(handlers: { }) } if (url.includes('/api/inventory/unlocks/') && init?.method === 'POST') { - return new Response('{}', { status: handlers.purchaseStatus ?? 200 }) + if (handlers.purchaseStatus && handlers.purchaseStatus !== 200) { + return new Response('{}', { status: handlers.purchaseStatus }) + } + return new Response( + JSON.stringify(handlers.purchaseBody ?? { unlock_key: 'listening_tool', essence: 0 }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) } return new Response('not found', { status: 404 }) }), @@ -84,10 +94,11 @@ describe('InventoryPanel', () => { expect(btn).toHaveProperty('disabled', false) // 100 essence affords the 40 fallback price }) - it('purchasing successfully moves the unlock into the owned list and deducts essence', async () => { + it('purchasing successfully moves the unlock into the owned list and updates essence to the server-reported balance', async () => { mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] }, purchaseStatus: 200, + purchaseBody: { unlock_key: 'listening_tool', essence: 60 }, }) const user = userEvent.setup() render() @@ -100,6 +111,24 @@ describe('InventoryPanel', () => { expect(screen.queryByRole('button', { name: /buy/i })).toBeNull() }) + it('trusts the server-reported essence balance even when it diverges from the client-side fallback price', async () => { + // Catalog endpoint down (fallback price 40 in play) — client would + // naively compute 100 - 40 = 60, but the server's real balance (e.g. a + // dynamic/discounted price, or another purchase racing in) is 55. + mockFetch({ + catalogStatus: 404, + purchaseStatus: 200, + purchaseBody: { unlock_key: 'listening_tool', essence: 55 }, + }) + const user = userEvent.setup() + render() + const btn = await screen.findByRole('button', { name: /buy/i }) + await waitFor(() => expect(btn).toHaveProperty('disabled', false)) + await user.click(btn) + + await waitFor(() => expect(screen.getByTestId('essence-value').textContent).toBe('55')) + }) + it('shows an insufficient-funds message on a 402 without changing essence', async () => { mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] }, diff --git a/frontend/src/components/InventoryPanel.tsx b/frontend/src/components/InventoryPanel.tsx index e6f0d7c..d104df1 100644 --- a/frontend/src/components/InventoryPanel.tsx +++ b/frontend/src/components/InventoryPanel.tsx @@ -129,9 +129,15 @@ export function InventoryPanel({ unlocks, essence: initialEssence }: InventoryPa })) return } - const price = prices[unlockKey] ?? 0 + // The server (app/routes/inventory.py's PurchaseOut) returns the + // seeker's actual post-purchase essence balance — use it directly + // rather than recomputing `essence - price` client-side, which + // could drift from the real balance whenever `prices` is running + // off FALLBACK_PRICES (catalogState === 'fallback') instead of the + // live catalog. + const data = (await res.json()) as { unlock_key: string; essence: number } setOwnedUnlocks((prev) => (prev.includes(unlockKey) ? prev : [...prev, unlockKey])) - setEssence((prev) => Math.max(0, prev - price)) + setEssence(data.essence) } catch { setPurchaseError((prev) => ({ ...prev, @@ -141,7 +147,7 @@ export function InventoryPanel({ unlocks, essence: initialEssence }: InventoryPa setPurchasing(null) } }, - [prices, purchasing, t], + [purchasing, t], ) const offers = OFFERED_UNLOCK_KEYS.filter((key) => !ownedUnlocks.includes(key)) diff --git a/frontend/src/components/JudgmentPanel.test.tsx b/frontend/src/components/JudgmentPanel.test.tsx index c484e8c..a59685c 100644 --- a/frontend/src/components/JudgmentPanel.test.tsx +++ b/frontend/src/components/JudgmentPanel.test.tsx @@ -63,9 +63,15 @@ describe('JudgmentPanel', () => { expect(sendJudgment).toHaveBeenCalledWith(verdict) }) + // `pending` is now sourced from shared state (state.judgmentPending), not + // component-local state set on click — the real SeanceProvider dispatches + // 'local_judgment_start' (arming judgmentPending) synchronously inside + // sendJudgment() before the socket send, so this is what a real click + // looks like from the component's point of view: render with it already + // armed, exactly as the provider would have it by the time React re-runs + // this component after the click. it('disables the verdict buttons while a judgment is pending', () => { - renderPanel() - fireEvent.click(screen.getByRole('button', { name: /^trust$/i })) + renderPanel({ judgmentPending: true }) for (const btn of screen.getAllByRole('button')) { expect(btn).toBeDisabled() } @@ -73,10 +79,10 @@ describe('JudgmentPanel', () => { }) it('ignores further clicks while a judgment is already pending', () => { - const { sendJudgment } = renderPanel() + const { sendJudgment } = renderPanel({ judgmentPending: true }) fireEvent.click(screen.getByRole('button', { name: /^trust$/i })) fireEvent.click(screen.getByRole('button', { name: /^banish$/i })) - expect(sendJudgment).toHaveBeenCalledTimes(1) + expect(sendJudgment).not.toHaveBeenCalled() }) it('renders a reward treatment for a correct trust', () => { @@ -159,7 +165,9 @@ describe('JudgmentPanel — pending clears on a fresh result', () => { it('clears the pending state once judgmentResult changes (rerender simulates the reducer answering)', () => { const sendJudgment = vi.fn() - const state: SeanceState = { ...initialSeanceState, entity, judgmentResult: null } + // judgmentPending: true simulates the moment right after the real + // provider's sendJudgment() dispatched 'local_judgment_start'. + const state: SeanceState = { ...initialSeanceState, entity, judgmentResult: null, judgmentPending: true } const api = { state, sendJudgment } as unknown as SeanceApi const { rerender } = render( @@ -167,10 +175,14 @@ describe('JudgmentPanel — pending clears on a fresh result', () => { , ) - fireEvent.click(screen.getByRole('button', { name: /^test$/i })) expect(screen.getByText(/awaiting the verdict/i)).toBeTruthy() - const nextApi = { ...api, state: { ...state, judgmentResult: result({ consequence: 'neutral' }) } } + // The reducer's judgment_result case clears judgmentPending back to + // false in the same update that sets judgmentResult — reproduce both. + const nextApi = { + ...api, + state: { ...state, judgmentResult: result({ consequence: 'neutral' }), judgmentPending: false }, + } rerender( diff --git a/frontend/src/components/JudgmentPanel.tsx b/frontend/src/components/JudgmentPanel.tsx index 4b8e2c7..b2c095e 100644 --- a/frontend/src/components/JudgmentPanel.tsx +++ b/frontend/src/components/JudgmentPanel.tsx @@ -7,7 +7,7 @@ // — and, distinct from all of those, a slow calm farewell fade when a // stuck spirit is correctly helped to cross over (a goodbye, not a reward). -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useSeance } from '../state/seance' import { forceEscalate } from '../lib/haunting' @@ -159,13 +159,19 @@ const VERDICTS: readonly { export function JudgmentPanel() { const { state, sendJudgment } = useSeance() const { t } = useTranslation() - const [pending, setPending] = useState(null) + // `pending` lives in shared state (state.judgmentPending), not local + // component state — the reducer resets it the moment a fresh entity + // arrives, which is also what lets it correctly ignore a stale + // judgment_result answer from whatever entity was summoned before this + // one. A local boolean here couldn't observe that reset (it only clears + // on a *new* judgmentResult object) and could get stuck permanently + // disabling every verdict button after a mid-flight re-summon. + const pending = state.judgmentPending const seenResultRef = useRef(state.judgmentResult) useEffect(() => { if (state.judgmentResult && state.judgmentResult !== seenResultRef.current) { seenResultRef.current = state.judgmentResult - setPending(null) // The wrongly-trusted consequence lashes the ambient haunting // immediately rather than waiting for the normal idle escalation. if (state.judgmentResult.consequence === 'escalation') forceEscalate() @@ -185,7 +191,6 @@ export function JudgmentPanel() { const cast = (verdict: JudgmentVerdict) => { if (pending) return - setPending(verdict) sendJudgment(verdict) } @@ -203,7 +208,7 @@ export function JudgmentPanel() { type="button" className={`verdict-btn ${cls}`} onClick={() => cast(verdict)} - disabled={pending !== null} + disabled={pending} aria-label={t(labelKey, { defaultValue: fallback })} > diff --git a/frontend/src/components/PlanchetteBoard.tsx b/frontend/src/components/PlanchetteBoard.tsx index 60f3d9e..c6840c7 100644 --- a/frontend/src/components/PlanchetteBoard.tsx +++ b/frontend/src/components/PlanchetteBoard.tsx @@ -102,7 +102,13 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) { // glides down and rests on GOODBYE for a few seconds before resuming // normal idle wander. `t`-relative (seconds since mount), not wall-clock. const randomBetween = (min: number, max: number) => min + Math.random() * (max - min) - let nextGoodbyeAt = randomBetween(120, 300) + // `t` inside draw() is seconds since performance.timeOrigin (page load), + // not since this effect ran — so the first deadline must be offset from + // the current clock the same way every later reschedule is (line ~197), + // or a component mounted well after page load (e.g. remounted by + // navigating back to this route) would see t already past a bare + // 120-300 value and jump straight to GOODBYE on the first frame. + let nextGoodbyeAt = last / 1000 + randomBetween(120, 300) let goodbyeRestUntil = 0 // Spirit knock: an AudioContext created lazily on the first gesture diff --git a/frontend/src/lib/coldSpot.ts b/frontend/src/lib/coldSpot.ts index cc87d1e..ccefd7f 100644 --- a/frontend/src/lib/coldSpot.ts +++ b/frontend/src/lib/coldSpot.ts @@ -194,7 +194,12 @@ export function applyTemperatureReading( ) const drop = deviation !== null ? Math.max(0, -deviation) : 0 const isColdSpot = warm && drop >= COLD_SPOT_DROP_C - const severity = clamp01(drop / (COLD_SPOT_DROP_C * 3)) + // Gated by `warm` for the same reason isColdSpot is: pre-warm-up + // deviations are against a baseline that hasn't had a real chance to + // average out sensor noise, and severity feeds the composite disturbance + // gauge directly (ColdSpotPanel.tsx), which has no boolean gate of its + // own to catch an ungated value here. + const severity = warm ? clamp01(drop / (COLD_SPOT_DROP_C * 3)) : 0 return { state: next, deviation, isColdSpot, severity } } @@ -252,7 +257,9 @@ export function applyPressureReading( ) const swing = deviation !== null ? Math.abs(deviation) : 0 const isPressureAnomaly = warm && swing >= PRESSURE_SWING_HPA - const severity = clamp01(swing / (PRESSURE_SWING_HPA * 3)) + // See applyTemperatureReading's comment: gated by `warm` so the composite + // disturbance gauge can't be driven by a pre-warm-up baseline swing. + const severity = warm ? clamp01(swing / (PRESSURE_SWING_HPA * 3)) : 0 const direction: 'rise' | 'drop' = deviation !== null && deviation < 0 ? 'drop' : 'rise' return { state: next, deviation, isPressureAnomaly, severity, direction } } diff --git a/frontend/src/lib/sdr.ts b/frontend/src/lib/sdr.ts index 0168078..c1617a8 100644 --- a/frontend/src/lib/sdr.ts +++ b/frontend/src/lib/sdr.ts @@ -196,12 +196,14 @@ export class RtlSdr { const dev = this.device if (dev?.opened) { try { - await this.demodWrite(1, 0x01, 0x10, 1) // suspend + await withTimeout(this.demodWrite(1, 0x01, 0x10, 1), 3_000, 'RTL-SDR suspend on close') // suspend } catch { - /* device may already be gone */ + /* device may already be gone, or stopped responding — proceed to release/close anyway */ } - await dev.releaseInterface(this.interfaceNumber).catch(() => undefined) - await dev.close().catch(() => undefined) + await withTimeout(dev.releaseInterface(this.interfaceNumber), 3_000, 'RTL-SDR releaseInterface').catch( + () => undefined, + ) + await withTimeout(dev.close(), 3_000, 'RTL-SDR device close').catch(() => undefined) } } @@ -271,7 +273,7 @@ export class RtlSdr { const readBytes = fftSize * 2 * 2 // 2 samples per fft point, unsigned iq try { while (this.running) { - await this.setFrequency(hz) + await withTimeout(this.setFrequency(hz), 3_000, 'PLL retune') await new Promise((r) => setTimeout(r, settleMs)) try { await this.readSamples(16384) // discard: PLL settle diff --git a/frontend/src/state/seance.test.ts b/frontend/src/state/seance.test.ts index dacc942..c1789bb 100644 --- a/frontend/src/state/seance.test.ts +++ b/frontend/src/state/seance.test.ts @@ -305,6 +305,7 @@ describe('seanceReducer — ritual', () => { success: true, revealed: { alignment: 1, power: 1, volatility: 1, deceptiveness: 1 }, }) + state = seanceReducer(state, { type: 'local_judgment_start' }) state = frame(state, { type: 'judgment_result', correct: true, @@ -319,6 +320,35 @@ describe('seanceReducer — ritual', () => { state = frame(state, { type: 'entity', is_new: false, entity }) expect(state.ritual).toEqual({ status: 'idle', revealed: null }) expect(state.judgmentResult).toBeNull() + expect(state.judgmentPending).toBe(false) + }) + + it('drops a ritual_complete that arrives when no ritual is in_progress (stale answer for a previous entity)', () => { + // No local_ritual_start dispatched — nothing is in flight — so this + // mirrors an answer to a *previous* entity's ritual arriving after a + // fresh 'entity' frame already reset ritual.status back to 'idle'. + const state = frame(initialSeanceState, { + type: 'ritual_complete', + success: true, + revealed: { alignment: 1, power: 1, volatility: 1, deceptiveness: 1 }, + }) + expect(state.ritual).toEqual({ status: 'idle', revealed: null }) + }) + + it('drops a ritual_complete for an entity that has already moved on to a new one', () => { + let state = seanceReducer(initialSeanceState, { type: 'local_ritual_start' }) + // A fresh entity arrives before the old ritual's answer does. + state = frame(state, { type: 'entity', is_new: true, entity }) + expect(state.ritual).toEqual({ status: 'idle', revealed: null }) + + // The stale answer for the old entity now arrives — must not resurrect + // ritual UI (and must not leak the old entity's revealed traits). + state = frame(state, { + type: 'ritual_complete', + success: true, + revealed: { alignment: 1, power: 1, volatility: 1, deceptiveness: 1 }, + }) + expect(state.ritual).toEqual({ status: 'idle', revealed: null }) }) }) @@ -343,9 +373,17 @@ describe('seanceReducer — judgment_result', () => { } } + // A real judgment_result answer is only ever applied while judgmentPending + // is armed (see the stale-frame tests at the bottom of this block), so + // every "records a ..." test below arms it first via local_judgment_start, + // exactly as sendJudgment() does before sending the real WS frame. + function pending(state: SeanceState = initialSeanceState): SeanceState { + return seanceReducer(state, { type: 'local_judgment_start' }) + } + it('records a correct-trust reward', () => { const state = frame( - initialSeanceState, + pending(), judgmentFrame({ correct: true, favor_delta: 0.05, essence_delta: 15, consequence: 'reward' }), ) expect(state.judgmentResult).toMatchObject({ @@ -355,11 +393,12 @@ describe('seanceReducer — judgment_result', () => { atPeace: false, consequence: 'reward', }) + expect(state.judgmentPending).toBe(false) }) it('records a wrong-trust escalation', () => { const state = frame( - initialSeanceState, + pending(), judgmentFrame({ correct: false, favor_delta: -0.12, essence_delta: 0, consequence: 'escalation' }), ) expect(state.judgmentResult).toMatchObject({ consequence: 'escalation', favorDelta: -0.12 }) @@ -367,7 +406,7 @@ describe('seanceReducer — judgment_result', () => { it('records a wrong-banish withdrawal with a smaller favor penalty than escalation', () => { const state = frame( - initialSeanceState, + pending(), judgmentFrame({ correct: false, favor_delta: -0.06, essence_delta: 0, consequence: 'withdrawal' }), ) expect(state.judgmentResult).toMatchObject({ consequence: 'withdrawal', favorDelta: -0.06 }) @@ -375,7 +414,7 @@ describe('seanceReducer — judgment_result', () => { it('records a correctly-called cross_over with at_peace true and the largest essence reward', () => { const state = frame( - initialSeanceState, + pending(), judgmentFrame({ correct: true, favor_delta: 0.05, essence_delta: 30, at_peace: true, consequence: 'crossed_over' }), ) expect(state.judgmentResult).toMatchObject({ @@ -386,25 +425,43 @@ describe('seanceReducer — judgment_result', () => { }) it('records a cross_over attempted on a demon as resisted, with zero deltas', () => { - const state = frame(initialSeanceState, judgmentFrame({ correct: false, consequence: 'resisted' })) + const state = frame(pending(), judgmentFrame({ correct: false, consequence: 'resisted' })) expect(state.judgmentResult).toMatchObject({ consequence: 'resisted', favorDelta: 0, essenceDelta: 0 }) }) it('records a test verdict (or a judgment without a completed ritual) as neutral with zero deltas', () => { - const state = frame(initialSeanceState, judgmentFrame({ correct: false, consequence: 'neutral' })) + const state = frame(pending(), judgmentFrame({ correct: false, consequence: 'neutral' })) expect(state.judgmentResult).toMatchObject({ consequence: 'neutral', favorDelta: 0, essenceDelta: 0 }) }) it('logs a system transcript row alongside the stored result', () => { - const state = frame(initialSeanceState, judgmentFrame({ consequence: 'crossed_over' })) + const state = frame(pending(), judgmentFrame({ consequence: 'crossed_over' })) const row = lastRow(state) expect(row.kind).toBe('system') if (row.kind === 'system') expect(row.text).toMatch(/at peace/) }) it('overwrites the previous judgmentResult on a second judgment', () => { - let state = frame(initialSeanceState, judgmentFrame({ consequence: 'reward' })) - state = frame(state, judgmentFrame({ consequence: 'withdrawal' })) + let state = frame(pending(), judgmentFrame({ consequence: 'reward' })) + state = frame(pending(state), judgmentFrame({ consequence: 'withdrawal' })) expect(state.judgmentResult?.consequence).toBe('withdrawal') }) + + it('drops a judgment_result that arrives with nothing pending (stale answer for a previous entity)', () => { + const state = frame(initialSeanceState, judgmentFrame({ consequence: 'reward' })) + expect(state.judgmentResult).toBeNull() + }) + + it('drops a judgment_result for an entity that has already moved on to a new one', () => { + let state = pending() + // A fresh entity arrives before the old judgment's answer does — this + // resets judgmentPending back to false (see the 'entity' frame case). + state = frame(state, { type: 'entity', is_new: true, entity }) + expect(state.judgmentPending).toBe(false) + + // The stale answer for the old entity now arrives — must not resurrect + // judgment UI for the new entity. + state = frame(state, judgmentFrame({ consequence: 'reward' })) + expect(state.judgmentResult).toBeNull() + }) }) diff --git a/frontend/src/state/seance.tsx b/frontend/src/state/seance.tsx index 1a27938..8627637 100644 --- a/frontend/src/state/seance.tsx +++ b/frontend/src/state/seance.tsx @@ -95,6 +95,13 @@ export type SeanceState = { speakingId: string | null ritual: RitualUiState judgmentResult: JudgmentResult | null + /** True from the moment a `judgment` frame is sent until its + * `judgment_result` answer arrives (or the entity changes underneath + * it). Lives in shared state, not component-local, specifically so the + * `judgment_result` frame handler can tell a genuine answer to the + * *current* entity's judgment apart from a stale answer to a *previous* + * entity's judgment arriving late — see that case below. */ + judgmentPending: boolean } const IDLE_RITUAL: RitualUiState = { status: 'idle', revealed: null } @@ -118,6 +125,7 @@ export const initialSeanceState: SeanceState = { speakingId: null, ritual: IDLE_RITUAL, judgmentResult: null, + judgmentPending: false, } // ---- Actions ---- @@ -141,6 +149,7 @@ export type SeanceAction = | { type: 'set_speaking'; utteranceId: string | null } | { type: 'dismiss_toast'; id: number } | { type: 'local_ritual_start' } + | { type: 'local_judgment_start' } | { type: 'reset' } function pushCapped(arr: T[], item: T, cap: number): T[] { @@ -234,6 +243,9 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS case 'local_ritual_start': return { ...state, ritual: { status: 'in_progress', revealed: null } } + case 'local_judgment_start': + return { ...state, judgmentPending: true } + case 'reset': return { ...initialSeanceState } @@ -259,9 +271,14 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS entityIsNew: frame.is_new, status: null, // A fresh presence invalidates any ritual/judgment history from - // whatever was summoned before it. + // whatever was summoned before it. Resetting `ritual.status` + // away from 'in_progress' and `judgmentPending` to false here is + // also what lets the frame handlers below detect and drop a + // stale ritual_complete/judgment_result that was still in + // flight for the *previous* entity when this one arrived. ritual: IDLE_RITUAL, judgmentResult: null, + judgmentPending: false, transcript: pushCapped( state.transcript, { @@ -423,6 +440,16 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS }) case 'ritual_complete': { + // Neither this frame nor `judgment_result` below carries an + // entity id, so a response that was still in flight when the + // seeker summoned a fresh entity (e.g. clicked summon again + // before the old ritual answered) would otherwise land on + // whatever entity happens to be current when it finally arrives. + // Only apply it while we're actually still waiting on *a* + // ritual — the 'entity' case above resets this to 'idle' the + // moment a new presence arrives, so a late answer for the old + // entity finds `status !== 'in_progress'` here and is dropped. + if (state.ritual.status !== 'in_progress') return state const text = frame.success ? '⟁ the channel holds — the truth surfaces' : '⟁ the channel collapses' @@ -441,6 +468,9 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS } case 'judgment_result': { + // Same stale-frame guard as ritual_complete above, keyed off + // `judgmentPending` instead of `ritual.status`. + if (!state.judgmentPending) return state const result: JudgmentResult = { correct: frame.correct, favorDelta: frame.favor_delta, @@ -452,6 +482,7 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS return { ...state, judgmentResult: result, + judgmentPending: false, transcript: pushCapped( state.transcript, { id: nextId(), kind: 'system', text: judgmentTranscriptLine(frame.consequence), at }, @@ -605,6 +636,10 @@ export function SeanceProvider({ children }: { children: ReactNode }) { }, []) const sendJudgment = useCallback((verdict: JudgmentVerdict) => { + // Local-first, same pattern as startRitual() above — also what arms + // the judgmentPending guard the reducer uses to drop a stale + // judgment_result from a previous entity (see the frame case). + dispatch({ type: 'local_judgment_start' }) socketRef.current?.send({ type: 'judgment', verdict }) }, [])