// 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_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 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('idle') const [step, setStep] = useState(1) const [progress, setProgress] = useState(0) const [log, setLog] = useState([]) const heldRef = useRef(0) const intervalRef = useRef | 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 (

{t('seance.ritual.none', { defaultValue: 'no presence to focus on' })}

{t('seance.ritual.noneHint', { defaultValue: 'summon a spirit before attempting the rite' })}

) } const ritualStatus = state.ritual.status const showResult = ritualStatus === 'success' || ritualStatus === 'failure' const currentPrompt = phase === 'stepping' ? RITUAL_PROMPTS[step - 1] : null return (

{t('seance.ritual.title', { defaultValue: 'focus the channel' })}

{!showResult && phase === 'idle' && ( )} {!showResult && phase !== 'idle' && ( <>
{log.map((line, i) => (
{line}
))} {currentPrompt && (
{runeCopy(currentPrompt.rune, t).command}
)} {phase === 'awaiting' && (
{t('seance.ritual.awaiting', { defaultValue: "awaiting the veil's answer…" })}
)}
{currentPrompt && ( )} )} {ritualStatus === 'success' && (

{t('seance.ritual.successTitle', { defaultValue: 'the channel holds — the truth surfaces' })}

{state.ritual.revealed && (
{TRAIT_KEYS.map((k) => { const v = state.ritual.revealed?.[k] ?? 0 return (
{traitLabel(k, t)}
{v.toFixed(2)}
) })}
)}
)} {ritualStatus === 'failure' && (

{t('seance.ritual.failureTitle', { defaultValue: 'the channel collapses' })}

{t('seance.ritual.failureHint', { defaultValue: 'the truth stays hidden — the rite may be tried again' })}

)}
) }