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 })
}, [])