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:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user