Merge Workstream E: ritual mini-game + judgment UI + consequences

Resolved conflict in types.ts: dropped the duplicate EntityTraits
definition (D and E both added it identically) and combined both
workstreams' new ServerFrame variants (tell/ritual_complete from D,
judgment_result from E). 223/223 frontend tests pass, tsc clean.
This commit is contained in:
Indiana
2026-07-24 09:39:08 +00:00
17 changed files with 1904 additions and 17 deletions

View File

@@ -12,10 +12,10 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
HauntBed,
IdleEscalator,
flickerTitle,
hauntBus,
pickWhisperWord,
sharedIdleEscalator,
} from '../lib/haunting'
import './HauntingLayer.css'
@@ -84,7 +84,10 @@ export function HauntingLayer() {
useEffect(() => {
const reduced = prefersReducedMotion()
const escalator = new IdleEscalator()
// Shared instance (not a fresh one per mount) so a forced escalation
// triggered elsewhere (e.g. a wrongly-trusted judgment) reaches this
// running layer — see `forceEscalate()` in lib/haunting.ts.
const escalator = sharedIdleEscalator
const bed = new HauntBed()
const timers = new Set<ReturnType<typeof setTimeout>>()
let disposed = false

View File

@@ -0,0 +1,167 @@
/* JudgmentPanel — Trust / Banish / Cross Over / Test verdict controls and
their consequence treatments. Same token palette as RitualPanel.css /
SeancePage.css. */
.judgment-panel {
margin-top: 0.9rem;
padding-top: 0.75rem;
border-top: 1px dotted rgba(124, 255, 178, 0.14);
}
.judgment-empty {
margin-top: 0.9rem;
}
.judgment-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
margin-top: 0.5rem;
}
.verdict-btn {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.5rem 0.6rem;
background: rgba(16, 16, 26, 0.6);
border: 1px solid rgba(211, 233, 219, 0.18);
border-radius: 4px;
color: #d3e9db;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transition: box-shadow 150ms ease, border-color 150ms ease, background 150ms ease;
}
.verdict-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.verdict-btn.verdict-trust:hover:not(:disabled) {
border-color: rgba(124, 255, 178, 0.6);
box-shadow: 0 0 14px rgba(124, 255, 178, 0.28);
}
.verdict-btn.verdict-banish:hover:not(:disabled) {
border-color: rgba(255, 59, 92, 0.6);
box-shadow: 0 0 14px rgba(255, 59, 92, 0.28);
}
.verdict-btn.verdict-crossover:hover:not(:disabled) {
border-color: rgba(178, 107, 255, 0.6);
box-shadow: 0 0 14px rgba(178, 107, 255, 0.28);
}
.verdict-btn.verdict-test:hover:not(:disabled) {
border-color: rgba(211, 233, 219, 0.5);
box-shadow: 0 0 10px rgba(211, 233, 219, 0.2);
}
.verdict-rune {
flex: 0 0 auto;
}
.judgment-pending {
margin: 0.5rem 0 0;
font-size: 0.74rem;
}
.judgment-consequence {
margin-top: 0.65rem;
padding: 0.65rem 0.75rem;
border-radius: 4px;
border: 1px solid rgba(211, 233, 219, 0.15);
background: rgba(16, 16, 26, 0.5);
}
.judgment-consequence-title {
margin: 0 0 0.25rem;
font-family: 'Cinzel', serif;
font-size: 0.8rem;
letter-spacing: 0.05em;
}
.judgment-consequence-body {
margin: 0;
font-size: 0.76rem;
line-height: 1.5;
}
.judgment-deltas {
margin: 0.45rem 0 0;
font-size: 0.7rem;
}
.consequence-reward {
border-color: rgba(124, 255, 178, 0.4);
}
.consequence-reward .judgment-consequence-title {
color: #7cffb2;
text-shadow: 0 0 10px rgba(124, 255, 178, 0.4);
}
.consequence-escalation {
border-color: rgba(255, 59, 92, 0.45);
animation: escalationPulse 1.8s ease-in-out 2;
}
.consequence-escalation .judgment-consequence-title {
color: #ff3b5c;
text-shadow: 0 0 10px rgba(255, 59, 92, 0.5);
}
.consequence-withdrawal {
border-color: rgba(178, 107, 255, 0.35);
opacity: 0.92;
}
.consequence-withdrawal .judgment-consequence-title {
color: rgba(178, 107, 255, 0.85);
}
.consequence-resisted {
border-color: rgba(255, 59, 92, 0.3);
}
.consequence-resisted .judgment-consequence-title {
color: #ff3b5c;
opacity: 0.85;
}
.consequence-neutral {
border-color: rgba(211, 233, 219, 0.15);
}
.consequence-neutral .judgment-consequence-title {
color: rgba(211, 233, 219, 0.7);
}
/* crossed-over: a calm farewell, distinct from the "reward" treatment —
the entity's own glyph fades out gently rather than any triumphant glow. */
.judgment-farewell {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.3rem;
border-color: rgba(178, 107, 255, 0.3);
background: radial-gradient(ellipse at 50% 0%, rgba(178, 107, 255, 0.08), transparent 70%);
}
.farewell-glyph-wrap {
margin-bottom: 0.2rem;
}
.farewell-glyph {
animation: farewellFade 2.4s ease-out forwards;
}
@keyframes escalationPulse {
0%, 100% { box-shadow: 0 0 0 rgba(255, 59, 92, 0); }
50% { box-shadow: 0 0 18px rgba(255, 59, 92, 0.35); }
}
@keyframes farewellFade {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0.32; transform: scale(0.92); }
}

View File

@@ -0,0 +1,185 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '../i18n'
import { JudgmentPanel } from './JudgmentPanel'
import { SeanceContext, initialSeanceState } from '../state/seance'
import type { JudgmentResult, SeanceApi, SeanceState } from '../state/seance'
import type { SpiritEntity } from '../lib/types'
import * as haunting from '../lib/haunting'
const entity: SpiritEntity = {
id: 'e-1',
name: 'Mirela',
epithet: 'the Drowned',
persona: 'a soft-spoken drowned girl',
rarity: 'rare',
voice: { voice_id: 'v-1', pitch: 0.5, rate: 0.5, noise: 0.3, echo: 0.4 },
visual: { hue: 200, form: 'wisp' },
quotes: [],
contact_count: 1,
discovered_at: '2026-01-01T00:00:00.000Z',
}
function renderPanel(stateOverrides: Partial<SeanceState> = {}) {
const sendJudgment = vi.fn()
const state: SeanceState = { ...initialSeanceState, entity, ...stateOverrides }
const api = { state, sendJudgment } as unknown as SeanceApi
const utils = render(
<SeanceContext.Provider value={api}>
<JudgmentPanel />
</SeanceContext.Provider>,
)
return { ...utils, sendJudgment }
}
function result(overrides: Partial<JudgmentResult> = {}): JudgmentResult {
return {
correct: false,
favorDelta: 0,
essenceDelta: 0,
atPeace: false,
consequence: 'neutral',
at: 1,
...overrides,
}
}
describe('JudgmentPanel', () => {
it('shows an empty state with no summoned entity, and no buttons', () => {
renderPanel({ entity: null })
expect(screen.getByText(/no presence to judge/i)).toBeTruthy()
expect(screen.queryByRole('button')).toBeNull()
})
it.each([
['trust', /^trust$/i],
['banish', /^banish$/i],
['cross_over', /cross over/i],
['test', /^test$/i],
] as const)('sends a judgment frame with verdict %s on click', (verdict, label) => {
const { sendJudgment } = renderPanel()
fireEvent.click(screen.getByRole('button', { name: label }))
expect(sendJudgment).toHaveBeenCalledTimes(1)
expect(sendJudgment).toHaveBeenCalledWith(verdict)
})
it('disables the verdict buttons while a judgment is pending', () => {
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /^trust$/i }))
for (const btn of screen.getAllByRole('button')) {
expect(btn).toBeDisabled()
}
expect(screen.getByText(/awaiting the verdict/i)).toBeTruthy()
})
it('ignores further clicks while a judgment is already pending', () => {
const { sendJudgment } = renderPanel()
fireEvent.click(screen.getByRole('button', { name: /^trust$/i }))
fireEvent.click(screen.getByRole('button', { name: /^banish$/i }))
expect(sendJudgment).toHaveBeenCalledTimes(1)
})
it('renders a reward treatment for a correct trust', () => {
renderPanel({ judgmentResult: result({ correct: true, favorDelta: 0.05, essenceDelta: 15, consequence: 'reward' }) })
expect(screen.getByText(/bond holds/i)).toBeTruthy()
expect(screen.getByText(/\+0\.05/)).toBeTruthy()
expect(screen.getByText(/\+15/)).toBeTruthy()
})
it('renders a distinct withdrawal treatment for a wrong banish', () => {
renderPanel({ judgmentResult: result({ favorDelta: -0.06, consequence: 'withdrawal' }) })
expect(screen.getByText(/recoils/i)).toBeTruthy()
})
it('renders a resisted treatment for cross_over on a demon', () => {
renderPanel({ judgmentResult: result({ consequence: 'resisted' }) })
expect(screen.getByText(/will not go/i)).toBeTruthy()
})
it('renders a neutral treatment for a test verdict', () => {
renderPanel({ judgmentResult: result({ consequence: 'neutral' }) })
expect(screen.getByText(/no verdict rendered/i)).toBeTruthy()
})
it('renders a distinct calm farewell (not the reward treatment) for crossed_over, with the entity glyph', () => {
const { container } = renderPanel({
judgmentResult: result({ correct: true, atPeace: true, essenceDelta: 40, consequence: 'crossed_over' }),
})
expect(screen.getByText(/at peace/i)).toBeTruthy()
expect(screen.queryByText(/bond holds/i)).toBeNull() // not the reward copy
expect(container.querySelector('.judgment-farewell')).not.toBeNull()
expect(container.querySelector('.farewell-glyph')).not.toBeNull()
expect(container.querySelector('.consequence-reward')).toBeNull()
})
// A judgment_result always *arrives* after mount (the reducer starts
// with judgmentResult: null and updates it once the server answers) —
// simulate that transition with a rerender rather than mounting with
// the result already present, matching how SeanceProvider actually
// drives this component.
function renderThenDeliver(consequence: JudgmentResult['consequence'], overrides: Partial<JudgmentResult> = {}) {
const sendJudgment = vi.fn()
const state: SeanceState = { ...initialSeanceState, entity, judgmentResult: null }
const api = { state, sendJudgment } as unknown as SeanceApi
const { rerender } = render(
<SeanceContext.Provider value={api}>
<JudgmentPanel />
</SeanceContext.Provider>,
)
const nextApi = { ...api, state: { ...state, judgmentResult: result({ consequence, ...overrides }) } }
rerender(
<SeanceContext.Provider value={nextApi}>
<JudgmentPanel />
</SeanceContext.Provider>,
)
}
it('calls forceEscalate once judgment_result arrives with consequence: escalation', () => {
const spy = vi.spyOn(haunting, 'forceEscalate').mockImplementation(() => undefined)
renderThenDeliver('escalation', { favorDelta: -0.12 })
expect(spy).toHaveBeenCalledTimes(1)
spy.mockRestore()
})
it('does not call forceEscalate for any other consequence', () => {
const spy = vi.spyOn(haunting, 'forceEscalate').mockImplementation(() => undefined)
renderThenDeliver('reward')
expect(spy).not.toHaveBeenCalled()
spy.mockRestore()
})
})
describe('JudgmentPanel — pending clears on a fresh result', () => {
beforeEach(() => {
vi.spyOn(haunting, 'forceEscalate').mockImplementation(() => undefined)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('clears the pending state once judgmentResult changes (rerender simulates the reducer answering)', () => {
const sendJudgment = vi.fn()
const state: SeanceState = { ...initialSeanceState, entity, judgmentResult: null }
const api = { state, sendJudgment } as unknown as SeanceApi
const { rerender } = render(
<SeanceContext.Provider value={api}>
<JudgmentPanel />
</SeanceContext.Provider>,
)
fireEvent.click(screen.getByRole('button', { name: /^test$/i }))
expect(screen.getByText(/awaiting the verdict/i)).toBeTruthy()
const nextApi = { ...api, state: { ...state, judgmentResult: result({ consequence: 'neutral' }) } }
rerender(
<SeanceContext.Provider value={nextApi}>
<JudgmentPanel />
</SeanceContext.Provider>,
)
expect(screen.queryByText(/awaiting the verdict/i)).toBeNull()
for (const btn of screen.getAllByRole('button')) {
expect(btn).not.toBeDisabled()
}
})
})

View File

@@ -0,0 +1,265 @@
// JudgmentPanel — the Trust / Banish / Cross Over / Test verdict controls.
// Sends a `judgment` WS frame per verdict (always allowed — a seeker can
// judge blind without completing the ritual first) and renders the
// `judgment_result` consequence with a treatment specific to each outcome:
// a warm reward, a sharpened haunting on a wrongful trust, a wounded
// withdrawal on a wrongful banish, a resisted demon, a neutral diagnostic
// — and, distinct from all of those, a slow calm farewell fade when a
// stuck spirit is correctly helped to cross over (a goodbye, not a reward).
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSeance } from '../state/seance'
import { forceEscalate } from '../lib/haunting'
import { GhostGlyph } from './GhostGlyph'
import type { JudgmentResult } from '../state/seance'
import type { JudgmentConsequence, JudgmentVerdict, SpiritEntity } from '../lib/types'
import './JudgmentPanel.css'
type TFn = (key: string, options?: Record<string, unknown>) => string
type Copy = { title: string; body: string }
function consequenceCopy(consequence: JudgmentConsequence, t: TFn): Copy {
switch (consequence) {
case 'reward':
return {
title: t('seance.judgment.reward.title', { defaultValue: 'the bond holds' }),
body: t('seance.judgment.reward.body', { defaultValue: 'the spirit answers your trust in kind.' }),
}
case 'escalation':
return {
title: t('seance.judgment.escalation.title', { defaultValue: 'a reckoning' }),
body: t('seance.judgment.escalation.body', {
defaultValue: 'wrongly trusted, it turns — the haunting sharpens.',
}),
}
case 'withdrawal':
return {
title: t('seance.judgment.withdrawal.title', { defaultValue: 'it recoils' }),
body: t('seance.judgment.withdrawal.body', {
defaultValue: 'banished in error, the presence withdraws, wounded.',
}),
}
case 'resisted':
return {
title: t('seance.judgment.resisted.title', { defaultValue: 'it will not go' }),
body: t('seance.judgment.resisted.body', {
defaultValue: 'a demon does not cross over — it claws back to stay.',
}),
}
case 'crossed_over':
return {
title: t('seance.judgment.crossedOver.title', { defaultValue: 'at peace' }),
body: t('seance.judgment.crossedOver.body', { defaultValue: 'the spirit lets go, and the line falls quiet.' }),
}
case 'neutral':
default:
return {
title: t('seance.judgment.neutral.title', { defaultValue: 'no verdict rendered' }),
body: t('seance.judgment.neutral.body', {
defaultValue: 'a diagnostic pulse only — nothing risked, nothing gained.',
}),
}
}
}
// ---- rune glyphs (GhostGlyph-style radial-gradient SVGs, one per verdict) ----
function TrustRune() {
return (
<svg viewBox="0 0 32 32" width="18" height="18" className="verdict-rune" aria-hidden="true">
<defs>
<radialGradient id="rune-trust" cx="50%" cy="42%" r="65%">
<stop offset="0%" stopColor="#eaffef" stopOpacity="0.95" />
<stop offset="100%" stopColor="#7cffb2" stopOpacity="0.3" />
</radialGradient>
</defs>
<circle cx="16" cy="16" r="11" fill="url(#rune-trust)" stroke="#7cffb2" strokeWidth="1.3" />
<path
d="M10 16.5 L14 20.5 L22 11.5"
fill="none"
stroke="#08150e"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function BanishRune() {
return (
<svg viewBox="0 0 32 32" width="18" height="18" className="verdict-rune" aria-hidden="true">
<circle cx="16" cy="16" r="11" fill="rgba(255,59,92,0.08)" stroke="#ff3b5c" strokeWidth="1.3" opacity="0.85" />
<path
d="M9 8 L17 15 L13 15 L23 24 M22 8 L17 13"
fill="none"
stroke="#ff3b5c"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function CrossOverRune() {
return (
<svg viewBox="0 0 32 32" width="18" height="18" className="verdict-rune" aria-hidden="true">
<defs>
<radialGradient id="rune-crossover" cx="50%" cy="50%" r="60%">
<stop offset="0%" stopColor="#e6d4ff" stopOpacity="0.85" />
<stop offset="65%" stopColor="#b26bff" stopOpacity="0.22" />
<stop offset="100%" stopColor="#b26bff" stopOpacity="0" />
</radialGradient>
</defs>
<circle cx="16" cy="16" r="12" fill="url(#rune-crossover)" />
<circle cx="16" cy="16" r="12" fill="none" stroke="#b26bff" strokeWidth="0.9" opacity="0.45" />
</svg>
)
}
function TestRune() {
return (
<svg viewBox="0 0 32 32" width="18" height="18" className="verdict-rune" aria-hidden="true">
<circle cx="13.5" cy="13.5" r="7.5" fill="none" stroke="#d3e9db" strokeWidth="1.3" opacity="0.8" />
<line x1="19" y1="19" x2="25" y2="25" stroke="#d3e9db" strokeWidth="1.5" strokeLinecap="round" opacity="0.8" />
<path
d="M10.5 13.5 L12.5 15.5 L16.5 10.5"
fill="none"
stroke="#d3e9db"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.7"
/>
</svg>
)
}
const VERDICTS: readonly {
verdict: JudgmentVerdict
labelKey: string
fallback: string
Icon: () => JSX.Element
cls: string
}[] = [
{ verdict: 'trust', labelKey: 'seance.judgment.trust', fallback: 'trust', Icon: TrustRune, cls: 'verdict-trust' },
{ verdict: 'banish', labelKey: 'seance.judgment.banish', fallback: 'banish', Icon: BanishRune, cls: 'verdict-banish' },
{
verdict: 'cross_over',
labelKey: 'seance.judgment.crossOver',
fallback: 'cross over',
Icon: CrossOverRune,
cls: 'verdict-crossover',
},
{ verdict: 'test', labelKey: 'seance.judgment.test', fallback: 'test', Icon: TestRune, cls: 'verdict-test' },
]
export function JudgmentPanel() {
const { state, sendJudgment } = useSeance()
const { t } = useTranslation()
const [pending, setPending] = useState<JudgmentVerdict | null>(null)
const seenResultRef = useRef<JudgmentResult | null>(state.judgmentResult)
useEffect(() => {
if (state.judgmentResult && state.judgmentResult !== seenResultRef.current) {
seenResultRef.current = state.judgmentResult
setPending(null)
// The wrongly-trusted consequence lashes the ambient haunting
// immediately rather than waiting for the normal idle escalation.
if (state.judgmentResult.consequence === 'escalation') forceEscalate()
}
}, [state.judgmentResult])
if (!state.entity) {
return (
<section className="judgment-panel judgment-empty">
<p className="ritual-none">{t('seance.judgment.none', { defaultValue: 'no presence to judge' })}</p>
<p className="ritual-none-hint dim">
{t('seance.judgment.noneHint', { defaultValue: 'summon a spirit first' })}
</p>
</section>
)
}
const cast = (verdict: JudgmentVerdict) => {
if (pending) return
setPending(verdict)
sendJudgment(verdict)
}
const result = state.judgmentResult
const crossedOver = result?.consequence === 'crossed_over'
return (
<section className="judgment-panel">
<h4 className="ritual-title">{t('seance.judgment.title', { defaultValue: 'render judgment' })}</h4>
<div className="judgment-actions">
{VERDICTS.map(({ verdict, labelKey, fallback, Icon, cls }) => (
<button
key={verdict}
type="button"
className={`verdict-btn ${cls}`}
onClick={() => cast(verdict)}
disabled={pending !== null}
aria-label={t(labelKey, { defaultValue: fallback })}
>
<Icon />
<span>{t(labelKey, { defaultValue: fallback })}</span>
</button>
))}
</div>
{pending && (
<p className="judgment-pending dim flicker">
{t('seance.judgment.pending', { defaultValue: 'awaiting the verdict…' })}
</p>
)}
{result && crossedOver && <CrossedOverCard result={result} entity={state.entity} t={t} />}
{result && !crossedOver && <ConsequenceCard result={result} t={t} />}
</section>
)
}
function DeltaRow({ result, t }: { result: JudgmentResult; t: TFn }) {
if (result.favorDelta === 0 && result.essenceDelta === 0) return null
const favorTxt = `${result.favorDelta >= 0 ? '+' : ''}${result.favorDelta.toFixed(2)}`
const essenceTxt = `${result.essenceDelta >= 0 ? '+' : ''}${result.essenceDelta}`
return (
<p className="judgment-deltas dim">
{t('seance.judgment.favor', { defaultValue: 'favor' })} {favorTxt} ·{' '}
{t('seance.judgment.essence', { defaultValue: 'essence' })} {essenceTxt}
</p>
)
}
function ConsequenceCard({ result, t }: { result: JudgmentResult; t: TFn }) {
const copy = consequenceCopy(result.consequence, t)
return (
<div className={`judgment-consequence consequence-${result.consequence}`}>
<p className="judgment-consequence-title">{copy.title}</p>
<p className="judgment-consequence-body dim">{copy.body}</p>
<DeltaRow result={result} t={t} />
</div>
)
}
// The compassionate resolution gets a visibly different beat from a
// "reward" — a farewell, not a continuation. The entity's own glyph fades
// gently over ~2.4s rather than any triumphant glow.
function CrossedOverCard({ result, entity, t }: { result: JudgmentResult; entity: SpiritEntity; t: TFn }) {
const copy = consequenceCopy('crossed_over', t)
return (
<div className="judgment-consequence consequence-crossed_over judgment-farewell">
<div className="farewell-glyph-wrap">
<GhostGlyph hue={entity.visual.hue} form={entity.visual.form} size={56} className="farewell-glyph" />
</div>
<p className="judgment-consequence-title">{copy.title}</p>
<p className="judgment-consequence-body dim">{copy.body}</p>
<DeltaRow result={result} t={t} />
</div>
)
}

View File

@@ -0,0 +1,195 @@
/* RitualPanel — the "focus the channel" mini-ritual. Fused hacker/witch
aesthetic: a monospace command log inside candlelit sigil chrome, not a
flat CLI window and not pure occult set-dressing. Reuses SeancePage.css's
token palette (phosphor #7cffb2 · violet #b26bff · blood #ff3b5c). */
.ritual-panel {
margin-top: 0.75rem;
}
.ritual-frame {
position: relative;
border: 1px solid rgba(178, 107, 255, 0.32);
border-radius: 6px;
padding: 0.85rem 0.9rem;
background:
radial-gradient(ellipse at 15% -15%, rgba(178, 107, 255, 0.16), transparent 60%),
radial-gradient(ellipse at 105% 120%, rgba(124, 255, 178, 0.08), transparent 55%),
rgba(10, 10, 18, 0.55);
box-shadow: 0 0 22px rgba(178, 107, 255, 0.08) inset;
}
.ritual-title {
margin: 0 0 0.65rem;
font-family: 'Cinzel', serif;
font-size: 0.72rem;
letter-spacing: 0.24em;
color: rgba(178, 107, 255, 0.85);
text-shadow: 0 0 10px rgba(178, 107, 255, 0.4);
}
.ritual-none {
margin: 0 0 0.2rem;
font-family: 'Cinzel', serif;
font-size: 0.8rem;
letter-spacing: 0.06em;
}
.ritual-none-hint {
margin: 0;
font-size: 0.74rem;
}
.ritual-begin {
margin-top: 0;
}
.ritual-terminal {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.76rem;
line-height: 1.6;
color: #7cffb2;
min-height: 1.6em;
margin-bottom: 0.75rem;
}
.ritual-log-active {
opacity: 0.85;
}
.ritual-log-active::after {
content: '▌';
margin-left: 2px;
animation: cursorBlink 1s steps(1) infinite;
}
/* the sigil: a hold-to-charge candlelit rune button */
.ritual-sigil {
--charge: 0;
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 1.1rem 0.5rem;
background: radial-gradient(circle at 50% 45%, rgba(178, 107, 255, 0.16), rgba(10, 10, 18, 0.4) 70%);
border: 1px solid rgba(178, 107, 255, 0.4);
border-radius: 12px;
color: #e6d4ff;
cursor: pointer;
user-select: none;
touch-action: none;
transition: box-shadow 120ms ease;
}
.ritual-sigil-ring {
position: absolute;
inset: 4px;
border-radius: 9px;
pointer-events: none;
background: conic-gradient(
from -90deg,
#b26bff calc(var(--charge) * 360deg),
rgba(178, 107, 255, 0.12) calc(var(--charge) * 360deg)
);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
padding: 2px;
}
.ritual-sigil:hover,
.ritual-sigil:active {
box-shadow: 0 0 20px rgba(178, 107, 255, 0.35);
}
.ritual-sigil-glyph {
position: relative;
font-size: 1.6rem;
color: #b26bff;
text-shadow: 0 0 14px rgba(178, 107, 255, 0.55);
}
.ritual-sigil-label {
position: relative;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.12em;
color: rgba(230, 212, 255, 0.75);
}
.ritual-result {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ritual-result-title {
margin: 0;
font-family: 'Cinzel', serif;
font-size: 0.82rem;
letter-spacing: 0.05em;
}
.ritual-success .ritual-result-title {
color: #7cffb2;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.5);
}
.ritual-failure .ritual-result-title {
color: #ff3b5c;
text-shadow: 0 0 12px rgba(255, 59, 92, 0.4);
}
.ritual-traits {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
}
.ritual-trait-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ritual-trait-row dt {
flex: 0 0 6.5rem;
margin: 0;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(211, 233, 219, 0.65);
}
.ritual-trait-row dd {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.ritual-trait-bar {
flex: 1;
height: 6px;
border-radius: 3px;
background: rgba(211, 233, 219, 0.1);
overflow: hidden;
}
.ritual-trait-fill {
display: block;
height: 100%;
background: linear-gradient(90deg, #7cffb2, #b26bff);
box-shadow: 0 0 8px rgba(178, 107, 255, 0.5);
}
.ritual-trait-value {
font-size: 0.7rem;
color: rgba(211, 233, 219, 0.7);
width: 2.6em;
text-align: right;
}

View File

@@ -0,0 +1,141 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '../i18n'
import { RitualPanel } from './RitualPanel'
import { SeanceContext, initialSeanceState } from '../state/seance'
import type { SeanceApi, SeanceState } from '../state/seance'
import type { SpiritEntity } from '../lib/types'
import { RITUAL_HOLD_MS } from '../lib/ritual'
const entity: SpiritEntity = {
id: 'e-1',
name: 'Mirela',
epithet: 'the Drowned',
persona: 'a soft-spoken drowned girl',
rarity: 'rare',
voice: { voice_id: 'v-1', pitch: 0.5, rate: 0.5, noise: 0.3, echo: 0.4 },
visual: { hue: 200, form: 'wisp' },
quotes: [],
contact_count: 1,
discovered_at: '2026-01-01T00:00:00.000Z',
}
function renderPanel(stateOverrides: Partial<SeanceState> = {}, apiOverrides: Partial<SeanceApi> = {}) {
const startRitual = vi.fn()
const sendRitualStep = vi.fn()
const state: SeanceState = { ...initialSeanceState, entity, ...stateOverrides }
const api = {
state,
startRitual,
sendRitualStep,
sendJudgment: vi.fn(),
} as unknown as SeanceApi
Object.assign(api, apiOverrides)
const utils = render(
<SeanceContext.Provider value={api}>
<RitualPanel />
</SeanceContext.Provider>,
)
return { ...utils, startRitual, sendRitualStep }
}
function holdSigil() {
const sigil = screen.getByText(/hold to focus/i).closest('button')!
fireEvent.pointerDown(sigil)
return sigil
}
describe('RitualPanel', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('shows an empty state with no summoned entity', () => {
renderPanel({ entity: null })
expect(screen.getByText(/no presence to focus on/i)).toBeTruthy()
expect(screen.queryByText(/begin the rite/i)).toBeNull()
})
it('begins the rite: local UI advances and the provider is told', () => {
const { startRitual } = renderPanel()
fireEvent.click(screen.getByText(/begin the rite/i))
expect(startRitual).toHaveBeenCalledTimes(1)
expect(screen.getByText(/hold to focus/i)).toBeTruthy()
expect(screen.getByText(/1\/4/)).toBeTruthy()
})
it('holding a sigil the full charge duration sends a ritual_step and advances to the next one', () => {
const { sendRitualStep } = renderPanel()
fireEvent.click(screen.getByText(/begin the rite/i))
holdSigil()
act(() => {
vi.advanceTimersByTime(RITUAL_HOLD_MS + 50)
})
expect(sendRitualStep).toHaveBeenCalledWith(1)
expect(screen.getByText(/2\/4/)).toBeTruthy()
})
it('releasing early does not complete the step', () => {
const { sendRitualStep } = renderPanel()
fireEvent.click(screen.getByText(/begin the rite/i))
const sigil = holdSigil()
act(() => {
vi.advanceTimersByTime(RITUAL_HOLD_MS / 2)
})
fireEvent.pointerUp(sigil)
act(() => {
vi.advanceTimersByTime(RITUAL_HOLD_MS + 50)
})
expect(sendRitualStep).not.toHaveBeenCalled()
expect(screen.getByText(/1\/4/)).toBeTruthy()
})
it('completing all four steps sends four ritual_step frames in order and awaits the server', () => {
const { sendRitualStep } = renderPanel()
fireEvent.click(screen.getByText(/begin the rite/i))
for (let i = 0; i < 4; i++) {
holdSigil()
act(() => {
vi.advanceTimersByTime(RITUAL_HOLD_MS + 50)
})
}
expect(sendRitualStep.mock.calls.map((c) => c[0])).toEqual([1, 2, 3, 4])
expect(screen.getByText(/awaiting the veil/i)).toBeTruthy()
expect(screen.queryByText(/hold to focus/i)).toBeNull()
})
it('renders the revealed traits on a successful ritual_complete', () => {
renderPanel({
ritual: {
status: 'success',
revealed: { alignment: 0.9, power: 0.4, volatility: 0.1, deceptiveness: 0.05 },
},
})
expect(screen.getByText(/truth surfaces/i)).toBeTruthy()
expect(screen.getByText('0.90')).toBeTruthy()
expect(screen.getByText('0.40')).toBeTruthy()
})
it('renders a failure message with no revealed traits', () => {
renderPanel({ ritual: { status: 'failure', revealed: null } })
expect(screen.getByText(/channel collapses/i)).toBeTruthy()
expect(screen.queryByText(/truth surfaces/i)).toBeNull()
})
it('"attempt again" after a result re-invokes the provider', () => {
const { startRitual } = renderPanel({ ritual: { status: 'failure', revealed: null } })
fireEvent.click(screen.getByText(/attempt again/i))
expect(startRitual).toHaveBeenCalledTimes(1)
})
})

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