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:
@@ -12,10 +12,10 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
HauntBed,
|
HauntBed,
|
||||||
IdleEscalator,
|
|
||||||
flickerTitle,
|
flickerTitle,
|
||||||
hauntBus,
|
hauntBus,
|
||||||
pickWhisperWord,
|
pickWhisperWord,
|
||||||
|
sharedIdleEscalator,
|
||||||
} from '../lib/haunting'
|
} from '../lib/haunting'
|
||||||
import './HauntingLayer.css'
|
import './HauntingLayer.css'
|
||||||
|
|
||||||
@@ -84,7 +84,10 @@ export function HauntingLayer() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reduced = prefersReducedMotion()
|
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 bed = new HauntBed()
|
||||||
const timers = new Set<ReturnType<typeof setTimeout>>()
|
const timers = new Set<ReturnType<typeof setTimeout>>()
|
||||||
let disposed = false
|
let disposed = false
|
||||||
|
|||||||
167
frontend/src/components/JudgmentPanel.css
Normal file
167
frontend/src/components/JudgmentPanel.css
Normal 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); }
|
||||||
|
}
|
||||||
185
frontend/src/components/JudgmentPanel.test.tsx
Normal file
185
frontend/src/components/JudgmentPanel.test.tsx
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
265
frontend/src/components/JudgmentPanel.tsx
Normal file
265
frontend/src/components/JudgmentPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
frontend/src/components/RitualPanel.css
Normal file
195
frontend/src/components/RitualPanel.css
Normal 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;
|
||||||
|
}
|
||||||
141
frontend/src/components/RitualPanel.test.tsx
Normal file
141
frontend/src/components/RitualPanel.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -180,6 +180,78 @@
|
|||||||
"mythic": "mythic"
|
"mythic": "mythic"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ritual": {
|
||||||
|
"title": "focus the channel",
|
||||||
|
"none": "no presence to focus on",
|
||||||
|
"noneHint": "summon a spirit before attempting the rite",
|
||||||
|
"begin": "begin the rite",
|
||||||
|
"retry": "attempt again",
|
||||||
|
"hold": "hold to focus",
|
||||||
|
"awaiting": "awaiting the veil's answer…",
|
||||||
|
"successTitle": "the channel holds — the truth surfaces",
|
||||||
|
"failureTitle": "the channel collapses",
|
||||||
|
"failureHint": "the truth stays hidden — the rite may be tried again",
|
||||||
|
"traits": {
|
||||||
|
"alignment": "alignment",
|
||||||
|
"power": "power",
|
||||||
|
"volatility": "volatility",
|
||||||
|
"deceptiveness": "deceptiveness"
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"align": {
|
||||||
|
"command": "> aligning the wire…",
|
||||||
|
"locked": "> wire aligned ✓"
|
||||||
|
},
|
||||||
|
"breath": {
|
||||||
|
"command": "> steadying the breath…",
|
||||||
|
"locked": "> breath steadied ✓"
|
||||||
|
},
|
||||||
|
"trace": {
|
||||||
|
"command": "> tracing the sigil…",
|
||||||
|
"locked": "> sigil traced ✓"
|
||||||
|
},
|
||||||
|
"lock": {
|
||||||
|
"command": "> locking the channel…",
|
||||||
|
"locked": "> channel locked ✓"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"judgment": {
|
||||||
|
"title": "render judgment",
|
||||||
|
"none": "no presence to judge",
|
||||||
|
"noneHint": "summon a spirit first",
|
||||||
|
"trust": "trust",
|
||||||
|
"banish": "banish",
|
||||||
|
"crossOver": "cross over",
|
||||||
|
"test": "test",
|
||||||
|
"pending": "awaiting the verdict…",
|
||||||
|
"favor": "favor",
|
||||||
|
"essence": "essence",
|
||||||
|
"reward": {
|
||||||
|
"title": "the bond holds",
|
||||||
|
"body": "the spirit answers your trust in kind."
|
||||||
|
},
|
||||||
|
"escalation": {
|
||||||
|
"title": "a reckoning",
|
||||||
|
"body": "wrongly trusted, it turns — the haunting sharpens."
|
||||||
|
},
|
||||||
|
"withdrawal": {
|
||||||
|
"title": "it recoils",
|
||||||
|
"body": "banished in error, the presence withdraws, wounded."
|
||||||
|
},
|
||||||
|
"crossedOver": {
|
||||||
|
"title": "at peace",
|
||||||
|
"body": "the spirit lets go, and the line falls quiet."
|
||||||
|
},
|
||||||
|
"resisted": {
|
||||||
|
"title": "it will not go",
|
||||||
|
"body": "a demon does not cross over — it claws back to stay."
|
||||||
|
},
|
||||||
|
"neutral": {
|
||||||
|
"title": "no verdict rendered",
|
||||||
|
"body": "a diagnostic pulse only — nothing risked, nothing gained."
|
||||||
|
}
|
||||||
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"rate_limited": "the veil is thin — slow your knocking",
|
"rate_limited": "the veil is thin — slow your knocking",
|
||||||
"veil_crowded": "too many spirits crowd the line"
|
"veil_crowded": "too many spirits crowd the line"
|
||||||
|
|||||||
@@ -180,6 +180,78 @@
|
|||||||
"mythic": "mítico"
|
"mythic": "mítico"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ritual": {
|
||||||
|
"title": "enfoca el canal",
|
||||||
|
"none": "ninguna presencia que enfocar",
|
||||||
|
"noneHint": "invoca un espíritu antes de intentar el rito",
|
||||||
|
"begin": "comenzar el rito",
|
||||||
|
"retry": "intentar de nuevo",
|
||||||
|
"hold": "mantén para enfocar",
|
||||||
|
"awaiting": "esperando la respuesta del velo…",
|
||||||
|
"successTitle": "el canal se sostiene — la verdad emerge",
|
||||||
|
"failureTitle": "el canal colapsa",
|
||||||
|
"failureHint": "la verdad permanece oculta — el rito puede intentarse de nuevo",
|
||||||
|
"traits": {
|
||||||
|
"alignment": "alineación",
|
||||||
|
"power": "poder",
|
||||||
|
"volatility": "volatilidad",
|
||||||
|
"deceptiveness": "engaño"
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"align": {
|
||||||
|
"command": "> alineando el cable…",
|
||||||
|
"locked": "> cable alineado ✓"
|
||||||
|
},
|
||||||
|
"breath": {
|
||||||
|
"command": "> serenando el aliento…",
|
||||||
|
"locked": "> aliento sereno ✓"
|
||||||
|
},
|
||||||
|
"trace": {
|
||||||
|
"command": "> trazando el sigilo…",
|
||||||
|
"locked": "> sigilo trazado ✓"
|
||||||
|
},
|
||||||
|
"lock": {
|
||||||
|
"command": "> cerrando el canal…",
|
||||||
|
"locked": "> canal cerrado ✓"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"judgment": {
|
||||||
|
"title": "dicta juicio",
|
||||||
|
"none": "ninguna presencia que juzgar",
|
||||||
|
"noneHint": "invoca un espíritu primero",
|
||||||
|
"trust": "confiar",
|
||||||
|
"banish": "desterrar",
|
||||||
|
"crossOver": "cruzar al más allá",
|
||||||
|
"test": "poner a prueba",
|
||||||
|
"pending": "esperando el veredicto…",
|
||||||
|
"favor": "favor",
|
||||||
|
"essence": "esencia",
|
||||||
|
"reward": {
|
||||||
|
"title": "el vínculo se sostiene",
|
||||||
|
"body": "el espíritu responde a tu confianza de igual manera."
|
||||||
|
},
|
||||||
|
"escalation": {
|
||||||
|
"title": "un ajuste de cuentas",
|
||||||
|
"body": "confiado en error, se revuelve — la aparición se agudiza."
|
||||||
|
},
|
||||||
|
"withdrawal": {
|
||||||
|
"title": "se retrae",
|
||||||
|
"body": "desterrado en error, la presencia se retira, herida."
|
||||||
|
},
|
||||||
|
"crossedOver": {
|
||||||
|
"title": "en paz",
|
||||||
|
"body": "el espíritu se suelta, y la línea queda en silencio."
|
||||||
|
},
|
||||||
|
"resisted": {
|
||||||
|
"title": "no se irá",
|
||||||
|
"body": "un demonio no cruza al más allá — se aferra para quedarse."
|
||||||
|
},
|
||||||
|
"neutral": {
|
||||||
|
"title": "ningún veredicto emitido",
|
||||||
|
"body": "solo un pulso de diagnóstico — no se arriesga ni se gana nada."
|
||||||
|
}
|
||||||
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"rate_limited": "el velo es delgado — golpea más despacio",
|
"rate_limited": "el velo es delgado — golpea más despacio",
|
||||||
"veil_crowded": "demasiados espíritus se apiñan en la línea"
|
"veil_crowded": "demasiados espíritus se apiñan en la línea"
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
HauntBus,
|
HauntBus,
|
||||||
IdleEscalator,
|
IdleEscalator,
|
||||||
flickerTitle,
|
flickerTitle,
|
||||||
|
forceEscalate,
|
||||||
hauntBus,
|
hauntBus,
|
||||||
pickWhisperWord,
|
pickWhisperWord,
|
||||||
|
sharedIdleEscalator,
|
||||||
type HauntScheduler,
|
type HauntScheduler,
|
||||||
} from './haunting'
|
} from './haunting'
|
||||||
|
|
||||||
@@ -56,6 +58,63 @@ describe('IdleEscalator', () => {
|
|||||||
expect(e.level(95_000 + 31_000)).toBe(1)
|
expect(e.level(95_000 + 31_000)).toBe(1)
|
||||||
expect(e.level(95_000 + 91_000)).toBe(2)
|
expect(e.level(95_000 + 91_000)).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('forceEscalate', () => {
|
||||||
|
it('pins level() at 2 immediately, even fresh off activity', () => {
|
||||||
|
const e = new IdleEscalator(0)
|
||||||
|
e.notifyActivity(0)
|
||||||
|
expect(e.level(1_000)).toBe(0) // sanity: still fresh, no force yet
|
||||||
|
e.forceEscalate(20_000, 1_000)
|
||||||
|
expect(e.level(1_000)).toBe(2)
|
||||||
|
expect(e.level(1_000 + 19_999)).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expires after the given duration, falling back to the idle clock', () => {
|
||||||
|
const e = new IdleEscalator(0)
|
||||||
|
e.forceEscalate(20_000, 0)
|
||||||
|
expect(e.level(19_999)).toBe(2)
|
||||||
|
expect(e.level(20_000)).toBe(0) // idle clock: 20s since lastActivity=0 < 30s
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a later call extends rather than shortens an active window', () => {
|
||||||
|
const e = new IdleEscalator(0)
|
||||||
|
e.forceEscalate(20_000, 0)
|
||||||
|
e.forceEscalate(5_000, 10_000) // would expire at 15_000, earlier than 20_000
|
||||||
|
expect(e.level(18_000)).toBe(2) // still within the original, longer window
|
||||||
|
})
|
||||||
|
|
||||||
|
it('real activity does not cut a force window short', () => {
|
||||||
|
const e = new IdleEscalator(0)
|
||||||
|
e.forceEscalate(20_000, 0)
|
||||||
|
e.notifyActivity(5_000)
|
||||||
|
expect(e.level(10_000)).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sharedIdleEscalator / forceEscalate()', () => {
|
||||||
|
it('is a single shared instance, not a fresh one per import', () => {
|
||||||
|
sharedIdleEscalator.notifyActivity(0)
|
||||||
|
expect(sharedIdleEscalator.level(0)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forces the shared escalator to its max level', () => {
|
||||||
|
sharedIdleEscalator.notifyActivity(0)
|
||||||
|
forceEscalate(20_000)
|
||||||
|
expect(sharedIdleEscalator.level(0)).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits an immediate flare and manifest burst on the shared bus', () => {
|
||||||
|
const flares: number[] = []
|
||||||
|
const manifests: number[] = []
|
||||||
|
const offF = hauntBus.on('flare', (d) => flares.push(d.strength))
|
||||||
|
const offM = hauntBus.on('manifest', (d) => manifests.push(d.durationMs))
|
||||||
|
forceEscalate()
|
||||||
|
expect(flares).toEqual([1])
|
||||||
|
expect(manifests).toEqual([900])
|
||||||
|
offF()
|
||||||
|
offM()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('flickerTitle', () => {
|
describe('flickerTitle', () => {
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ export function pickWhisperWord(words: readonly string[], rand: () => number): s
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Paces the haunting by how long the seeker has been still:
|
* Paces the haunting by how long the seeker has been still:
|
||||||
* 0 under 30s idle, 1 under 90s, 2 beyond.
|
* 0 under 30s idle, 1 under 90s, 2 beyond. A `forceEscalate` window can
|
||||||
|
* also pin the level at its maximum independent of idle time — see below.
|
||||||
*/
|
*/
|
||||||
export class IdleEscalator {
|
export class IdleEscalator {
|
||||||
private lastActivity: number
|
private lastActivity: number
|
||||||
|
private forcedUntil = 0
|
||||||
|
|
||||||
constructor(nowMs: number = Date.now()) {
|
constructor(nowMs: number = Date.now()) {
|
||||||
this.lastActivity = nowMs
|
this.lastActivity = nowMs
|
||||||
@@ -72,7 +74,19 @@ export class IdleEscalator {
|
|||||||
this.lastActivity = nowMs
|
this.lastActivity = nowMs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin `level()` at its maximum (2) until `durationMs` from now elapses,
|
||||||
|
* regardless of actual idle time. A later call while one is already
|
||||||
|
* active only extends the window, never shortens it. Real seeker
|
||||||
|
* activity does not cancel an active force early — the spike runs its
|
||||||
|
* course rather than being shrugged off by a single click.
|
||||||
|
*/
|
||||||
|
forceEscalate(durationMs: number = 20_000, nowMs: number = Date.now()): void {
|
||||||
|
this.forcedUntil = Math.max(this.forcedUntil, nowMs + durationMs)
|
||||||
|
}
|
||||||
|
|
||||||
level(nowMs: number = Date.now()): number {
|
level(nowMs: number = Date.now()): number {
|
||||||
|
if (nowMs < this.forcedUntil) return 2
|
||||||
const idle = nowMs - this.lastActivity
|
const idle = nowMs - this.lastActivity
|
||||||
if (idle < 30_000) return 0
|
if (idle < 30_000) return 0
|
||||||
if (idle < 90_000) return 1
|
if (idle < 90_000) return 1
|
||||||
@@ -80,6 +94,31 @@ export class IdleEscalator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single `IdleEscalator` instance `HauntingLayer` paces the ambient
|
||||||
|
* vignette/bed by. Exported (rather than kept local to that component) so
|
||||||
|
* other systems can force a spike without owning idle-tracking state of
|
||||||
|
* their own — see `forceEscalate()` immediately below, which is how a
|
||||||
|
* `judgment_result` with `consequence: "escalation"` reaches the ambient
|
||||||
|
* haunting layer.
|
||||||
|
*/
|
||||||
|
export const sharedIdleEscalator = new IdleEscalator()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force the ambient haunting to its most intense idle level immediately
|
||||||
|
* (bypassing the normal 90s idle wait) for `durationMs`, and fire one
|
||||||
|
* immediate flare + manifest burst on the shared bus so screens whose
|
||||||
|
* visuals are driven by those events, not just the idle poll, still react
|
||||||
|
* right away. This is the "forced escalation" extension point: a wrongly
|
||||||
|
* -trusted entity (`judgment_result.consequence === "escalation"`) calls
|
||||||
|
* this rather than the haunting waiting for the seeker to sit still.
|
||||||
|
*/
|
||||||
|
export function forceEscalate(durationMs: number = 20_000): void {
|
||||||
|
sharedIdleEscalator.forceEscalate(durationMs)
|
||||||
|
hauntBus.emit('flare', { strength: 1 })
|
||||||
|
hauntBus.emit('manifest', { durationMs: 900 })
|
||||||
|
}
|
||||||
|
|
||||||
// ---- document.title flicker ----
|
// ---- document.title flicker ----
|
||||||
|
|
||||||
/** Injectable scheduler: run `fn` after `ms`, return a cancel function. */
|
/** Injectable scheduler: run `fn` after `ms`, return a cancel function. */
|
||||||
|
|||||||
75
frontend/src/lib/ritual.test.ts
Normal file
75
frontend/src/lib/ritual.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
RITUAL_HOLD_MS,
|
||||||
|
RITUAL_PROMPTS,
|
||||||
|
RITUAL_TOTAL_STEPS,
|
||||||
|
holdProgress,
|
||||||
|
nextStep,
|
||||||
|
stepComplete,
|
||||||
|
} from './ritual'
|
||||||
|
|
||||||
|
describe('RITUAL_PROMPTS', () => {
|
||||||
|
it('is a fixed, 1-based sequence with distinct runes', () => {
|
||||||
|
expect(RITUAL_PROMPTS.map((p) => p.step)).toEqual([1, 2, 3, 4])
|
||||||
|
expect(new Set(RITUAL_PROMPTS.map((p) => p.rune)).size).toBe(RITUAL_PROMPTS.length)
|
||||||
|
expect(RITUAL_TOTAL_STEPS).toBe(RITUAL_PROMPTS.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('holdProgress', () => {
|
||||||
|
it('is 0 at the start of a hold', () => {
|
||||||
|
expect(holdProgress(0)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is 1 once the hold threshold is reached', () => {
|
||||||
|
expect(holdProgress(RITUAL_HOLD_MS)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is linear between 0 and the threshold', () => {
|
||||||
|
expect(holdProgress(RITUAL_HOLD_MS / 2)).toBeCloseTo(0.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps beyond the threshold instead of exceeding 1', () => {
|
||||||
|
expect(holdProgress(RITUAL_HOLD_MS * 5)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never goes negative for a negative heldMs', () => {
|
||||||
|
expect(holdProgress(-100)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects a custom holdMs', () => {
|
||||||
|
expect(holdProgress(50, 100)).toBeCloseTo(0.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('stepComplete', () => {
|
||||||
|
it('is false before the threshold', () => {
|
||||||
|
expect(stepComplete(RITUAL_HOLD_MS - 1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is true at and beyond the threshold', () => {
|
||||||
|
expect(stepComplete(RITUAL_HOLD_MS)).toBe(true)
|
||||||
|
expect(stepComplete(RITUAL_HOLD_MS + 500)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('nextStep', () => {
|
||||||
|
it('advances sequentially through the whole sequence', () => {
|
||||||
|
let step: number | null = 1
|
||||||
|
const seen = [step]
|
||||||
|
while (step !== null) {
|
||||||
|
step = nextStep(step, RITUAL_TOTAL_STEPS)
|
||||||
|
if (step !== null) seen.push(step)
|
||||||
|
}
|
||||||
|
expect(seen).toEqual([1, 2, 3, 4])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null once the last step completes', () => {
|
||||||
|
expect(nextStep(RITUAL_TOTAL_STEPS, RITUAL_TOTAL_STEPS)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports a custom total', () => {
|
||||||
|
expect(nextStep(1, 2)).toBe(2)
|
||||||
|
expect(nextStep(2, 2)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
47
frontend/src/lib/ritual.ts
Normal file
47
frontend/src/lib/ritual.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Pure helpers for the ritual mini-game's step sequencing: a short
|
||||||
|
// "focus the channel" hold-to-charge incantation the seeker performs
|
||||||
|
// before judging a spirit. Kept side-effect-free (no timers, no DOM) so
|
||||||
|
// RitualPanel's timing logic is unit-testable without mounting React or
|
||||||
|
// faking requestAnimationFrame.
|
||||||
|
|
||||||
|
export type RitualRune = 'align' | 'breath' | 'trace' | 'lock'
|
||||||
|
|
||||||
|
export type RitualPrompt = {
|
||||||
|
/** 1-based step number. */
|
||||||
|
step: number
|
||||||
|
rune: RitualRune
|
||||||
|
}
|
||||||
|
|
||||||
|
// The contract leaves the exact interaction to the implementer ("e.g. 3-5
|
||||||
|
// click/hold steps") — four chosen for a brief but deliberate rite: align
|
||||||
|
// the wire, steady the breath, trace the sigil, lock the channel.
|
||||||
|
export const RITUAL_PROMPTS: readonly RitualPrompt[] = [
|
||||||
|
{ step: 1, rune: 'align' },
|
||||||
|
{ step: 2, rune: 'breath' },
|
||||||
|
{ step: 3, rune: 'trace' },
|
||||||
|
{ step: 4, rune: 'lock' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const RITUAL_TOTAL_STEPS = RITUAL_PROMPTS.length
|
||||||
|
|
||||||
|
/** How long (ms) a sigil must be held to "charge" and complete a step. */
|
||||||
|
export const RITUAL_HOLD_MS = 850
|
||||||
|
|
||||||
|
/** 0..1 charge progress, for a ring/bar readout — clamps outside range. */
|
||||||
|
export function holdProgress(heldMs: number, holdMs: number = RITUAL_HOLD_MS): number {
|
||||||
|
if (holdMs <= 0) return 1
|
||||||
|
return Math.max(0, Math.min(1, heldMs / holdMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether holding for `heldMs` is enough to complete the current step. */
|
||||||
|
export function stepComplete(heldMs: number, holdMs: number = RITUAL_HOLD_MS): boolean {
|
||||||
|
return heldMs >= holdMs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The step number after completing `current`, or `null` once the whole
|
||||||
|
* sequence (`total` steps) is done.
|
||||||
|
*/
|
||||||
|
export function nextStep(current: number, total: number = RITUAL_TOTAL_STEPS): number | null {
|
||||||
|
return current < total ? current + 1 : null
|
||||||
|
}
|
||||||
@@ -61,6 +61,26 @@ export type Stats = {
|
|||||||
|
|
||||||
export type SessionStatus = 'attuning' | 'summoning' | 'gathering'
|
export type SessionStatus = 'attuning' | 'summoning' | 'gathering'
|
||||||
|
|
||||||
|
// ---- ritual / judgment (Character Depth spec contract) ----
|
||||||
|
|
||||||
|
/** Entity's hidden truth, revealed only on a successful ritual. */
|
||||||
|
export type EntityTraits = {
|
||||||
|
alignment: number
|
||||||
|
power: number
|
||||||
|
volatility: number
|
||||||
|
deceptiveness: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JudgmentVerdict = 'trust' | 'banish' | 'test' | 'cross_over'
|
||||||
|
|
||||||
|
export type JudgmentConsequence =
|
||||||
|
| 'reward'
|
||||||
|
| 'escalation'
|
||||||
|
| 'withdrawal'
|
||||||
|
| 'crossed_over'
|
||||||
|
| 'resisted'
|
||||||
|
| 'neutral'
|
||||||
|
|
||||||
// ---- WebSocket frames: client -> server ----
|
// ---- WebSocket frames: client -> server ----
|
||||||
|
|
||||||
export type ClientFrame =
|
export type ClientFrame =
|
||||||
@@ -71,6 +91,9 @@ export type ClientFrame =
|
|||||||
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
|
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
|
||||||
| { type: 'question'; text: string }
|
| { type: 'question'; text: string }
|
||||||
| { type: 'passive'; enabled: boolean }
|
| { type: 'passive'; enabled: boolean }
|
||||||
|
| { type: 'ritual_start' }
|
||||||
|
| { type: 'ritual_step'; step: number }
|
||||||
|
| { type: 'judgment'; verdict: JudgmentVerdict }
|
||||||
|
|
||||||
// ---- WebSocket frames: server -> client ----
|
// ---- WebSocket frames: server -> client ----
|
||||||
|
|
||||||
@@ -98,6 +121,15 @@ export type ServerFrame =
|
|||||||
| { type: 'reply_end'; id: string; text: string }
|
| { type: 'reply_end'; id: string; text: string }
|
||||||
| { type: 'telemetry' } & Telemetry
|
| { type: 'telemetry' } & Telemetry
|
||||||
| { type: 'error'; code: 'rate_limited' | 'veil_crowded' | string; message: string }
|
| { type: 'error'; code: 'rate_limited' | 'veil_crowded' | string; message: string }
|
||||||
|
| { type: 'ritual_complete'; success: boolean; revealed: EntityTraits | null }
|
||||||
|
| {
|
||||||
|
type: 'judgment_result'
|
||||||
|
correct: boolean
|
||||||
|
favor_delta: number
|
||||||
|
essence_delta: number
|
||||||
|
at_peace: boolean
|
||||||
|
consequence: JudgmentConsequence
|
||||||
|
}
|
||||||
|
|
||||||
// REST helpers
|
// REST helpers
|
||||||
export type SortOrder = 'recent' | 'contacted'
|
export type SortOrder = 'recent' | 'contacted'
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { MatrixView } from '../components/MatrixView'
|
|||||||
import { GraphView } from '../components/GraphView'
|
import { GraphView } from '../components/GraphView'
|
||||||
import { Transcript } from '../components/Transcript'
|
import { Transcript } from '../components/Transcript'
|
||||||
import { EntityCard } from '../components/EntityCard'
|
import { EntityCard } from '../components/EntityCard'
|
||||||
|
import { RitualPanel } from '../components/RitualPanel'
|
||||||
|
import { JudgmentPanel } from '../components/JudgmentPanel'
|
||||||
import { TelemetryReadout } from '../components/TelemetryReadout'
|
import { TelemetryReadout } from '../components/TelemetryReadout'
|
||||||
import { PlanchetteMachine } from '../lib/planchette'
|
import { PlanchetteMachine } from '../lib/planchette'
|
||||||
import type { PlanchetteSnapshot } from '../lib/planchette'
|
import type { PlanchetteSnapshot } from '../lib/planchette'
|
||||||
@@ -344,6 +346,9 @@ function SeanceSession({ username }: { username: string }) {
|
|||||||
onToggleOpen={() => setDossierOpen((o) => !o)}
|
onToggleOpen={() => setDossierOpen((o) => !o)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RitualPanel />
|
||||||
|
<JudgmentPanel />
|
||||||
|
|
||||||
<section className="mode-panel">
|
<section className="mode-panel">
|
||||||
{state.mode === 'wire' && <WirePanel />}
|
{state.mode === 'wire' && <WirePanel />}
|
||||||
{state.mode === 'evp' && <EvpPanel />}
|
{state.mode === 'evp' && <EvpPanel />}
|
||||||
|
|||||||
@@ -265,3 +265,146 @@ describe('seanceReducer', () => {
|
|||||||
expect(state).toEqual(initialSeanceState)
|
expect(state).toEqual(initialSeanceState)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('seanceReducer — ritual', () => {
|
||||||
|
it('starts idle', () => {
|
||||||
|
expect(initialSeanceState.ritual).toEqual({ status: 'idle', revealed: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('local_ritual_start marks the ritual in_progress and clears any prior reveal', () => {
|
||||||
|
const state = seanceReducer(initialSeanceState, { type: 'local_ritual_start' })
|
||||||
|
expect(state.ritual).toEqual({ status: 'in_progress', revealed: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ritual_complete success stores the revealed traits and logs a system row', () => {
|
||||||
|
let state = frame(initialSeanceState, { type: 'entity', is_new: true, entity })
|
||||||
|
state = seanceReducer(state, { type: 'local_ritual_start' })
|
||||||
|
|
||||||
|
const revealed = { alignment: 0.8, power: 0.4, volatility: 0.2, deceptiveness: 0.1 }
|
||||||
|
state = frame(state, { type: 'ritual_complete', success: true, revealed })
|
||||||
|
|
||||||
|
expect(state.ritual).toEqual({ status: 'success', revealed })
|
||||||
|
const row = lastRow(state)
|
||||||
|
expect(row.kind).toBe('system')
|
||||||
|
if (row.kind === 'system') expect(row.text).toMatch(/truth surfaces/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ritual_complete failure clears any reveal and logs a distinct system row', () => {
|
||||||
|
let state = seanceReducer(initialSeanceState, { type: 'local_ritual_start' })
|
||||||
|
state = frame(state, { type: 'ritual_complete', success: false, revealed: null })
|
||||||
|
|
||||||
|
expect(state.ritual).toEqual({ status: 'failure', revealed: null })
|
||||||
|
const row = lastRow(state)
|
||||||
|
if (row.kind === 'system') expect(row.text).toMatch(/collapses/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a fresh entity frame resets any prior ritual/judgment state', () => {
|
||||||
|
let state = seanceReducer(initialSeanceState, { type: 'local_ritual_start' })
|
||||||
|
state = frame(state, {
|
||||||
|
type: 'ritual_complete',
|
||||||
|
success: true,
|
||||||
|
revealed: { alignment: 1, power: 1, volatility: 1, deceptiveness: 1 },
|
||||||
|
})
|
||||||
|
state = frame(state, {
|
||||||
|
type: 'judgment_result',
|
||||||
|
correct: true,
|
||||||
|
favor_delta: 0.05,
|
||||||
|
essence_delta: 10,
|
||||||
|
at_peace: false,
|
||||||
|
consequence: 'reward',
|
||||||
|
})
|
||||||
|
expect(state.ritual.status).toBe('success')
|
||||||
|
expect(state.judgmentResult).not.toBeNull()
|
||||||
|
|
||||||
|
state = frame(state, { type: 'entity', is_new: false, entity })
|
||||||
|
expect(state.ritual).toEqual({ status: 'idle', revealed: null })
|
||||||
|
expect(state.judgmentResult).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('seanceReducer — judgment_result', () => {
|
||||||
|
function judgmentFrame(
|
||||||
|
overrides: Partial<{
|
||||||
|
correct: boolean
|
||||||
|
favor_delta: number
|
||||||
|
essence_delta: number
|
||||||
|
at_peace: boolean
|
||||||
|
consequence: 'reward' | 'escalation' | 'withdrawal' | 'crossed_over' | 'resisted' | 'neutral'
|
||||||
|
}> = {},
|
||||||
|
): ServerFrame {
|
||||||
|
return {
|
||||||
|
type: 'judgment_result',
|
||||||
|
correct: false,
|
||||||
|
favor_delta: 0,
|
||||||
|
essence_delta: 0,
|
||||||
|
at_peace: false,
|
||||||
|
consequence: 'neutral',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('records a correct-trust reward', () => {
|
||||||
|
const state = frame(
|
||||||
|
initialSeanceState,
|
||||||
|
judgmentFrame({ correct: true, favor_delta: 0.05, essence_delta: 15, consequence: 'reward' }),
|
||||||
|
)
|
||||||
|
expect(state.judgmentResult).toMatchObject({
|
||||||
|
correct: true,
|
||||||
|
favorDelta: 0.05,
|
||||||
|
essenceDelta: 15,
|
||||||
|
atPeace: false,
|
||||||
|
consequence: 'reward',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records a wrong-trust escalation', () => {
|
||||||
|
const state = frame(
|
||||||
|
initialSeanceState,
|
||||||
|
judgmentFrame({ correct: false, favor_delta: -0.12, essence_delta: 0, consequence: 'escalation' }),
|
||||||
|
)
|
||||||
|
expect(state.judgmentResult).toMatchObject({ consequence: 'escalation', favorDelta: -0.12 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records a wrong-banish withdrawal with a smaller favor penalty than escalation', () => {
|
||||||
|
const state = frame(
|
||||||
|
initialSeanceState,
|
||||||
|
judgmentFrame({ correct: false, favor_delta: -0.06, essence_delta: 0, consequence: 'withdrawal' }),
|
||||||
|
)
|
||||||
|
expect(state.judgmentResult).toMatchObject({ consequence: 'withdrawal', favorDelta: -0.06 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records a correctly-called cross_over with at_peace true and the largest essence reward', () => {
|
||||||
|
const state = frame(
|
||||||
|
initialSeanceState,
|
||||||
|
judgmentFrame({ correct: true, favor_delta: 0.05, essence_delta: 30, at_peace: true, consequence: 'crossed_over' }),
|
||||||
|
)
|
||||||
|
expect(state.judgmentResult).toMatchObject({
|
||||||
|
atPeace: true,
|
||||||
|
essenceDelta: 30,
|
||||||
|
consequence: 'crossed_over',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records a cross_over attempted on a demon as resisted, with zero deltas', () => {
|
||||||
|
const state = frame(initialSeanceState, judgmentFrame({ correct: false, consequence: 'resisted' }))
|
||||||
|
expect(state.judgmentResult).toMatchObject({ consequence: 'resisted', favorDelta: 0, essenceDelta: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records a test verdict (or a judgment without a completed ritual) as neutral with zero deltas', () => {
|
||||||
|
const state = frame(initialSeanceState, judgmentFrame({ correct: false, consequence: 'neutral' }))
|
||||||
|
expect(state.judgmentResult).toMatchObject({ consequence: 'neutral', favorDelta: 0, essenceDelta: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs a system transcript row alongside the stored result', () => {
|
||||||
|
const state = frame(initialSeanceState, judgmentFrame({ consequence: 'crossed_over' }))
|
||||||
|
const row = lastRow(state)
|
||||||
|
expect(row.kind).toBe('system')
|
||||||
|
if (row.kind === 'system') expect(row.text).toMatch(/at peace/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overwrites the previous judgmentResult on a second judgment', () => {
|
||||||
|
let state = frame(initialSeanceState, judgmentFrame({ consequence: 'reward' }))
|
||||||
|
state = frame(state, judgmentFrame({ consequence: 'withdrawal' }))
|
||||||
|
expect(state.judgmentResult?.consequence).toBe('withdrawal')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import { VeilSocket } from '../lib/ws'
|
|||||||
import type { VeilConnectionState } from '../lib/ws'
|
import type { VeilConnectionState } from '../lib/ws'
|
||||||
import { SpiritAudioPlayer } from '../lib/audio'
|
import { SpiritAudioPlayer } from '../lib/audio'
|
||||||
import type {
|
import type {
|
||||||
|
EntityTraits,
|
||||||
|
JudgmentConsequence,
|
||||||
|
JudgmentVerdict,
|
||||||
Language,
|
Language,
|
||||||
Mode,
|
Mode,
|
||||||
ServerFrame,
|
ServerFrame,
|
||||||
@@ -50,6 +53,28 @@ export type TranscriptEntry =
|
|||||||
|
|
||||||
export type Toast = { id: number; code: string; message: string; at: number }
|
export type Toast = { id: number; code: string; message: string; at: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `idle` — no attempt this entity/session yet.
|
||||||
|
* `in_progress` — `ritual_start` sent, awaiting `ritual_complete`.
|
||||||
|
* `success` / `failure` — the server's answer; `revealed` is non-null only
|
||||||
|
* on success (the entity's true hidden traits).
|
||||||
|
*/
|
||||||
|
export type RitualStatus = 'idle' | 'in_progress' | 'success' | 'failure'
|
||||||
|
|
||||||
|
export type RitualUiState = {
|
||||||
|
status: RitualStatus
|
||||||
|
revealed: EntityTraits | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JudgmentResult = {
|
||||||
|
correct: boolean
|
||||||
|
favorDelta: number
|
||||||
|
essenceDelta: number
|
||||||
|
atPeace: boolean
|
||||||
|
consequence: JudgmentConsequence
|
||||||
|
at: number
|
||||||
|
}
|
||||||
|
|
||||||
export type SeanceState = {
|
export type SeanceState = {
|
||||||
connection: VeilConnectionState
|
connection: VeilConnectionState
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
@@ -67,8 +92,12 @@ export type SeanceState = {
|
|||||||
telemetry: Telemetry | null
|
telemetry: Telemetry | null
|
||||||
toasts: Toast[]
|
toasts: Toast[]
|
||||||
speakingId: string | null
|
speakingId: string | null
|
||||||
|
ritual: RitualUiState
|
||||||
|
judgmentResult: JudgmentResult | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IDLE_RITUAL: RitualUiState = { status: 'idle', revealed: null }
|
||||||
|
|
||||||
export const initialSeanceState: SeanceState = {
|
export const initialSeanceState: SeanceState = {
|
||||||
connection: 'closed',
|
connection: 'closed',
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
@@ -86,6 +115,8 @@ export const initialSeanceState: SeanceState = {
|
|||||||
telemetry: null,
|
telemetry: null,
|
||||||
toasts: [],
|
toasts: [],
|
||||||
speakingId: null,
|
speakingId: null,
|
||||||
|
ritual: IDLE_RITUAL,
|
||||||
|
judgmentResult: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Actions ----
|
// ---- Actions ----
|
||||||
@@ -108,6 +139,7 @@ export type SeanceAction =
|
|||||||
| { type: 'set_passive'; enabled: boolean }
|
| { type: 'set_passive'; enabled: boolean }
|
||||||
| { type: 'set_speaking'; utteranceId: string | null }
|
| { type: 'set_speaking'; utteranceId: string | null }
|
||||||
| { type: 'dismiss_toast'; id: number }
|
| { type: 'dismiss_toast'; id: number }
|
||||||
|
| { type: 'local_ritual_start' }
|
||||||
| { type: 'reset' }
|
| { type: 'reset' }
|
||||||
|
|
||||||
function pushCapped<T>(arr: T[], item: T, cap: number): T[] {
|
function pushCapped<T>(arr: T[], item: T, cap: number): T[] {
|
||||||
@@ -122,6 +154,24 @@ function withToast(state: SeanceState, toast: Toast): SeanceState {
|
|||||||
return { ...state, toasts }
|
return { ...state, toasts }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function judgmentTranscriptLine(consequence: JudgmentConsequence): string {
|
||||||
|
switch (consequence) {
|
||||||
|
case 'reward':
|
||||||
|
return '⟁ the bond holds'
|
||||||
|
case 'escalation':
|
||||||
|
return '⟁ wrongly trusted — it turns'
|
||||||
|
case 'withdrawal':
|
||||||
|
return '⟁ banished in error — it withdraws, wounded'
|
||||||
|
case 'crossed_over':
|
||||||
|
return '⟁ the spirit crosses over, at peace'
|
||||||
|
case 'resisted':
|
||||||
|
return '⟁ it will not cross over'
|
||||||
|
case 'neutral':
|
||||||
|
default:
|
||||||
|
return '⟁ no verdict rendered'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceState {
|
export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceState {
|
||||||
const at = ('at' in action && action.at) || Date.now()
|
const at = ('at' in action && action.at) || Date.now()
|
||||||
|
|
||||||
@@ -180,6 +230,9 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
|
|||||||
case 'dismiss_toast':
|
case 'dismiss_toast':
|
||||||
return { ...state, toasts: state.toasts.filter((t) => t.id !== action.id) }
|
return { ...state, toasts: state.toasts.filter((t) => t.id !== action.id) }
|
||||||
|
|
||||||
|
case 'local_ritual_start':
|
||||||
|
return { ...state, ritual: { status: 'in_progress', revealed: null } }
|
||||||
|
|
||||||
case 'reset':
|
case 'reset':
|
||||||
return { ...initialSeanceState }
|
return { ...initialSeanceState }
|
||||||
|
|
||||||
@@ -204,6 +257,10 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
|
|||||||
entity: frame.entity,
|
entity: frame.entity,
|
||||||
entityIsNew: frame.is_new,
|
entityIsNew: frame.is_new,
|
||||||
status: null,
|
status: null,
|
||||||
|
// A fresh presence invalidates any ritual/judgment history from
|
||||||
|
// whatever was summoned before it.
|
||||||
|
ritual: IDLE_RITUAL,
|
||||||
|
judgmentResult: null,
|
||||||
transcript: pushCapped(
|
transcript: pushCapped(
|
||||||
state.transcript,
|
state.transcript,
|
||||||
{
|
{
|
||||||
@@ -364,6 +421,44 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
|
|||||||
at,
|
at,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
case 'ritual_complete': {
|
||||||
|
const text = frame.success
|
||||||
|
? '⟁ the channel holds — the truth surfaces'
|
||||||
|
: '⟁ the channel collapses'
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ritual: {
|
||||||
|
status: frame.success ? 'success' : 'failure',
|
||||||
|
revealed: frame.revealed,
|
||||||
|
},
|
||||||
|
transcript: pushCapped(
|
||||||
|
state.transcript,
|
||||||
|
{ id: nextId(), kind: 'system', text, at },
|
||||||
|
TRANSCRIPT_CAP,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'judgment_result': {
|
||||||
|
const result: JudgmentResult = {
|
||||||
|
correct: frame.correct,
|
||||||
|
favorDelta: frame.favor_delta,
|
||||||
|
essenceDelta: frame.essence_delta,
|
||||||
|
atPeace: frame.at_peace,
|
||||||
|
consequence: frame.consequence,
|
||||||
|
at,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
judgmentResult: result,
|
||||||
|
transcript: pushCapped(
|
||||||
|
state.transcript,
|
||||||
|
{ id: nextId(), kind: 'system', text: judgmentTranscriptLine(frame.consequence), at },
|
||||||
|
TRANSCRIPT_CAP,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
@@ -388,9 +483,15 @@ export type SeanceApi = {
|
|||||||
sendAnomaly: (source: 'radio' | 'evp' | 'wire' | 'emf', frequency: number, magnitude: number) => void
|
sendAnomaly: (source: 'radio' | 'evp' | 'wire' | 'emf', frequency: number, magnitude: number) => void
|
||||||
playUtterance: (utteranceId: string) => void
|
playUtterance: (utteranceId: string) => void
|
||||||
dismissToast: (id: number) => void
|
dismissToast: (id: number) => void
|
||||||
|
startRitual: () => void
|
||||||
|
sendRitualStep: (step: number) => void
|
||||||
|
sendJudgment: (verdict: JudgmentVerdict) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SeanceContext = createContext<SeanceApi | null>(null)
|
// Exported so component tests can render against a hand-built SeanceApi
|
||||||
|
// (bypassing the real WebSocket/audio setup SeanceProvider owns) rather
|
||||||
|
// than only via the public useSeance() hook.
|
||||||
|
export const SeanceContext = createContext<SeanceApi | null>(null)
|
||||||
|
|
||||||
export function SeanceProvider({ children }: { children: ReactNode }) {
|
export function SeanceProvider({ children }: { children: ReactNode }) {
|
||||||
const [state, dispatch] = useReducer(seanceReducer, initialSeanceState)
|
const [state, dispatch] = useReducer(seanceReducer, initialSeanceState)
|
||||||
@@ -475,6 +576,21 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
|
|||||||
dispatch({ type: 'dismiss_toast', id })
|
dispatch({ type: 'dismiss_toast', id })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const startRitual = useCallback(() => {
|
||||||
|
// Local-first so the UI can show "in progress" immediately, same
|
||||||
|
// pattern as ask()/sendAnomaly() below.
|
||||||
|
dispatch({ type: 'local_ritual_start' })
|
||||||
|
socketRef.current?.send({ type: 'ritual_start' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const sendRitualStep = useCallback((step: number) => {
|
||||||
|
socketRef.current?.send({ type: 'ritual_step', step })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const sendJudgment = useCallback((verdict: JudgmentVerdict) => {
|
||||||
|
socketRef.current?.send({ type: 'judgment', verdict })
|
||||||
|
}, [])
|
||||||
|
|
||||||
const api = useMemo<SeanceApi>(
|
const api = useMemo<SeanceApi>(
|
||||||
() => ({
|
() => ({
|
||||||
state,
|
state,
|
||||||
@@ -488,8 +604,24 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
|
|||||||
sendAnomaly,
|
sendAnomaly,
|
||||||
playUtterance,
|
playUtterance,
|
||||||
dismissToast,
|
dismissToast,
|
||||||
|
startRitual,
|
||||||
|
sendRitualStep,
|
||||||
|
sendJudgment,
|
||||||
}),
|
}),
|
||||||
[state, setMode, setPassive, setLanguage, summon, ask, sendAnomaly, playUtterance, dismissToast],
|
[
|
||||||
|
state,
|
||||||
|
setMode,
|
||||||
|
setPassive,
|
||||||
|
setLanguage,
|
||||||
|
summon,
|
||||||
|
ask,
|
||||||
|
sendAnomaly,
|
||||||
|
playUtterance,
|
||||||
|
dismissToast,
|
||||||
|
startRitual,
|
||||||
|
sendRitualStep,
|
||||||
|
sendJudgment,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
return <SeanceContext.Provider value={api}>{children}</SeanceContext.Provider>
|
return <SeanceContext.Provider value={api}>{children}</SeanceContext.Provider>
|
||||||
|
|||||||
Reference in New Issue
Block a user