Files
qtalker---/frontend/src/components/RitualPanel.tsx
Indiana 2a67684df4 chore: dead-code + duplicate sweep; fix moon-flaky re-contact test
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>
2026-07-29 02:14:39 +00:00

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>
)
}