Dead code: removed an unused `settings` import (main.py), an unused `RITUAL_HOLD_MS` import (RitualPanel), and an unused `beforeEach` (SigilDesigner test). The `_refs` keep-alive in sdr.ts is deliberate (holds hardware-pass constants) and stays; the orphaned .evp-scope / .radio-waterfall CSS was already removed in an earlier lint pass. Duplicate: coldSpot.ts and baseline.ts each carried the same time-aware EMA alpha formula. Extracted it as baseline.emaAlpha(dtMs, tauMs) and pointed both at it. coldSpot's pure-function core is deliberately NOT merged into the stateful ThresholdBaseline class — different contract (immutable-state-threaded vs internal-threshold), and forcing them together would be an overhaul that risks the tested cold-spot logic. Determinism fix: test_familiar_presence_answers_again_on_a_known_channel pinned RETURN_CHANCE=1.0 but not the sky. Since the astronomy wiring made the real return chance RETURN_CHANCE*(1 - veil_thinness*PULL), and veil_thinness reads the *actual current moon phase*, a full-moon test run dragged the effective chance to ~0.55 and the test failed ~45% of the time. Now also pins VEIL_THINNESS_PULL=0 to isolate re-contact from the veil influence (which has its own tests). Verified 12/12 consecutive passes; it was ~7/12 before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
266 lines
9.5 KiB
TypeScript
266 lines
9.5 KiB
TypeScript
// 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, 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>
|
|
)
|
|
}
|