fix: stale-frame race, stuck verdict buttons, and 3 more frontend defects

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 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-27 15:47:39 +00:00
parent 31f3f91801
commit eaa299f683
9 changed files with 194 additions and 35 deletions

View File

@@ -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<JudgmentVerdict | null>(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<JudgmentResult | null>(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 })}
>
<Icon />