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:
@@ -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(<InventoryPanel unlocks={[]} essence={100} />)
|
||||
@@ -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(<InventoryPanel unlocks={[]} essence={100} />)
|
||||
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 }] },
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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', () => {
|
||||
<JudgmentPanel />
|
||||
</SeanceContext.Provider>,
|
||||
)
|
||||
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(
|
||||
<SeanceContext.Provider value={nextApi}>
|
||||
<JudgmentPanel />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<T>(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 })
|
||||
}, [])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user