feat(frontend): ritual mini-game, judgment UI, and consequences (Workstream E)

Implements the Character Depth / Ghost Log spec's Workstream E:

- RitualPanel.tsx: a 4-step "focus the channel" hold-to-charge sequence
  (align/breathe/trace/lock), sending ritual_step frames as the seeker
  progresses. Success reveals the entity's true hidden traits; failure
  reveals nothing. Sequencing/timing logic lives in the pure, unit-tested
  lib/ritual.ts rather than inline in the component.
- JudgmentPanel.tsx: Trust/Banish/Cross Over/Test verdict buttons with
  rune-style SVG icons, sending the judgment frame and rendering a
  distinct treatment per judgment_result consequence — reward,
  escalation (also spikes the ambient haunting), withdrawal, resisted,
  neutral, and a calm glyph-fade farewell for crossed_over (deliberately
  not the reward treatment, since it's a goodbye).
- lib/haunting.ts: IdleEscalator gains forceEscalate()/forcedUntil so a
  judgment's "escalation" consequence can spike the ambient haunting
  immediately instead of waiting on the 90s idle clock; exports a
  sharedIdleEscalator singleton and a forceEscalate() free function.
  HauntingLayer now paces itself off that shared instance instead of a
  private one, so the forced spike actually reaches the running layer.
- state/seance.tsx: new ritual/judgmentResult state, a local_ritual_start
  action, and reducer cases for the ritual_complete/judgment_result server
  frames; SeanceContext exported for component testing; startRitual/
  sendRitualStep/sendJudgment added to the provider API.
- lib/types.ts: EntityTraits/JudgmentVerdict/JudgmentConsequence types and
  the new client/server WS frames, per the spec's Contract section.
- i18n: seance.ritual.* / seance.judgment.* keys in en.json and es.json.

Fast-forwarded this worktree's branch onto master first — it had been
created from a stale ancestor commit predating the frontend scaffold
entirely, with zero commits of its own ahead of that point.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-23 11:29:29 +00:00
parent 6d8c6f2496
commit a52a781880
17 changed files with 1903 additions and 5 deletions

View File

@@ -0,0 +1,265 @@
// 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, useState } 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()
const [pending, setPending] = useState<JudgmentVerdict | null>(null)
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()
}
}, [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
setPending(verdict)
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 !== null}
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>
)
}