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>
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
// JudgmentPanel — the Trust / Banish / Cross Over / Test verdict controls.
|
|
// Sends a `judgment` WS frame per verdict (always allowed — a seeker can
|
|
// judge blind without completing the ritual first) and renders the
|
|
// `judgment_result` consequence with a treatment specific to each outcome:
|
|
// a warm reward, a sharpened haunting on a wrongful trust, a wounded
|
|
// withdrawal on a wrongful banish, a resisted demon, a neutral diagnostic
|
|
// — 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 } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useSeance } from '../state/seance'
|
|
import { forceEscalate } from '../lib/haunting'
|
|
import { GhostGlyph } from './GhostGlyph'
|
|
import type { JudgmentResult } from '../state/seance'
|
|
import type { JudgmentConsequence, JudgmentVerdict, SpiritEntity } from '../lib/types'
|
|
import './JudgmentPanel.css'
|
|
|
|
type TFn = (key: string, options?: Record<string, unknown>) => string
|
|
type Copy = { title: string; body: string }
|
|
|
|
function consequenceCopy(consequence: JudgmentConsequence, t: TFn): Copy {
|
|
switch (consequence) {
|
|
case 'reward':
|
|
return {
|
|
title: t('seance.judgment.reward.title', { defaultValue: 'the bond holds' }),
|
|
body: t('seance.judgment.reward.body', { defaultValue: 'the spirit answers your trust in kind.' }),
|
|
}
|
|
case 'escalation':
|
|
return {
|
|
title: t('seance.judgment.escalation.title', { defaultValue: 'a reckoning' }),
|
|
body: t('seance.judgment.escalation.body', {
|
|
defaultValue: 'wrongly trusted, it turns — the haunting sharpens.',
|
|
}),
|
|
}
|
|
case 'withdrawal':
|
|
return {
|
|
title: t('seance.judgment.withdrawal.title', { defaultValue: 'it recoils' }),
|
|
body: t('seance.judgment.withdrawal.body', {
|
|
defaultValue: 'banished in error, the presence withdraws, wounded.',
|
|
}),
|
|
}
|
|
case 'resisted':
|
|
return {
|
|
title: t('seance.judgment.resisted.title', { defaultValue: 'it will not go' }),
|
|
body: t('seance.judgment.resisted.body', {
|
|
defaultValue: 'a demon does not cross over — it claws back to stay.',
|
|
}),
|
|
}
|
|
case 'crossed_over':
|
|
return {
|
|
title: t('seance.judgment.crossedOver.title', { defaultValue: 'at peace' }),
|
|
body: t('seance.judgment.crossedOver.body', { defaultValue: 'the spirit lets go, and the line falls quiet.' }),
|
|
}
|
|
case 'neutral':
|
|
default:
|
|
return {
|
|
title: t('seance.judgment.neutral.title', { defaultValue: 'no verdict rendered' }),
|
|
body: t('seance.judgment.neutral.body', {
|
|
defaultValue: 'a diagnostic pulse only — nothing risked, nothing gained.',
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- rune glyphs (GhostGlyph-style radial-gradient SVGs, one per verdict) ----
|
|
|
|
function TrustRune() {
|
|
return (
|
|
<svg viewBox="0 0 32 32" width="18" height="18" className="verdict-rune" aria-hidden="true">
|
|
<defs>
|
|
<radialGradient id="rune-trust" cx="50%" cy="42%" r="65%">
|
|
<stop offset="0%" stopColor="#eaffef" stopOpacity="0.95" />
|
|
<stop offset="100%" stopColor="#7cffb2" stopOpacity="0.3" />
|
|
</radialGradient>
|
|
</defs>
|
|
<circle cx="16" cy="16" r="11" fill="url(#rune-trust)" stroke="#7cffb2" strokeWidth="1.3" />
|
|
<path
|
|
d="M10 16.5 L14 20.5 L22 11.5"
|
|
fill="none"
|
|
stroke="#08150e"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function BanishRune() {
|
|
return (
|
|
<svg viewBox="0 0 32 32" width="18" height="18" className="verdict-rune" aria-hidden="true">
|
|
<circle cx="16" cy="16" r="11" fill="rgba(255,59,92,0.08)" stroke="#ff3b5c" strokeWidth="1.3" opacity="0.85" />
|
|
<path
|
|
d="M9 8 L17 15 L13 15 L23 24 M22 8 L17 13"
|
|
fill="none"
|
|
stroke="#ff3b5c"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function CrossOverRune() {
|
|
return (
|
|
<svg viewBox="0 0 32 32" width="18" height="18" className="verdict-rune" aria-hidden="true">
|
|
<defs>
|
|
<radialGradient id="rune-crossover" cx="50%" cy="50%" r="60%">
|
|
<stop offset="0%" stopColor="#e6d4ff" stopOpacity="0.85" />
|
|
<stop offset="65%" stopColor="#b26bff" stopOpacity="0.22" />
|
|
<stop offset="100%" stopColor="#b26bff" stopOpacity="0" />
|
|
</radialGradient>
|
|
</defs>
|
|
<circle cx="16" cy="16" r="12" fill="url(#rune-crossover)" />
|
|
<circle cx="16" cy="16" r="12" fill="none" stroke="#b26bff" strokeWidth="0.9" opacity="0.45" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function TestRune() {
|
|
return (
|
|
<svg viewBox="0 0 32 32" width="18" height="18" className="verdict-rune" aria-hidden="true">
|
|
<circle cx="13.5" cy="13.5" r="7.5" fill="none" stroke="#d3e9db" strokeWidth="1.3" opacity="0.8" />
|
|
<line x1="19" y1="19" x2="25" y2="25" stroke="#d3e9db" strokeWidth="1.5" strokeLinecap="round" opacity="0.8" />
|
|
<path
|
|
d="M10.5 13.5 L12.5 15.5 L16.5 10.5"
|
|
fill="none"
|
|
stroke="#d3e9db"
|
|
strokeWidth="1.2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
opacity="0.7"
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
const VERDICTS: readonly {
|
|
verdict: JudgmentVerdict
|
|
labelKey: string
|
|
fallback: string
|
|
Icon: () => JSX.Element
|
|
cls: string
|
|
}[] = [
|
|
{ verdict: 'trust', labelKey: 'seance.judgment.trust', fallback: 'trust', Icon: TrustRune, cls: 'verdict-trust' },
|
|
{ verdict: 'banish', labelKey: 'seance.judgment.banish', fallback: 'banish', Icon: BanishRune, cls: 'verdict-banish' },
|
|
{
|
|
verdict: 'cross_over',
|
|
labelKey: 'seance.judgment.crossOver',
|
|
fallback: 'cross over',
|
|
Icon: CrossOverRune,
|
|
cls: 'verdict-crossover',
|
|
},
|
|
{ verdict: 'test', labelKey: 'seance.judgment.test', fallback: 'test', Icon: TestRune, cls: 'verdict-test' },
|
|
]
|
|
|
|
export function JudgmentPanel() {
|
|
const { state, sendJudgment } = useSeance()
|
|
const { t } = useTranslation()
|
|
// `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
|
|
// The wrongly-trusted consequence lashes the ambient haunting
|
|
// immediately rather than waiting for the normal idle escalation.
|
|
if (state.judgmentResult.consequence === 'escalation') forceEscalate()
|
|
}
|
|
}, [state.judgmentResult])
|
|
|
|
if (!state.entity) {
|
|
return (
|
|
<section className="judgment-panel judgment-empty">
|
|
<p className="ritual-none">{t('seance.judgment.none', { defaultValue: 'no presence to judge' })}</p>
|
|
<p className="ritual-none-hint dim">
|
|
{t('seance.judgment.noneHint', { defaultValue: 'summon a spirit first' })}
|
|
</p>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
const cast = (verdict: JudgmentVerdict) => {
|
|
if (pending) return
|
|
sendJudgment(verdict)
|
|
}
|
|
|
|
const result = state.judgmentResult
|
|
const crossedOver = result?.consequence === 'crossed_over'
|
|
|
|
return (
|
|
<section className="judgment-panel">
|
|
<h4 className="ritual-title">{t('seance.judgment.title', { defaultValue: 'render judgment' })}</h4>
|
|
|
|
<div className="judgment-actions">
|
|
{VERDICTS.map(({ verdict, labelKey, fallback, Icon, cls }) => (
|
|
<button
|
|
key={verdict}
|
|
type="button"
|
|
className={`verdict-btn ${cls}`}
|
|
onClick={() => cast(verdict)}
|
|
disabled={pending}
|
|
aria-label={t(labelKey, { defaultValue: fallback })}
|
|
>
|
|
<Icon />
|
|
<span>{t(labelKey, { defaultValue: fallback })}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{pending && (
|
|
<p className="judgment-pending dim flicker">
|
|
{t('seance.judgment.pending', { defaultValue: 'awaiting the verdict…' })}
|
|
</p>
|
|
)}
|
|
|
|
{result && crossedOver && <CrossedOverCard result={result} entity={state.entity} t={t} />}
|
|
{result && !crossedOver && <ConsequenceCard result={result} t={t} />}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function DeltaRow({ result, t }: { result: JudgmentResult; t: TFn }) {
|
|
if (result.favorDelta === 0 && result.essenceDelta === 0) return null
|
|
const favorTxt = `${result.favorDelta >= 0 ? '+' : ''}${result.favorDelta.toFixed(2)}`
|
|
const essenceTxt = `${result.essenceDelta >= 0 ? '+' : ''}${result.essenceDelta}`
|
|
return (
|
|
<p className="judgment-deltas dim">
|
|
{t('seance.judgment.favor', { defaultValue: 'favor' })} {favorTxt} ·{' '}
|
|
{t('seance.judgment.essence', { defaultValue: 'essence' })} {essenceTxt}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
function ConsequenceCard({ result, t }: { result: JudgmentResult; t: TFn }) {
|
|
const copy = consequenceCopy(result.consequence, t)
|
|
return (
|
|
<div className={`judgment-consequence consequence-${result.consequence}`}>
|
|
<p className="judgment-consequence-title">{copy.title}</p>
|
|
<p className="judgment-consequence-body dim">{copy.body}</p>
|
|
<DeltaRow result={result} t={t} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// The compassionate resolution gets a visibly different beat from a
|
|
// "reward" — a farewell, not a continuation. The entity's own glyph fades
|
|
// gently over ~2.4s rather than any triumphant glow.
|
|
function CrossedOverCard({ result, entity, t }: { result: JudgmentResult; entity: SpiritEntity; t: TFn }) {
|
|
const copy = consequenceCopy('crossed_over', t)
|
|
return (
|
|
<div className="judgment-consequence consequence-crossed_over judgment-farewell">
|
|
<div className="farewell-glyph-wrap">
|
|
<GhostGlyph hue={entity.visual.hue} form={entity.visual.form} size={56} className="farewell-glyph" />
|
|
</div>
|
|
<p className="judgment-consequence-title">{copy.title}</p>
|
|
<p className="judgment-consequence-body dim">{copy.body}</p>
|
|
<DeltaRow result={result} t={t} />
|
|
</div>
|
|
)
|
|
}
|