diff --git a/frontend/src/components/HauntingLayer.tsx b/frontend/src/components/HauntingLayer.tsx index f396899..a471440 100644 --- a/frontend/src/components/HauntingLayer.tsx +++ b/frontend/src/components/HauntingLayer.tsx @@ -12,10 +12,10 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { HauntBed, - IdleEscalator, flickerTitle, hauntBus, pickWhisperWord, + sharedIdleEscalator, } from '../lib/haunting' import './HauntingLayer.css' @@ -84,7 +84,10 @@ export function HauntingLayer() { useEffect(() => { const reduced = prefersReducedMotion() - const escalator = new IdleEscalator() + // Shared instance (not a fresh one per mount) so a forced escalation + // triggered elsewhere (e.g. a wrongly-trusted judgment) reaches this + // running layer — see `forceEscalate()` in lib/haunting.ts. + const escalator = sharedIdleEscalator const bed = new HauntBed() const timers = new Set>() let disposed = false diff --git a/frontend/src/components/JudgmentPanel.css b/frontend/src/components/JudgmentPanel.css new file mode 100644 index 0000000..6424a6c --- /dev/null +++ b/frontend/src/components/JudgmentPanel.css @@ -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); } +} diff --git a/frontend/src/components/JudgmentPanel.test.tsx b/frontend/src/components/JudgmentPanel.test.tsx new file mode 100644 index 0000000..c484e8c --- /dev/null +++ b/frontend/src/components/JudgmentPanel.test.tsx @@ -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 = {}) { + const sendJudgment = vi.fn() + const state: SeanceState = { ...initialSeanceState, entity, ...stateOverrides } + const api = { state, sendJudgment } as unknown as SeanceApi + const utils = render( + + + , + ) + return { ...utils, sendJudgment } +} + +function result(overrides: Partial = {}): 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 = {}) { + const sendJudgment = vi.fn() + const state: SeanceState = { ...initialSeanceState, entity, judgmentResult: null } + const api = { state, sendJudgment } as unknown as SeanceApi + const { rerender } = render( + + + , + ) + const nextApi = { ...api, state: { ...state, judgmentResult: result({ consequence, ...overrides }) } } + rerender( + + + , + ) + } + + 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( + + + , + ) + 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( + + + , + ) + + expect(screen.queryByText(/awaiting the verdict/i)).toBeNull() + for (const btn of screen.getAllByRole('button')) { + expect(btn).not.toBeDisabled() + } + }) +}) diff --git a/frontend/src/components/JudgmentPanel.tsx b/frontend/src/components/JudgmentPanel.tsx new file mode 100644 index 0000000..4b8e2c7 --- /dev/null +++ b/frontend/src/components/JudgmentPanel.tsx @@ -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 +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 ( + + ) +} + +function BanishRune() { + return ( + + ) +} + +function CrossOverRune() { + return ( + + ) +} + +function TestRune() { + return ( + + ) +} + +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(null) + const seenResultRef = useRef(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 ( +
+

{t('seance.judgment.none', { defaultValue: 'no presence to judge' })}

+

+ {t('seance.judgment.noneHint', { defaultValue: 'summon a spirit first' })} +

+
+ ) + } + + const cast = (verdict: JudgmentVerdict) => { + if (pending) return + setPending(verdict) + sendJudgment(verdict) + } + + const result = state.judgmentResult + const crossedOver = result?.consequence === 'crossed_over' + + return ( +
+

{t('seance.judgment.title', { defaultValue: 'render judgment' })}

+ +
+ {VERDICTS.map(({ verdict, labelKey, fallback, Icon, cls }) => ( + + ))} +
+ + {pending && ( +

+ {t('seance.judgment.pending', { defaultValue: 'awaiting the verdict…' })} +

+ )} + + {result && crossedOver && } + {result && !crossedOver && } +
+ ) +} + +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 ( +

+ {t('seance.judgment.favor', { defaultValue: 'favor' })} {favorTxt} ·{' '} + {t('seance.judgment.essence', { defaultValue: 'essence' })} {essenceTxt} +

+ ) +} + +function ConsequenceCard({ result, t }: { result: JudgmentResult; t: TFn }) { + const copy = consequenceCopy(result.consequence, t) + return ( +
+

{copy.title}

+

{copy.body}

+ +
+ ) +} + +// 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 ( +
+
+ +
+

{copy.title}

+

{copy.body}

+ +
+ ) +} diff --git a/frontend/src/components/RitualPanel.css b/frontend/src/components/RitualPanel.css new file mode 100644 index 0000000..bcf6421 --- /dev/null +++ b/frontend/src/components/RitualPanel.css @@ -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; +} diff --git a/frontend/src/components/RitualPanel.test.tsx b/frontend/src/components/RitualPanel.test.tsx new file mode 100644 index 0000000..5a09b10 --- /dev/null +++ b/frontend/src/components/RitualPanel.test.tsx @@ -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 = {}, apiOverrides: Partial = {}) { + 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( + + + , + ) + 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) + }) +}) diff --git a/frontend/src/components/RitualPanel.tsx b/frontend/src/components/RitualPanel.tsx new file mode 100644 index 0000000..63dd22a --- /dev/null +++ b/frontend/src/components/RitualPanel.tsx @@ -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 + +const HOLD_TICK_MS = 40 + +type LocalPhase = 'idle' | 'stepping' | 'awaiting' + +function runeCopy(rune: RitualRune, t: TFn): { command: string; locked: string } { + switch (rune) { + case 'align': + return { + command: t('seance.ritual.steps.align.command', { defaultValue: '> aligning the wire…' }), + locked: t('seance.ritual.steps.align.locked', { defaultValue: '> wire aligned ✓' }), + } + case 'breath': + return { + command: t('seance.ritual.steps.breath.command', { defaultValue: '> steadying the breath…' }), + locked: t('seance.ritual.steps.breath.locked', { defaultValue: '> breath steadied ✓' }), + } + case 'trace': + return { + command: t('seance.ritual.steps.trace.command', { defaultValue: '> tracing the sigil…' }), + locked: t('seance.ritual.steps.trace.locked', { defaultValue: '> sigil traced ✓' }), + } + case 'lock': + default: + return { + command: t('seance.ritual.steps.lock.command', { defaultValue: '> locking the channel…' }), + locked: t('seance.ritual.steps.lock.locked', { defaultValue: '> channel locked ✓' }), + } + } +} + +const TRAIT_KEYS: readonly (keyof EntityTraits)[] = ['alignment', 'power', 'volatility', 'deceptiveness'] + +function traitLabel(key: keyof EntityTraits, t: TFn): string { + switch (key) { + case 'alignment': + return t('seance.ritual.traits.alignment', { defaultValue: 'alignment' }) + case 'power': + return t('seance.ritual.traits.power', { defaultValue: 'power' }) + case 'volatility': + return t('seance.ritual.traits.volatility', { defaultValue: 'volatility' }) + case 'deceptiveness': + default: + return t('seance.ritual.traits.deceptiveness', { defaultValue: 'deceptiveness' }) + } +} + +export function RitualPanel() { + const { state, startRitual, sendRitualStep } = useSeance() + const { t } = useTranslation() + + const [phase, setPhase] = useState('idle') + const [step, setStep] = useState(1) + const [progress, setProgress] = useState(0) + const [log, setLog] = useState([]) + const heldRef = useRef(0) + const intervalRef = useRef | null>(null) + + const hasEntity = !!state.entity + const entityId = state.entity?.id ?? null + const prevEntityIdRef = useRef(entityId) + + const clearHold = () => { + if (intervalRef.current !== null) { + window.clearInterval(intervalRef.current) + intervalRef.current = null + } + heldRef.current = 0 + setProgress(0) + } + + // A newly-summoned entity invalidates any local mid-ritual UI (the + // reducer already resets the shared ritual/judgment state on 'entity'). + useEffect(() => { + if (prevEntityIdRef.current !== entityId) { + prevEntityIdRef.current = entityId + clearHold() + setPhase('idle') + setStep(1) + setLog([]) + } + }, [entityId]) + + useEffect(() => () => clearHold(), []) + + const completeStep = (finishedStep: number) => { + clearHold() + const prompt = RITUAL_PROMPTS[finishedStep - 1] + setLog((l) => [...l, runeCopy(prompt.rune, t).locked]) + sendRitualStep(finishedStep) + const n = nextStep(finishedStep, RITUAL_PROMPTS.length) + if (n === null) { + setPhase('awaiting') + } else { + setStep(n) + } + } + + const beginHold = () => { + if (phase !== 'stepping' || intervalRef.current !== null) return + heldRef.current = 0 + intervalRef.current = window.setInterval(() => { + heldRef.current += HOLD_TICK_MS + setProgress(holdProgress(heldRef.current)) + if (stepComplete(heldRef.current)) completeStep(step) + }, HOLD_TICK_MS) + } + + const releaseHold = () => { + if (intervalRef.current !== null) clearHold() + } + + const begin = () => { + if (!hasEntity) return + clearHold() + setLog([]) + setStep(1) + setPhase('stepping') + startRitual() + } + + if (!hasEntity) { + return ( +
+
+

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

+

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

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

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

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

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

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

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

+

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

+ +
+ )} +
+
+ ) +} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index eef61f5..205674e 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -180,6 +180,78 @@ "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": { "rate_limited": "the veil is thin — slow your knocking", "veil_crowded": "too many spirits crowd the line" diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 2fbc209..ba51256 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -180,6 +180,78 @@ "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": { "rate_limited": "el velo es delgado — golpea más despacio", "veil_crowded": "demasiados espíritus se apiñan en la línea" diff --git a/frontend/src/lib/haunting.test.ts b/frontend/src/lib/haunting.test.ts index 4390a48..6687c1f 100644 --- a/frontend/src/lib/haunting.test.ts +++ b/frontend/src/lib/haunting.test.ts @@ -5,8 +5,10 @@ import { HauntBus, IdleEscalator, flickerTitle, + forceEscalate, hauntBus, pickWhisperWord, + sharedIdleEscalator, type HauntScheduler, } from './haunting' @@ -56,6 +58,63 @@ describe('IdleEscalator', () => { expect(e.level(95_000 + 31_000)).toBe(1) 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', () => { diff --git a/frontend/src/lib/haunting.ts b/frontend/src/lib/haunting.ts index 557b783..51eab19 100644 --- a/frontend/src/lib/haunting.ts +++ b/frontend/src/lib/haunting.ts @@ -59,10 +59,12 @@ export function pickWhisperWord(words: readonly string[], rand: () => number): s /** * 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 { private lastActivity: number + private forcedUntil = 0 constructor(nowMs: number = Date.now()) { this.lastActivity = nowMs @@ -72,7 +74,19 @@ export class IdleEscalator { 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 { + if (nowMs < this.forcedUntil) return 2 const idle = nowMs - this.lastActivity if (idle < 30_000) return 0 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 ---- /** Injectable scheduler: run `fn` after `ms`, return a cancel function. */ diff --git a/frontend/src/lib/ritual.test.ts b/frontend/src/lib/ritual.test.ts new file mode 100644 index 0000000..7508807 --- /dev/null +++ b/frontend/src/lib/ritual.test.ts @@ -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() + }) +}) diff --git a/frontend/src/lib/ritual.ts b/frontend/src/lib/ritual.ts new file mode 100644 index 0000000..68ea865 --- /dev/null +++ b/frontend/src/lib/ritual.ts @@ -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 +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 9cfae79..c50f356 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -61,6 +61,26 @@ export type Stats = { 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 ---- export type ClientFrame = @@ -71,6 +91,9 @@ export type ClientFrame = | { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number } | { type: 'question'; text: string } | { type: 'passive'; enabled: boolean } + | { type: 'ritual_start' } + | { type: 'ritual_step'; step: number } + | { type: 'judgment'; verdict: JudgmentVerdict } // ---- WebSocket frames: server -> client ---- @@ -83,16 +106,6 @@ export type Telemetry = { export type UtteranceKind = 'greeting' | 'fragment' | 'ambient' | 'reply' -// Entity hidden traits, per the Character Depth spec's Contract section — -// four floats each 0.0–1.0, rolled once at mint time. `alignment`: 0 = -// malevolent/demon, 1 = benevolent spirit. -export type EntityTraits = { - alignment: number - power: number - volatility: number - deceptiveness: number -} - export type ServerFrame = | { type: 'session'; id: string } | { type: 'pong' } @@ -108,10 +121,18 @@ export type ServerFrame = | { type: 'reply_end'; id: string; text: string } | { type: 'telemetry' } & Telemetry | { type: 'error'; code: 'rate_limited' | 'veil_crowded' | string; message: string } - // Character Depth / Ghost Log contract frames (backend built by a separate - // workstream — see docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md). + // Character Depth / Ghost Log contract frames — see + // docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md. | { type: 'tell'; text: 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 export type SortOrder = 'recent' | 'contacted' diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index 500be59..ac3d4ab 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -23,6 +23,8 @@ import { MatrixView } from '../components/MatrixView' import { GraphView } from '../components/GraphView' import { Transcript } from '../components/Transcript' import { EntityCard } from '../components/EntityCard' +import { RitualPanel } from '../components/RitualPanel' +import { JudgmentPanel } from '../components/JudgmentPanel' import { TelemetryReadout } from '../components/TelemetryReadout' import { PlanchetteMachine } from '../lib/planchette' import type { PlanchetteSnapshot } from '../lib/planchette' @@ -344,6 +346,9 @@ function SeanceSession({ username }: { username: string }) { onToggleOpen={() => setDossierOpen((o) => !o)} /> + + +
{state.mode === 'wire' && } {state.mode === 'evp' && } diff --git a/frontend/src/state/seance.test.ts b/frontend/src/state/seance.test.ts index 4f5c584..dacc942 100644 --- a/frontend/src/state/seance.test.ts +++ b/frontend/src/state/seance.test.ts @@ -265,3 +265,146 @@ describe('seanceReducer', () => { 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') + }) +}) diff --git a/frontend/src/state/seance.tsx b/frontend/src/state/seance.tsx index b01fe0b..1a27938 100644 --- a/frontend/src/state/seance.tsx +++ b/frontend/src/state/seance.tsx @@ -17,6 +17,9 @@ import type { VeilConnectionState } from '../lib/ws' import { SpiritAudioPlayer } from '../lib/audio' import { ghostLogBus } from '../lib/ghostLogBus' import type { + EntityTraits, + JudgmentConsequence, + JudgmentVerdict, Language, Mode, ServerFrame, @@ -51,6 +54,28 @@ export type TranscriptEntry = 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 = { connection: VeilConnectionState sessionId: string | null @@ -68,8 +93,12 @@ export type SeanceState = { telemetry: Telemetry | null toasts: Toast[] speakingId: string | null + ritual: RitualUiState + judgmentResult: JudgmentResult | null } +const IDLE_RITUAL: RitualUiState = { status: 'idle', revealed: null } + export const initialSeanceState: SeanceState = { connection: 'closed', sessionId: null, @@ -87,6 +116,8 @@ export const initialSeanceState: SeanceState = { telemetry: null, toasts: [], speakingId: null, + ritual: IDLE_RITUAL, + judgmentResult: null, } // ---- Actions ---- @@ -109,6 +140,7 @@ export type SeanceAction = | { type: 'set_passive'; enabled: boolean } | { type: 'set_speaking'; utteranceId: string | null } | { type: 'dismiss_toast'; id: number } + | { type: 'local_ritual_start' } | { type: 'reset' } function pushCapped(arr: T[], item: T, cap: number): T[] { @@ -123,6 +155,24 @@ function withToast(state: SeanceState, toast: Toast): SeanceState { 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 { const at = ('at' in action && action.at) || Date.now() @@ -181,6 +231,9 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS case 'dismiss_toast': 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': return { ...initialSeanceState } @@ -205,6 +258,10 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS entity: frame.entity, entityIsNew: frame.is_new, status: null, + // A fresh presence invalidates any ritual/judgment history from + // whatever was summoned before it. + ritual: IDLE_RITUAL, + judgmentResult: null, transcript: pushCapped( state.transcript, { @@ -365,6 +422,44 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS 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: return state } @@ -389,9 +484,15 @@ export type SeanceApi = { sendAnomaly: (source: 'radio' | 'evp' | 'wire' | 'emf', frequency: number, magnitude: number) => void playUtterance: (utteranceId: string) => void dismissToast: (id: number) => void + startRitual: () => void + sendRitualStep: (step: number) => void + sendJudgment: (verdict: JudgmentVerdict) => void } -const SeanceContext = createContext(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(null) export function SeanceProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(seanceReducer, initialSeanceState) @@ -492,6 +593,21 @@ export function SeanceProvider({ children }: { children: ReactNode }) { 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( () => ({ state, @@ -505,8 +621,24 @@ export function SeanceProvider({ children }: { children: ReactNode }) { sendAnomaly, playUtterance, 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 {children}