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:
266
frontend/src/components/RitualPanel.tsx
Normal file
266
frontend/src/components/RitualPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user