// 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 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 ( ) } function BanishRune() { return ( ) } function CrossOverRune() { return ( ) } function TestRune() { return ( ) } 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(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 (

{t('seance.judgment.none', { defaultValue: 'no presence to judge' })}

{t('seance.judgment.noneHint', { defaultValue: 'summon a spirit first' })}

) } const cast = (verdict: JudgmentVerdict) => { if (pending) return sendJudgment(verdict) } const result = state.judgmentResult const crossedOver = result?.consequence === 'crossed_over' return (

{t('seance.judgment.title', { defaultValue: 'render judgment' })}

{VERDICTS.map(({ verdict, labelKey, fallback, Icon, cls }) => ( ))}
{pending && (

{t('seance.judgment.pending', { defaultValue: 'awaiting the verdict…' })}

)} {result && crossedOver && } {result && !crossedOver && }
) } 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 (

{t('seance.judgment.favor', { defaultValue: 'favor' })} {favorTxt} ·{' '} {t('seance.judgment.essence', { defaultValue: 'essence' })} {essenceTxt}

) } function ConsequenceCard({ result, t }: { result: JudgmentResult; t: TFn }) { const copy = consequenceCopy(result.consequence, t) return (

{copy.title}

{copy.body}

) } // 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 (

{copy.title}

{copy.body}

) }