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,266 @@
// RitualPanel — the "focus the channel" ritual mini-game: a short
// hold-to-charge incantation sequence (4 sigils: align / breathe / trace /
// lock). Sends `ritual_step` frames as the seeker progresses; the pass/fail
// roll and revealed traits come back on `ritual_complete`, handled by the
// séance reducer and read here via useSeance().
//
// Success reveals the summoned entity's true hidden traits
// (alignment/power/volatility/deceptiveness); failure reveals nothing —
// the ritual doesn't gate judgment, it only decides whether the seeker
// judges with real information or blind.
//
// Visual language is deliberately both at once — a terminal command log
// (typing an incantation) wrapped in candlelit sigil chrome (running a
// rite), not a flat CLI window and not pure occult set-dressing.
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
import { useSeance } from '../state/seance'
import {
RITUAL_HOLD_MS,
RITUAL_PROMPTS,
holdProgress,
nextStep,
stepComplete,
} from '../lib/ritual'
import type { RitualRune } from '../lib/ritual'
import type { EntityTraits } from '../lib/types'
import './RitualPanel.css'
type TFn = (key: string, options?: Record<string, unknown>) => string
const HOLD_TICK_MS = 40
type LocalPhase = 'idle' | 'stepping' | 'awaiting'
function runeCopy(rune: RitualRune, t: TFn): { command: string; locked: string } {
switch (rune) {
case 'align':
return {
command: t('seance.ritual.steps.align.command', { defaultValue: '> aligning the wire…' }),
locked: t('seance.ritual.steps.align.locked', { defaultValue: '> wire aligned ✓' }),
}
case 'breath':
return {
command: t('seance.ritual.steps.breath.command', { defaultValue: '> steadying the breath…' }),
locked: t('seance.ritual.steps.breath.locked', { defaultValue: '> breath steadied ✓' }),
}
case 'trace':
return {
command: t('seance.ritual.steps.trace.command', { defaultValue: '> tracing the sigil…' }),
locked: t('seance.ritual.steps.trace.locked', { defaultValue: '> sigil traced ✓' }),
}
case 'lock':
default:
return {
command: t('seance.ritual.steps.lock.command', { defaultValue: '> locking the channel…' }),
locked: t('seance.ritual.steps.lock.locked', { defaultValue: '> channel locked ✓' }),
}
}
}
const TRAIT_KEYS: readonly (keyof EntityTraits)[] = ['alignment', 'power', 'volatility', 'deceptiveness']
function traitLabel(key: keyof EntityTraits, t: TFn): string {
switch (key) {
case 'alignment':
return t('seance.ritual.traits.alignment', { defaultValue: 'alignment' })
case 'power':
return t('seance.ritual.traits.power', { defaultValue: 'power' })
case 'volatility':
return t('seance.ritual.traits.volatility', { defaultValue: 'volatility' })
case 'deceptiveness':
default:
return t('seance.ritual.traits.deceptiveness', { defaultValue: 'deceptiveness' })
}
}
export function RitualPanel() {
const { state, startRitual, sendRitualStep } = useSeance()
const { t } = useTranslation()
const [phase, setPhase] = useState<LocalPhase>('idle')
const [step, setStep] = useState(1)
const [progress, setProgress] = useState(0)
const [log, setLog] = useState<string[]>([])
const heldRef = useRef(0)
const intervalRef = useRef<ReturnType<typeof window.setInterval> | null>(null)
const hasEntity = !!state.entity
const entityId = state.entity?.id ?? null
const prevEntityIdRef = useRef(entityId)
const clearHold = () => {
if (intervalRef.current !== null) {
window.clearInterval(intervalRef.current)
intervalRef.current = null
}
heldRef.current = 0
setProgress(0)
}
// A newly-summoned entity invalidates any local mid-ritual UI (the
// reducer already resets the shared ritual/judgment state on 'entity').
useEffect(() => {
if (prevEntityIdRef.current !== entityId) {
prevEntityIdRef.current = entityId
clearHold()
setPhase('idle')
setStep(1)
setLog([])
}
}, [entityId])
useEffect(() => () => clearHold(), [])
const completeStep = (finishedStep: number) => {
clearHold()
const prompt = RITUAL_PROMPTS[finishedStep - 1]
setLog((l) => [...l, runeCopy(prompt.rune, t).locked])
sendRitualStep(finishedStep)
const n = nextStep(finishedStep, RITUAL_PROMPTS.length)
if (n === null) {
setPhase('awaiting')
} else {
setStep(n)
}
}
const beginHold = () => {
if (phase !== 'stepping' || intervalRef.current !== null) return
heldRef.current = 0
intervalRef.current = window.setInterval(() => {
heldRef.current += HOLD_TICK_MS
setProgress(holdProgress(heldRef.current))
if (stepComplete(heldRef.current)) completeStep(step)
}, HOLD_TICK_MS)
}
const releaseHold = () => {
if (intervalRef.current !== null) clearHold()
}
const begin = () => {
if (!hasEntity) return
clearHold()
setLog([])
setStep(1)
setPhase('stepping')
startRitual()
}
if (!hasEntity) {
return (
<section className="ritual-panel ritual-empty">
<div className="ritual-frame">
<p className="ritual-none">{t('seance.ritual.none', { defaultValue: 'no presence to focus on' })}</p>
<p className="ritual-none-hint dim">
{t('seance.ritual.noneHint', { defaultValue: 'summon a spirit before attempting the rite' })}
</p>
</div>
</section>
)
}
const ritualStatus = state.ritual.status
const showResult = ritualStatus === 'success' || ritualStatus === 'failure'
const currentPrompt = phase === 'stepping' ? RITUAL_PROMPTS[step - 1] : null
return (
<section className="ritual-panel">
<div className="ritual-frame">
<h4 className="ritual-title">{t('seance.ritual.title', { defaultValue: 'focus the channel' })}</h4>
{!showResult && phase === 'idle' && (
<button type="button" className="panel-action ritual-begin" onClick={begin}>
{t('seance.ritual.begin', { defaultValue: 'begin the rite' })}
</button>
)}
{!showResult && phase !== 'idle' && (
<>
<div className="ritual-terminal" role="log" aria-live="polite">
{log.map((line, i) => (
<div key={i} className="ritual-log-line">
{line}
</div>
))}
{currentPrompt && (
<div className="ritual-log-line ritual-log-active">{runeCopy(currentPrompt.rune, t).command}</div>
)}
{phase === 'awaiting' && (
<div className="ritual-log-line ritual-log-active flicker">
{t('seance.ritual.awaiting', { defaultValue: "awaiting the veil's answer…" })}
</div>
)}
</div>
{currentPrompt && (
<button
type="button"
className={`ritual-sigil rune-${currentPrompt.rune}`}
style={{ '--charge': progress } as CSSProperties}
onPointerDown={beginHold}
onPointerUp={releaseHold}
onPointerLeave={releaseHold}
onPointerCancel={releaseHold}
>
<span className="ritual-sigil-glyph" aria-hidden>
⟁
</span>
<span className="ritual-sigil-ring" aria-hidden />
<span className="ritual-sigil-label">
{t('seance.ritual.hold', { defaultValue: 'hold to focus' })} · {step}/{RITUAL_PROMPTS.length}
</span>
</button>
)}
</>
)}
{ritualStatus === 'success' && (
<div className="ritual-result ritual-success">
<p className="ritual-result-title">
{t('seance.ritual.successTitle', { defaultValue: 'the channel holds — the truth surfaces' })}
</p>
{state.ritual.revealed && (
<dl className="ritual-traits">
{TRAIT_KEYS.map((k) => {
const v = state.ritual.revealed?.[k] ?? 0
return (
<div key={k} className="ritual-trait-row">
<dt>{traitLabel(k, t)}</dt>
<dd>
<span className="ritual-trait-bar">
<span className="ritual-trait-fill" style={{ width: `${Math.round(v * 100)}%` }} />
</span>
<span className="ritual-trait-value">{v.toFixed(2)}</span>
</dd>
</div>
)
})}
</dl>
)}
<button type="button" className="panel-subaction" onClick={begin}>
{t('seance.ritual.retry', { defaultValue: 'attempt again' })}
</button>
</div>
)}
{ritualStatus === 'failure' && (
<div className="ritual-result ritual-failure">
<p className="ritual-result-title">
{t('seance.ritual.failureTitle', { defaultValue: 'the channel collapses' })}
</p>
<p className="dim">
{t('seance.ritual.failureHint', { defaultValue: 'the truth stays hidden — the rite may be tried again' })}
</p>
<button type="button" className="panel-subaction" onClick={begin}>
{t('seance.ritual.retry', { defaultValue: 'attempt again' })}
</button>
</div>
)}
</div>
</section>
)
}