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

@@ -10,6 +10,10 @@ function mockFetch(handlers: {
catalog?: unknown catalog?: unknown
catalogStatus?: number catalogStatus?: number
purchaseStatus?: 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( vi.stubGlobal(
'fetch', 'fetch',
@@ -31,7 +35,13 @@ function mockFetch(handlers: {
}) })
} }
if (url.includes('/api/inventory/unlocks/') && init?.method === 'POST') { 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 }) 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 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({ mockFetch({
catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] }, catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] },
purchaseStatus: 200, purchaseStatus: 200,
purchaseBody: { unlock_key: 'listening_tool', essence: 60 },
}) })
const user = userEvent.setup() const user = userEvent.setup()
render(<InventoryPanel unlocks={[]} essence={100} />) render(<InventoryPanel unlocks={[]} essence={100} />)
@@ -100,6 +111,24 @@ describe('InventoryPanel', () => {
expect(screen.queryByRole('button', { name: /buy/i })).toBeNull() 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 () => { it('shows an insufficient-funds message on a 402 without changing essence', async () => {
mockFetch({ mockFetch({
catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] }, catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] },

View File

@@ -129,9 +129,15 @@ export function InventoryPanel({ unlocks, essence: initialEssence }: InventoryPa
})) }))
return 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])) setOwnedUnlocks((prev) => (prev.includes(unlockKey) ? prev : [...prev, unlockKey]))
setEssence((prev) => Math.max(0, prev - price)) setEssence(data.essence)
} catch { } catch {
setPurchaseError((prev) => ({ setPurchaseError((prev) => ({
...prev, ...prev,
@@ -141,7 +147,7 @@ export function InventoryPanel({ unlocks, essence: initialEssence }: InventoryPa
setPurchasing(null) setPurchasing(null)
} }
}, },
[prices, purchasing, t], [purchasing, t],
) )
const offers = OFFERED_UNLOCK_KEYS.filter((key) => !ownedUnlocks.includes(key)) const offers = OFFERED_UNLOCK_KEYS.filter((key) => !ownedUnlocks.includes(key))

View File

@@ -63,9 +63,15 @@ describe('JudgmentPanel', () => {
expect(sendJudgment).toHaveBeenCalledWith(verdict) 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', () => { it('disables the verdict buttons while a judgment is pending', () => {
renderPanel() renderPanel({ judgmentPending: true })
fireEvent.click(screen.getByRole('button', { name: /^trust$/i }))
for (const btn of screen.getAllByRole('button')) { for (const btn of screen.getAllByRole('button')) {
expect(btn).toBeDisabled() expect(btn).toBeDisabled()
} }
@@ -73,10 +79,10 @@ describe('JudgmentPanel', () => {
}) })
it('ignores further clicks while a judgment is already pending', () => { 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: /^trust$/i }))
fireEvent.click(screen.getByRole('button', { name: /^banish$/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', () => { 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)', () => { it('clears the pending state once judgmentResult changes (rerender simulates the reducer answering)', () => {
const sendJudgment = vi.fn() 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 api = { state, sendJudgment } as unknown as SeanceApi
const { rerender } = render( const { rerender } = render(
@@ -167,10 +175,14 @@ describe('JudgmentPanel — pending clears on a fresh result', () => {
<JudgmentPanel /> <JudgmentPanel />
</SeanceContext.Provider>, </SeanceContext.Provider>,
) )
fireEvent.click(screen.getByRole('button', { name: /^test$/i }))
expect(screen.getByText(/awaiting the verdict/i)).toBeTruthy() 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( rerender(
<SeanceContext.Provider value={nextApi}> <SeanceContext.Provider value={nextApi}>
<JudgmentPanel /> <JudgmentPanel />

View File

@@ -7,7 +7,7 @@
// — and, distinct from all of those, a slow calm farewell fade when a // — 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). // 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 { useTranslation } from 'react-i18next'
import { useSeance } from '../state/seance' import { useSeance } from '../state/seance'
import { forceEscalate } from '../lib/haunting' import { forceEscalate } from '../lib/haunting'
@@ -159,13 +159,19 @@ const VERDICTS: readonly {
export function JudgmentPanel() { export function JudgmentPanel() {
const { state, sendJudgment } = useSeance() const { state, sendJudgment } = useSeance()
const { t } = useTranslation() 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) const seenResultRef = useRef<JudgmentResult | null>(state.judgmentResult)
useEffect(() => { useEffect(() => {
if (state.judgmentResult && state.judgmentResult !== seenResultRef.current) { if (state.judgmentResult && state.judgmentResult !== seenResultRef.current) {
seenResultRef.current = state.judgmentResult seenResultRef.current = state.judgmentResult
setPending(null)
// The wrongly-trusted consequence lashes the ambient haunting // The wrongly-trusted consequence lashes the ambient haunting
// immediately rather than waiting for the normal idle escalation. // immediately rather than waiting for the normal idle escalation.
if (state.judgmentResult.consequence === 'escalation') forceEscalate() if (state.judgmentResult.consequence === 'escalation') forceEscalate()
@@ -185,7 +191,6 @@ export function JudgmentPanel() {
const cast = (verdict: JudgmentVerdict) => { const cast = (verdict: JudgmentVerdict) => {
if (pending) return if (pending) return
setPending(verdict)
sendJudgment(verdict) sendJudgment(verdict)
} }
@@ -203,7 +208,7 @@ export function JudgmentPanel() {
type="button" type="button"
className={`verdict-btn ${cls}`} className={`verdict-btn ${cls}`}
onClick={() => cast(verdict)} onClick={() => cast(verdict)}
disabled={pending !== null} disabled={pending}
aria-label={t(labelKey, { defaultValue: fallback })} aria-label={t(labelKey, { defaultValue: fallback })}
> >
<Icon /> <Icon />

View File

@@ -102,7 +102,13 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
// glides down and rests on GOODBYE for a few seconds before resuming // glides down and rests on GOODBYE for a few seconds before resuming
// normal idle wander. `t`-relative (seconds since mount), not wall-clock. // normal idle wander. `t`-relative (seconds since mount), not wall-clock.
const randomBetween = (min: number, max: number) => min + Math.random() * (max - min) 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 let goodbyeRestUntil = 0
// Spirit knock: an AudioContext created lazily on the first gesture // Spirit knock: an AudioContext created lazily on the first gesture

View File

@@ -194,7 +194,12 @@ export function applyTemperatureReading(
) )
const drop = deviation !== null ? Math.max(0, -deviation) : 0 const drop = deviation !== null ? Math.max(0, -deviation) : 0
const isColdSpot = warm && drop >= COLD_SPOT_DROP_C 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 } return { state: next, deviation, isColdSpot, severity }
} }
@@ -252,7 +257,9 @@ export function applyPressureReading(
) )
const swing = deviation !== null ? Math.abs(deviation) : 0 const swing = deviation !== null ? Math.abs(deviation) : 0
const isPressureAnomaly = warm && swing >= PRESSURE_SWING_HPA 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' const direction: 'rise' | 'drop' = deviation !== null && deviation < 0 ? 'drop' : 'rise'
return { state: next, deviation, isPressureAnomaly, severity, direction } return { state: next, deviation, isPressureAnomaly, severity, direction }
} }

View File

@@ -196,12 +196,14 @@ export class RtlSdr {
const dev = this.device const dev = this.device
if (dev?.opened) { if (dev?.opened) {
try { 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 { } 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 withTimeout(dev.releaseInterface(this.interfaceNumber), 3_000, 'RTL-SDR releaseInterface').catch(
await dev.close().catch(() => undefined) () => 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 const readBytes = fftSize * 2 * 2 // 2 samples per fft point, unsigned iq
try { try {
while (this.running) { while (this.running) {
await this.setFrequency(hz) await withTimeout(this.setFrequency(hz), 3_000, 'PLL retune')
await new Promise((r) => setTimeout(r, settleMs)) await new Promise((r) => setTimeout(r, settleMs))
try { try {
await this.readSamples(16384) // discard: PLL settle await this.readSamples(16384) // discard: PLL settle

View File

@@ -305,6 +305,7 @@ describe('seanceReducer — ritual', () => {
success: true, success: true,
revealed: { alignment: 1, power: 1, volatility: 1, deceptiveness: 1 }, revealed: { alignment: 1, power: 1, volatility: 1, deceptiveness: 1 },
}) })
state = seanceReducer(state, { type: 'local_judgment_start' })
state = frame(state, { state = frame(state, {
type: 'judgment_result', type: 'judgment_result',
correct: true, correct: true,
@@ -319,6 +320,35 @@ describe('seanceReducer — ritual', () => {
state = frame(state, { type: 'entity', is_new: false, entity }) state = frame(state, { type: 'entity', is_new: false, entity })
expect(state.ritual).toEqual({ status: 'idle', revealed: null }) expect(state.ritual).toEqual({ status: 'idle', revealed: null })
expect(state.judgmentResult).toBeNull() 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', () => { it('records a correct-trust reward', () => {
const state = frame( const state = frame(
initialSeanceState, pending(),
judgmentFrame({ correct: true, favor_delta: 0.05, essence_delta: 15, consequence: 'reward' }), judgmentFrame({ correct: true, favor_delta: 0.05, essence_delta: 15, consequence: 'reward' }),
) )
expect(state.judgmentResult).toMatchObject({ expect(state.judgmentResult).toMatchObject({
@@ -355,11 +393,12 @@ describe('seanceReducer — judgment_result', () => {
atPeace: false, atPeace: false,
consequence: 'reward', consequence: 'reward',
}) })
expect(state.judgmentPending).toBe(false)
}) })
it('records a wrong-trust escalation', () => { it('records a wrong-trust escalation', () => {
const state = frame( const state = frame(
initialSeanceState, pending(),
judgmentFrame({ correct: false, favor_delta: -0.12, essence_delta: 0, consequence: 'escalation' }), judgmentFrame({ correct: false, favor_delta: -0.12, essence_delta: 0, consequence: 'escalation' }),
) )
expect(state.judgmentResult).toMatchObject({ consequence: 'escalation', favorDelta: -0.12 }) 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', () => { it('records a wrong-banish withdrawal with a smaller favor penalty than escalation', () => {
const state = frame( const state = frame(
initialSeanceState, pending(),
judgmentFrame({ correct: false, favor_delta: -0.06, essence_delta: 0, consequence: 'withdrawal' }), judgmentFrame({ correct: false, favor_delta: -0.06, essence_delta: 0, consequence: 'withdrawal' }),
) )
expect(state.judgmentResult).toMatchObject({ consequence: 'withdrawal', favorDelta: -0.06 }) 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', () => { it('records a correctly-called cross_over with at_peace true and the largest essence reward', () => {
const state = frame( const state = frame(
initialSeanceState, pending(),
judgmentFrame({ correct: true, favor_delta: 0.05, essence_delta: 30, at_peace: true, consequence: 'crossed_over' }), judgmentFrame({ correct: true, favor_delta: 0.05, essence_delta: 30, at_peace: true, consequence: 'crossed_over' }),
) )
expect(state.judgmentResult).toMatchObject({ 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', () => { 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 }) 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', () => { 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 }) expect(state.judgmentResult).toMatchObject({ consequence: 'neutral', favorDelta: 0, essenceDelta: 0 })
}) })
it('logs a system transcript row alongside the stored result', () => { 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) const row = lastRow(state)
expect(row.kind).toBe('system') expect(row.kind).toBe('system')
if (row.kind === 'system') expect(row.text).toMatch(/at peace/) if (row.kind === 'system') expect(row.text).toMatch(/at peace/)
}) })
it('overwrites the previous judgmentResult on a second judgment', () => { it('overwrites the previous judgmentResult on a second judgment', () => {
let state = frame(initialSeanceState, judgmentFrame({ consequence: 'reward' })) let state = frame(pending(), judgmentFrame({ consequence: 'reward' }))
state = frame(state, judgmentFrame({ consequence: 'withdrawal' })) state = frame(pending(state), judgmentFrame({ consequence: 'withdrawal' }))
expect(state.judgmentResult?.consequence).toBe('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()
})
}) })

View File

@@ -95,6 +95,13 @@ export type SeanceState = {
speakingId: string | null speakingId: string | null
ritual: RitualUiState ritual: RitualUiState
judgmentResult: JudgmentResult | null 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 } const IDLE_RITUAL: RitualUiState = { status: 'idle', revealed: null }
@@ -118,6 +125,7 @@ export const initialSeanceState: SeanceState = {
speakingId: null, speakingId: null,
ritual: IDLE_RITUAL, ritual: IDLE_RITUAL,
judgmentResult: null, judgmentResult: null,
judgmentPending: false,
} }
// ---- Actions ---- // ---- Actions ----
@@ -141,6 +149,7 @@ export type SeanceAction =
| { type: 'set_speaking'; utteranceId: string | null } | { type: 'set_speaking'; utteranceId: string | null }
| { type: 'dismiss_toast'; id: number } | { type: 'dismiss_toast'; id: number }
| { type: 'local_ritual_start' } | { type: 'local_ritual_start' }
| { type: 'local_judgment_start' }
| { type: 'reset' } | { type: 'reset' }
function pushCapped<T>(arr: T[], item: T, cap: number): T[] { 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': case 'local_ritual_start':
return { ...state, ritual: { status: 'in_progress', revealed: null } } return { ...state, ritual: { status: 'in_progress', revealed: null } }
case 'local_judgment_start':
return { ...state, judgmentPending: true }
case 'reset': case 'reset':
return { ...initialSeanceState } return { ...initialSeanceState }
@@ -259,9 +271,14 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
entityIsNew: frame.is_new, entityIsNew: frame.is_new,
status: null, status: null,
// A fresh presence invalidates any ritual/judgment history from // 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, ritual: IDLE_RITUAL,
judgmentResult: null, judgmentResult: null,
judgmentPending: false,
transcript: pushCapped( transcript: pushCapped(
state.transcript, state.transcript,
{ {
@@ -423,6 +440,16 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
}) })
case 'ritual_complete': { 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 const text = frame.success
? '⟁ the channel holds — the truth surfaces' ? '⟁ the channel holds — the truth surfaces'
: '⟁ the channel collapses' : '⟁ the channel collapses'
@@ -441,6 +468,9 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
} }
case 'judgment_result': { 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 = { const result: JudgmentResult = {
correct: frame.correct, correct: frame.correct,
favorDelta: frame.favor_delta, favorDelta: frame.favor_delta,
@@ -452,6 +482,7 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
return { return {
...state, ...state,
judgmentResult: result, judgmentResult: result,
judgmentPending: false,
transcript: pushCapped( transcript: pushCapped(
state.transcript, state.transcript,
{ id: nextId(), kind: 'system', text: judgmentTranscriptLine(frame.consequence), at }, { id: nextId(), kind: 'system', text: judgmentTranscriptLine(frame.consequence), at },
@@ -605,6 +636,10 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
}, []) }, [])
const sendJudgment = useCallback((verdict: JudgmentVerdict) => { 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 }) socketRef.current?.send({ type: 'judgment', verdict })
}, []) }, [])