From e4a834620f885993f115b7e4b2f9228716540cb8 Mon Sep 17 00:00:00 2001 From: Indiana Date: Thu, 23 Jul 2026 16:28:33 +0000 Subject: [PATCH] feat: Ghost Log HUD + evil meter + tells (Workstream D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-shell HUD (mounted in App.tsx alongside HauntingLayer, visible on every screen) that shows ambient idle status until a séance is active, then streams `tell` WS frames as terminal-style log lines with a "hacker witch" crescent-arc evil-meter gauge (occult sigils/runes fused with Transcript.tsx's monospace log vocabulary). - lib/evilMeter.ts: pure function computing a malevolent<->benevolent belief from the accumulated tell history — starts wide/uncertain, narrows geometrically and shifts per tell (deterministic hash of the tell text, since tells never leak ground truth), and snaps to definitive certainty on a successful ritual_complete's revealed.alignment. Fully unit tested (narrowing, ordering, determinism, ritual override, idle/empty history). - lib/ghostLogBus.ts: tiny typed event bus (mirrors lib/haunting.ts's HauntBus) so the app-shell-level GhostLog can react to live séance frames — SeanceProvider is only mounted inside the séance route, so a shell-level sibling can't read its context directly. - state/seance.tsx: publish entity/tell/ritual_complete onto the bus, and session_end on provider teardown so the HUD falls back to idle when the seeker leaves the séance page. - lib/types.ts: add the `tell` and `ritual_complete` server frames from the Character Depth spec's Contract section (only what this workstream consumes). - components/GhostLog.tsx/.css: the HUD itself, plus i18n keys in en.json/es.json. 168/168 frontend tests pass (137 pre-existing + 20 evilMeter + 11 GhostLog); tsc -b and the i18n coverage pretest are clean. Co-Authored-By: Claude Sonnet 5 --- frontend/src/App.tsx | 2 + frontend/src/components/GhostLog.css | 258 ++++++++++++++++++++++ frontend/src/components/GhostLog.test.tsx | 139 ++++++++++++ frontend/src/components/GhostLog.tsx | 254 +++++++++++++++++++++ frontend/src/i18n/en.json | 6 + frontend/src/i18n/es.json | 6 + frontend/src/lib/evilMeter.test.ts | 169 ++++++++++++++ frontend/src/lib/evilMeter.ts | 132 +++++++++++ frontend/src/lib/ghostLogBus.ts | 53 +++++ frontend/src/lib/types.ts | 14 ++ frontend/src/state/seance.tsx | 17 ++ 11 files changed, 1050 insertions(+) create mode 100644 frontend/src/components/GhostLog.css create mode 100644 frontend/src/components/GhostLog.test.tsx create mode 100644 frontend/src/components/GhostLog.tsx create mode 100644 frontend/src/lib/evilMeter.test.ts create mode 100644 frontend/src/lib/evilMeter.ts create mode 100644 frontend/src/lib/ghostLogBus.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a33a6a8..5ff5c54 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' import { AuthProvider } from './state/auth' import { HauntingLayer } from './components/HauntingLayer' +import { GhostLog } from './components/GhostLog' import { CodexEntityPage } from './pages/CodexEntityPage' import { CodexPage } from './pages/CodexPage' import { EnterPage } from './pages/EnterPage' @@ -20,6 +21,7 @@ function App() {
+ } /> } /> diff --git a/frontend/src/components/GhostLog.css b/frontend/src/components/GhostLog.css new file mode 100644 index 0000000..b2f165a --- /dev/null +++ b/frontend/src/components/GhostLog.css @@ -0,0 +1,258 @@ +/* GhostLog — app-shell HUD, mounted once alongside HauntingLayer so it sits + above every page. "Hacker witch": Transcript.tsx's terminal vocabulary + (monospace log rows, phosphor glow, flicker) fused with occult framing + (sigil corners, rune bullets, pentagram divider, crescent evil-meter). + Palette borrowed from App.css's --qm-* tokens / SeancePage.css — no new + colors introduced. */ + +.ghost-log { + position: fixed; + left: 14px; + bottom: 14px; + z-index: 500; /* above page content, below the global CRT glass (9999) and haunting layer (9998) */ + width: 300px; + max-width: calc(100vw - 28px); + max-height: 46vh; + display: flex; + flex-direction: column; + background: var(--qm-panel, rgba(16, 16, 26, 0.85)); + border: 1px solid var(--qm-border, rgba(124, 255, 178, 0.18)); + border-radius: 6px; + box-shadow: + 0 6px 28px rgba(0, 0, 0, 0.55), + 0 0 18px rgba(178, 107, 255, 0.08), + inset 0 0 24px rgba(124, 255, 178, 0.03); + backdrop-filter: blur(4px); + font-family: var(--qm-font-ui, 'IBM Plex Mono', monospace); + overflow: hidden; + pointer-events: auto; +} + +/* candle-flicker glow instead of a flat terminal green */ +.ghost-log--active { + animation: ghostLogCandle 5.5s ease-in-out infinite; +} + +.ghost-log-corner { + position: absolute; + top: 4px; + font-size: 0.7rem; + color: rgba(178, 107, 255, 0.55); + text-shadow: 0 0 8px rgba(178, 107, 255, 0.5); + pointer-events: none; +} + +.ghost-log-corner--tl { + left: 6px; +} + +.ghost-log-corner--tr { + right: 6px; +} + +.ghost-log-header { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.55rem 1rem 0.35rem; +} + +.ghost-log-glyph { + color: var(--qm-violet, #b26bff); + text-shadow: 0 0 10px rgba(178, 107, 255, 0.7); + font-size: 0.95rem; +} + +.ghost-log-title { + font-family: var(--qm-font-display, 'Cinzel', serif); + font-weight: 700; + font-size: 0.72rem; + letter-spacing: 0.28em; + color: var(--qm-green, #7cffb2); + text-shadow: 0 0 10px rgba(124, 255, 178, 0.4); +} + +.ghost-log-divider { + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.1rem 0 0.3rem; + color: rgba(178, 107, 255, 0.4); + font-size: 0.6rem; + letter-spacing: 0.3em; +} + +.ghost-log-divider-sigil { + color: var(--qm-blood, #ff3b5c); + text-shadow: 0 0 8px rgba(255, 59, 92, 0.5); + font-size: 0.75rem; +} + +.ghost-log-status { + margin: 0; + padding: 0.3rem 1rem 0.8rem; + font-size: 0.74rem; + font-style: italic; + letter-spacing: 0.04em; + color: rgba(211, 228, 220, 0.55); +} + +.ghost-log-status.dim { + opacity: 0.6; +} + +.ghost-log-body { + flex: 1 1 auto; + overflow-y: auto; + padding: 0.15rem 1rem 0.8rem; + display: flex; + flex-direction: column; + gap: 0.32rem; + font-size: 0.74rem; + line-height: 1.4; + scrollbar-width: thin; + scrollbar-color: rgba(124, 255, 178, 0.3) transparent; +} + +.ghost-log-body::-webkit-scrollbar { + width: 5px; +} + +.ghost-log-body::-webkit-scrollbar-thumb { + background: rgba(124, 255, 178, 0.25); + border-radius: 3px; +} + +.ghost-log-line { + display: flex; + align-items: baseline; + gap: 0.4rem; +} + +.ghost-log-rune { + flex: 0 0 auto; + color: var(--qm-violet, #b26bff); + text-shadow: 0 0 6px rgba(178, 107, 255, 0.6); + font-size: 0.72rem; +} + +.ghost-log-line--system .ghost-log-rune { + color: var(--qm-green, #7cffb2); + text-shadow: 0 0 6px rgba(124, 255, 178, 0.6); +} + +.ghost-log-text { + color: rgba(211, 228, 220, 0.9); +} + +.ghost-log-entity-name { + font-family: var(--qm-font-display, 'Cinzel', serif); + letter-spacing: 0.08em; + color: var(--qm-green, #7cffb2); +} + +/* ---------- evil meter gauge ---------- */ + +.evil-meter { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 0.6rem; +} + +.evil-meter-svg { + width: 100%; + height: auto; + overflow: visible; +} + +.evil-meter-track { + fill: none; + stroke: rgba(124, 255, 178, 0.14); + stroke-width: 12; + stroke-linecap: round; +} + +.evil-meter-band { + fill: rgba(178, 107, 255, 0.4); + filter: drop-shadow(0 0 6px rgba(178, 107, 255, 0.55)); + transition: d 200ms ease; +} + +.evil-meter-band--certain { + fill: rgba(124, 255, 178, 0.5); + filter: drop-shadow(0 0 10px rgba(124, 255, 178, 0.7)); +} + +/* CRT-scanline texture cut through the belief band — the gauge belongs to + both worlds at once: occult crescent shape, hacker scanline fill. */ +.evil-meter-scanline { + stroke: rgba(7, 7, 13, 0.55); + stroke-width: 1.4; +} + +.evil-meter-band-texture { + opacity: 0.6; + mix-blend-mode: multiply; + pointer-events: none; +} + +.evil-meter-needle { + stroke: #d9f5e4; + stroke-width: 2; + stroke-linecap: round; + filter: drop-shadow(0 0 4px rgba(217, 245, 228, 0.8)); +} + +.evil-meter-hub { + fill: #d9f5e4; +} + +.evil-meter--certain .evil-meter-needle { + stroke: var(--qm-green, #7cffb2); +} + +.evil-meter-readout { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-top: -0.35rem; + font-size: 0.66rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.evil-meter-glyph { + font-size: 0.7rem; + opacity: 0.65; +} + +.evil-meter-glyph--malevolent { + color: var(--qm-blood, #ff3b5c); + text-shadow: 0 0 6px rgba(255, 59, 92, 0.5); +} + +.evil-meter-glyph--benevolent { + color: var(--qm-green, #7cffb2); + text-shadow: 0 0 6px rgba(124, 255, 178, 0.5); +} + +.evil-meter-label { + color: rgba(211, 228, 220, 0.85); + font-style: italic; +} + +@keyframes ghostLogCandle { + 0%, 100% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 18px rgba(178, 107, 255, 0.08), inset 0 0 24px rgba(124, 255, 178, 0.03); } + 35% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 24px rgba(178, 107, 255, 0.16), inset 0 0 28px rgba(124, 255, 178, 0.05); } + 60% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 14px rgba(178, 107, 255, 0.06), inset 0 0 20px rgba(124, 255, 178, 0.02); } + 80% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 22px rgba(178, 107, 255, 0.14), inset 0 0 26px rgba(124, 255, 178, 0.04); } +} + +@media (prefers-reduced-motion: reduce) { + .ghost-log--active { + animation: none; + } +} diff --git a/frontend/src/components/GhostLog.test.tsx b/frontend/src/components/GhostLog.test.tsx new file mode 100644 index 0000000..d441d30 --- /dev/null +++ b/frontend/src/components/GhostLog.test.tsx @@ -0,0 +1,139 @@ +import { act, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import '../i18n' +import { GhostLog } from './GhostLog' +import { ghostLogBus } from '../lib/ghostLogBus' +import type { GhostLogEventMap } from '../lib/ghostLogBus' + +// GhostLog subscribes to the shared, module-level ghostLogBus (it's mounted +// at the app-shell level with no props — see the component's header +// comment for why). Tests drive it the same way does in +// production: by emitting frames onto the bus and letting React flush. +function emit(type: K, detail: GhostLogEventMap[K]) { + act(() => { + ghostLogBus.emit(type, detail) + }) +} + +describe('GhostLog — idle state', () => { + afterEach(() => { + emit('session_end', {}) + }) + + it('shows ambient status text with no active entity/session', () => { + render() + expect( + screen.getByText('the veil is quiet — no presence detected'), + ).toBeTruthy() + }) + + it('renders no evil-meter gauge while idle', () => { + render() + expect(screen.queryByTestId('evil-meter')).toBeNull() + }) + + it('carries the idle class on the root element', () => { + const { container } = render() + expect(container.querySelector('.ghost-log--idle')).not.toBeNull() + expect(container.querySelector('.ghost-log--active')).toBeNull() + }) +}) + +describe('GhostLog — active session with tells', () => { + afterEach(() => { + emit('session_end', {}) + }) + + it('switches out of idle once an entity frame arrives', () => { + render() + emit('entity', { name: 'The Hollow Choir' }) + + expect(screen.queryByText('the veil is quiet — no presence detected')).toBeNull() + expect(screen.getByText('The Hollow Choir')).toBeTruthy() + }) + + it('shows a waiting line before any tells arrive', () => { + render() + emit('entity', { name: 'The Hollow Choir' }) + expect(screen.getByText('listening for tells…')).toBeTruthy() + }) + + it('streams tell frames in as log lines, most recent last (Transcript convention)', () => { + render() + emit('entity', { name: 'The Hollow Choir' }) + emit('tell', { text: 'the entity avoided a direct question' }) + emit('tell', { text: 'a cold draft crossed the room' }) + + expect(screen.getByText('the entity avoided a direct question')).toBeTruthy() + expect(screen.getByText('a cold draft crossed the room')).toBeTruthy() + + const lines = Array.from(document.querySelectorAll('.ghost-log-line .ghost-log-text')).map( + (el) => el.textContent, + ) + // entity name row + two tell lines, tells in arrival order + expect(lines).toEqual([ + 'The Hollow Choir', + 'the entity avoided a direct question', + 'a cold draft crossed the room', + ]) + }) + + it('renders an evil-meter gauge once active, reflecting the tell history', () => { + render() + emit('entity', { name: 'The Hollow Choir' }) + emit('tell', { text: 'the entity avoided a direct question' }) + + const gauge = screen.getByTestId('evil-meter') + expect(gauge).toBeTruthy() + const meterEl = gauge.querySelector('[role="meter"]') + expect(meterEl).not.toBeNull() + expect(meterEl?.getAttribute('aria-valuemin')).toBe('0') + expect(meterEl?.getAttribute('aria-valuemax')).toBe('1') + // starts near the uncertain midpoint before any tells narrow it + expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeGreaterThanOrEqual(0) + expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(1) + }) + + it('snaps the gauge to certainty on a successful ritual_complete', () => { + render() + emit('entity', { name: 'The Hollow Choir' }) + emit('tell', { text: 'the entity avoided a direct question' }) + emit('ritual_complete', { success: true, revealed: { alignment: 0.92 } }) + + const gauge = screen.getByTestId('evil-meter') + expect(gauge.className).toContain('evil-meter--certain') + const meterEl = gauge.querySelector('[role="meter"]') + expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeCloseTo(0.92, 3) + }) + + it('does not snap to certainty on a failed ritual_complete', () => { + render() + emit('entity', { name: 'The Hollow Choir' }) + emit('ritual_complete', { success: false, revealed: null }) + + const gauge = screen.getByTestId('evil-meter') + expect(gauge.className).not.toContain('evil-meter--certain') + }) + + it('resets back to idle on session_end (leaving the séance)', () => { + render() + emit('entity', { name: 'The Hollow Choir' }) + emit('tell', { text: 'a cold draft crossed the room' }) + emit('session_end', {}) + + expect( + screen.getByText('the veil is quiet — no presence detected'), + ).toBeTruthy() + expect(screen.queryByText('a cold draft crossed the room')).toBeNull() + }) + + it('starts a fresh log when a new entity frame supersedes the old one', () => { + render() + emit('entity', { name: 'The Hollow Choir' }) + emit('tell', { text: 'a cold draft crossed the room' }) + emit('entity', { name: 'The Weeping Usher' }) + + expect(screen.getByText('The Weeping Usher')).toBeTruthy() + expect(screen.queryByText('a cold draft crossed the room')).toBeNull() + }) +}) diff --git a/frontend/src/components/GhostLog.tsx b/frontend/src/components/GhostLog.tsx new file mode 100644 index 0000000..9cc5788 --- /dev/null +++ b/frontend/src/components/GhostLog.tsx @@ -0,0 +1,254 @@ +// GhostLog — the app-shell HUD (mounted once in App.tsx, alongside +// HauntingLayer, so it's visible on every screen, not just the séance +// page). It has no props: it subscribes directly to `ghostLogBus` +// (lib/ghostLogBus.ts), which publishes onto whenever a +// live séance is running. That indirection exists because the provider is +// only mounted inside the séance route — a shell-level sibling can't read +// its context directly — so this component stays fully functional (idle +// state) on every page with no active WS connection, and comes alive with +// streamed `tell` lines once a séance actually starts. +// +// Visual language: "hacker witch" — Transcript.tsx's terminal/log +// vocabulary (monospace rows, phosphor glow, flicker) fused with occult +// framing (sigil corner ornaments, rune bullets instead of dashes, a +// pentagram divider, a crescent-arc evil-meter gauge with a CRT scanline +// texture standing in for a plain progress bar). + +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ghostLogBus } from '../lib/ghostLogBus' +import { computeEvilMeter, evilMeterLabel } from '../lib/evilMeter' +import type { EvilMeterState, RitualReveal } from '../lib/evilMeter' +import './GhostLog.css' + +type TellLine = { id: number; text: string; at: number } + +/** Bounded so a very long séance never grows the HUD's memory unbounded. */ +const TELL_CAP = 60 + +let idSeq = 1 +const nextId = () => idSeq++ + +function clamp01(n: number): number { + return Math.min(1, Math.max(0, n)) +} + +export function GhostLog() { + const { t } = useTranslation() + const [entityName, setEntityName] = useState(null) + const [tells, setTells] = useState([]) + const [ritualReveal, setRitualReveal] = useState(null) + const bodyRef = useRef(null) + + useEffect(() => { + // A fresh `entity` event means a new (or newly re-summoned) presence — + // start this HUD's log over rather than carrying the previous entity's + // tells/ritual reveal forward. + const offEntity = ghostLogBus.on('entity', ({ name }) => { + setEntityName(name) + setTells([]) + setRitualReveal(null) + }) + const offTell = ghostLogBus.on('tell', ({ text }) => { + setTells((prev) => { + const next = [...prev, { id: nextId(), text, at: Date.now() }] + return next.length > TELL_CAP ? next.slice(next.length - TELL_CAP) : next + }) + }) + const offRitual = ghostLogBus.on('ritual_complete', (detail) => { + setRitualReveal(detail) + }) + const offEnd = ghostLogBus.on('session_end', () => { + setEntityName(null) + setTells([]) + setRitualReveal(null) + }) + return () => { + offEntity() + offTell() + offRitual() + offEnd() + } + }, []) + + // Same scroll convention as Transcript.tsx: newest line appended at the + // bottom, view auto-scrolls down to follow it. + useEffect(() => { + const box = bodyRef.current + if (box) box.scrollTop = box.scrollHeight + }, [tells.length]) + + const meter = useMemo( + () => computeEvilMeter(tells, ritualReveal), + [tells, ritualReveal], + ) + const active = entityName !== null + + return ( + + ) +} + +export default GhostLog + +// ---- evil-meter gauge: a crescent arc, not a boring progress bar ---- + +type GaugeProps = { meter: EvilMeterState; label: string } + +const CX = 60 +const CY = 56 +const R = 42 +const BAND_THICKNESS = 12 + +function angleForValue(v: number): number { + // 0 (malevolent) -> 180deg (left); 1 (benevolent) -> 0deg (right); the + // sweep from 180 down to 0 traces the *top* semicircle — the crescent. + return 180 - clamp01(v) * 180 +} + +function polar(radius: number, angleDeg: number): { x: number; y: number } { + const rad = (angleDeg * Math.PI) / 180 + return { x: CX + radius * Math.cos(rad), y: CY - radius * Math.sin(rad) } +} + +function EvilMeterGauge({ meter, label }: GaugeProps) { + const { t } = useTranslation() + const patternId = useId() + const { value, spread, certain } = meter + + const outerR = R + BAND_THICKNESS / 2 + const innerR = R - BAND_THICKNESS / 2 + const lowV = clamp01(value - spread) + const highV = clamp01(value + spread) + const lowAngle = angleForValue(lowV) // larger angle (further left) + const highAngle = angleForValue(highV) // smaller angle (further right) + const largeArc = lowAngle - highAngle > 180 ? 1 : 0 + + const outerStart = polar(outerR, lowAngle) + const outerEnd = polar(outerR, highAngle) + const innerEnd = polar(innerR, highAngle) + const innerStart = polar(innerR, lowAngle) + + const bandPath = [ + `M ${outerStart.x} ${outerStart.y}`, + `A ${outerR} ${outerR} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}`, + `L ${innerEnd.x} ${innerEnd.y}`, + `A ${innerR} ${innerR} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}`, + 'Z', + ].join(' ') + + const trackStart = polar(R, 180) + const trackEnd = polar(R, 0) + const trackPath = `M ${trackStart.x} ${trackStart.y} A ${R} ${R} 0 0 1 ${trackEnd.x} ${trackEnd.y}` + + const needle = polar(R + BAND_THICKNESS / 2 + 4, angleForValue(value)) + + return ( +
+ + + + + + + + + + + + + +
+ + {label} + +
+
+ ) +} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 48cecee..eef61f5 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -351,5 +351,11 @@ "behindYou": "QUANTUM▓NCY — behind you", "listening": "…listening…" } + }, + "ghostLog": { + "title": "GHOST LOG", + "idle": "the veil is quiet — no presence detected", + "waiting": "listening for tells…", + "meterLabel": "evil meter" } } diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 39b65ad..2fbc209 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -351,5 +351,11 @@ "behindYou": "QUANTUM▓NCY — detrás de ti", "listening": "…escuchando…" } + }, + "ghostLog": { + "title": "REGISTRO FANTASMA", + "idle": "el velo está en silencio — no se detecta presencia", + "waiting": "escuchando indicios…", + "meterLabel": "medidor de maldad" } } diff --git a/frontend/src/lib/evilMeter.test.ts b/frontend/src/lib/evilMeter.test.ts new file mode 100644 index 0000000..fa44a79 --- /dev/null +++ b/frontend/src/lib/evilMeter.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import { + EVIL_METER_INITIAL_SPREAD, + EVIL_METER_INITIAL_VALUE, + applyTell, + computeEvilMeter, + evilMeterLabel, +} from './evilMeter' + +describe('computeEvilMeter — idle / empty history', () => { + it('starts at the wide, uncertain midpoint with no tells', () => { + const meter = computeEvilMeter([]) + expect(meter.value).toBe(EVIL_METER_INITIAL_VALUE) + expect(meter.spread).toBe(EVIL_METER_INITIAL_SPREAD) + expect(meter.certain).toBe(false) + expect(meter.sampleCount).toBe(0) + }) + + it('is unaffected by a null/undefined ritualComplete', () => { + expect(computeEvilMeter([], null)).toEqual(computeEvilMeter([])) + expect(computeEvilMeter([], undefined)).toEqual(computeEvilMeter([])) + }) +}) + +describe('computeEvilMeter — narrowing over a sequence of tells', () => { + const tells = [ + { text: 'the entity avoided a direct question' }, + { text: 'a cold draft crossed the room' }, + { text: 'it answered warmly, without hesitation' }, + { text: 'the static thickened around your voice' }, + { text: 'it lingered on your name' }, + ] + + it('spread narrows monotonically (never widens) as tells accumulate', () => { + let spreads: number[] = [] + for (let n = 0; n <= tells.length; n++) { + spreads.push(computeEvilMeter(tells.slice(0, n)).spread) + } + for (let i = 1; i < spreads.length; i++) { + expect(spreads[i]).toBeLessThanOrEqual(spreads[i - 1]) + } + // strictly narrower after at least one tell + expect(spreads[1]).toBeLessThan(spreads[0]) + }) + + it('spread approaches but never drops below the narrowing floor', () => { + const many = Array.from({ length: 200 }, (_, i) => ({ text: `tell number ${i}` })) + const meter = computeEvilMeter(many) + expect(meter.spread).toBeGreaterThan(0) + expect(meter.spread).toBeLessThan(EVIL_METER_INITIAL_SPREAD) + }) + + it('value stays within [0, 1] no matter how many tells arrive', () => { + const many = Array.from({ length: 500 }, (_, i) => ({ text: `wildly evil tell ${i}` })) + const meter = computeEvilMeter(many) + expect(meter.value).toBeGreaterThanOrEqual(0) + expect(meter.value).toBeLessThanOrEqual(1) + }) + + it('is a pure function: same tell sequence always yields the same belief', () => { + const a = computeEvilMeter(tells) + const b = computeEvilMeter(tells.map((t) => ({ text: t.text }))) + expect(a).toEqual(b) + }) + + it('is sensitive to tell order (folds sequentially, not as a set)', () => { + const forward = computeEvilMeter(tells) + const reversed = computeEvilMeter([...tells].reverse()) + expect(forward).not.toEqual(reversed) + }) + + it('never reports certain from tells alone, however many arrive', () => { + const many = Array.from({ length: 1000 }, (_, i) => ({ text: `tell ${i}` })) + expect(computeEvilMeter(many).certain).toBe(false) + }) + + it('sampleCount tracks the tell history length', () => { + expect(computeEvilMeter(tells).sampleCount).toBe(tells.length) + }) +}) + +describe('applyTell — single-step narrowing primitive', () => { + it('always narrows spread by the fixed decay factor', () => { + const before = { value: 0.5, spread: 0.5 } + const after = applyTell(before, { text: 'anything' }) + expect(after.spread).toBeCloseTo(before.spread * 0.82, 5) + }) + + it('is deterministic for a given text', () => { + const a = applyTell({ value: 0.5, spread: 0.5 }, { text: 'the shadows lengthened' }) + const b = applyTell({ value: 0.5, spread: 0.5 }, { text: 'the shadows lengthened' }) + expect(a).toEqual(b) + }) + + it('different text can nudge in different directions', () => { + const base = { value: 0.5, spread: 0.5 } + const results = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta'].map( + (text) => applyTell(base, { text }).value, + ) + const above = results.some((v) => v > 0.5) + const below = results.some((v) => v < 0.5) + expect(above && below).toBe(true) + }) +}) + +describe('computeEvilMeter — ritual_complete override/snap', () => { + const tells = [ + { text: 'the entity avoided a direct question' }, + { text: 'a cold draft crossed the room' }, + ] + + it('snaps to the revealed alignment on a successful ritual, ignoring tell history', () => { + const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 0.9 } }) + expect(meter.value).toBe(0.9) + expect(meter.spread).toBe(0) + expect(meter.certain).toBe(true) + }) + + it('snaps to a malevolent reading too (alignment near 0)', () => { + const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 0.05 } }) + expect(meter.value).toBe(0.05) + expect(meter.certain).toBe(true) + }) + + it('clamps an out-of-range revealed alignment defensively', () => { + const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 1.4 } }) + expect(meter.value).toBe(1) + }) + + it('does NOT snap on a failed ritual — falls back to the tell-driven belief', () => { + const withRitual = computeEvilMeter(tells, { success: false, revealed: null }) + const withoutRitual = computeEvilMeter(tells) + expect(withRitual).toEqual(withoutRitual) + expect(withRitual.certain).toBe(false) + }) + + it('sampleCount still reflects the tell history even when snapped', () => { + const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 0.5 } }) + expect(meter.sampleCount).toBe(tells.length) + }) +}) + +describe('evilMeterLabel', () => { + it('reads "unknown" for the empty/idle state', () => { + expect(evilMeterLabel(computeEvilMeter([]))).toBe('unknown') + }) + + it('reads a confirmed label once certain', () => { + expect( + evilMeterLabel(computeEvilMeter([], { success: true, revealed: { alignment: 0.9 } })), + ).toBe('confirmed benevolent') + expect( + evilMeterLabel(computeEvilMeter([], { success: true, revealed: { alignment: 0.1 } })), + ).toBe('confirmed malevolent') + }) + + it('reads a leaning label once tells push the value past the threshold', () => { + // Hand-pick a value via the snap path (deterministic) to exercise the boundary text. + expect( + evilMeterLabel({ value: 0.7, spread: 0.2, certain: false, sampleCount: 3 }), + ).toBe('leaning benevolent') + expect( + evilMeterLabel({ value: 0.2, spread: 0.2, certain: false, sampleCount: 3 }), + ).toBe('leaning malevolent') + expect( + evilMeterLabel({ value: 0.5, spread: 0.2, certain: false, sampleCount: 3 }), + ).toBe('unreadable') + }) +}) diff --git a/frontend/src/lib/evilMeter.ts b/frontend/src/lib/evilMeter.ts new file mode 100644 index 0000000..f9dfede --- /dev/null +++ b/frontend/src/lib/evilMeter.ts @@ -0,0 +1,132 @@ +// The evil-meter: a pure function turning the accumulated `tell` history +// into a belief about the entity's true nature along the malevolent <-> +// benevolent axis (mirrors the backend's hidden `traits.alignment`, where +// 0 = malevolent/demon and 1 = benevolent spirit — see the Character Depth +// spec's Contract section). +// +// Tells never carry the ground truth (that's the point — the backend keeps +// `traits` hidden outside `ritual_complete`), so this reads only the tell +// *text* itself. Each tell is opaque flavor text ("the entity avoided a +// direct question"), so there is no shared vocabulary to parse reliably — +// instead each tell's text is hashed into a deterministic pseudo-random +// unit value. That value's distance from 0.5 becomes the nudge strength, +// and its side of 0.5 becomes the nudge direction. This keeps the whole +// thing a pure, deterministic function of the tell strings (same tells in +// the same order always produce the same belief — directly unit-testable) +// while still "reading between the lines" the way a real player would, +// each new tell narrowing the uncertainty band a little further. +// +// A successful `ritual_complete` is ground truth: it snaps the meter to +// definitive certainty at the entity's real `revealed.alignment`, ignoring +// every tell that came before (and any that come after, until reset). + +export type Tell = { text: string } + +export type RitualReveal = { + success: boolean + revealed: { alignment: number } | null +} + +export type EvilMeterState = { + /** 0 (malevolent) .. 1 (benevolent) — current best-guess mean. */ + value: number + /** 0 (certain) .. 1 (maximally uncertain) — half-width of the belief band. */ + spread: number + /** true once a successful ritual has snapped this to ground truth. */ + certain: boolean + /** number of tells folded into this belief (0 when snapped by a ritual reset). */ + sampleCount: number +} + +export const EVIL_METER_INITIAL_VALUE = 0.5 +export const EVIL_METER_INITIAL_SPREAD = 0.5 + +/** Spread never narrows past this from tells alone — only a ritual reveal reaches 0. */ +const MIN_SPREAD = 0.04 +/** Multiplicative narrowing applied to the spread per tell (geometric decay). */ +const SPREAD_DECAY = 0.82 +/** Nudge strength is scaled by the *current* spread, so early tells (wide + * spread) move the needle more than late ones (narrow spread) — this is + * what makes the belief "settle" rather than jitter forever. */ +const NUDGE_GAIN = 0.9 + +function clamp01(n: number): number { + return Math.min(1, Math.max(0, n)) +} + +/** Deterministic 0..1 pseudo-random value from a string (FNV-1a-ish). */ +function hashUnit(s: string): number { + let h = 0x811c9dc5 + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + return (h >>> 0) / 0x100000000 +} + +/** + * Folds one tell into a running (value, spread) belief. Exported separately + * from `computeEvilMeter` so the narrowing step itself is directly testable + * in isolation from the reduce-over-history driver. + */ +export function applyTell( + state: { value: number; spread: number }, + tell: Tell, +): { value: number; spread: number } { + const r = hashUnit(tell.text) + const direction = r >= 0.5 ? 1 : -1 + const magnitude = Math.abs(r - 0.5) * 2 // 0..1 + const step = direction * magnitude * state.spread * NUDGE_GAIN + return { + value: clamp01(state.value + step), + spread: Math.max(MIN_SPREAD, state.spread * SPREAD_DECAY), + } +} + +/** + * Computes the current evil-meter belief from the full tell history. + * + * - No tells yet: wide/uncertain band centered on the midpoint. + * - Each subsequent tell narrows the spread and nudges the value. + * - A successful `ritualComplete` overrides everything: the meter snaps to + * the revealed alignment with zero spread ("certain"). A failed ritual + * (`success: false`, `revealed: null`) does not snap — the tell-driven + * belief carries on as if the ritual hadn't resolved anything. + */ +export function computeEvilMeter( + tells: readonly Tell[], + ritualComplete?: RitualReveal | null, +): EvilMeterState { + if (ritualComplete && ritualComplete.success && ritualComplete.revealed) { + return { + value: clamp01(ritualComplete.revealed.alignment), + spread: 0, + certain: true, + sampleCount: tells.length, + } + } + + let running = { value: EVIL_METER_INITIAL_VALUE, spread: EVIL_METER_INITIAL_SPREAD } + for (const tell of tells) { + running = applyTell(running, tell) + } + + return { + value: running.value, + spread: running.spread, + certain: false, + sampleCount: tells.length, + } +} + +/** Human-readable label for the current belief, for the HUD readout. */ +export function evilMeterLabel(state: EvilMeterState): string { + const { value, certain } = state + if (certain) { + return value >= 0.5 ? 'confirmed benevolent' : 'confirmed malevolent' + } + if (state.sampleCount === 0) return 'unknown' + if (value >= 0.66) return 'leaning benevolent' + if (value <= 0.34) return 'leaning malevolent' + return 'unreadable' +} diff --git a/frontend/src/lib/ghostLogBus.ts b/frontend/src/lib/ghostLogBus.ts new file mode 100644 index 0000000..601d688 --- /dev/null +++ b/frontend/src/lib/ghostLogBus.ts @@ -0,0 +1,53 @@ +// GhostLog event bus — mirrors the tiny typed emitter pattern in +// lib/haunting.ts (HauntBus). It exists because is mounted once +// at the app-shell level (visible on every screen) while the live WS session +// lives inside , which is only mounted within the séance +// route/page. React context can't cross that boundary (a shell-level +// sibling can't read a provider nested under a route), so SeanceProvider +// publishes the frames GhostLog cares about onto this bus instead — GhostLog +// subscribes for the lifetime of the app and never needs to sit inside the +// séance's own component tree. + +export type GhostLogEventMap = { + /** A new entity is present (or the session cleared it — entity: null). */ + entity: { name: string | null } + /** One `tell` frame arrived from the server. */ + tell: { text: string } + /** A `ritual_complete` frame arrived. */ + ritual_complete: { success: boolean; revealed: { alignment: number } | null } + /** The active séance session ended (page left / socket torn down) — + * GhostLog should fall back to its idle state. */ + session_end: Record +} + +type GhostLogHandler = (detail: T) => void + +/** Tiny typed emitter — one bad listener never silences the others. */ +export class GhostLogBus { + private handlers = new Map>>() + + on( + type: K, + handler: GhostLogHandler, + ): () => void { + let set = this.handlers.get(type) + if (!set) { + set = new Set() + this.handlers.set(type, set) + } + const h = handler as GhostLogHandler + set.add(h) + return () => { + set.delete(h) + } + } + + emit(type: K, detail: GhostLogEventMap[K]): void { + const set = this.handlers.get(type) + if (!set) return + for (const h of [...set]) (h as GhostLogHandler)(detail) + } +} + +/** The shared bus the app-shell GhostLog and the séance provider both use. */ +export const ghostLogBus = new GhostLogBus() diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 25a2ef0..9cfae79 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -83,6 +83,16 @@ 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' } @@ -98,6 +108,10 @@ 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). + | { type: 'tell'; text: string } + | { type: 'ritual_complete'; success: boolean; revealed: EntityTraits | null } // REST helpers export type SortOrder = 'recent' | 'contacted' diff --git a/frontend/src/state/seance.tsx b/frontend/src/state/seance.tsx index f078688..b01fe0b 100644 --- a/frontend/src/state/seance.tsx +++ b/frontend/src/state/seance.tsx @@ -15,6 +15,7 @@ import type { ReactNode } from 'react' import { VeilSocket } from '../lib/ws' import type { VeilConnectionState } from '../lib/ws' import { SpiritAudioPlayer } from '../lib/audio' +import { ghostLogBus } from '../lib/ghostLogBus' import type { Language, Mode, @@ -414,6 +415,18 @@ export function SeanceProvider({ children }: { children: ReactNode }) { // Auto-play spirit voice on arrival (queued behind anything playing). player.enqueue(frame.id, frame.url) } + // The app-shell Ghost Log HUD lives outside this provider's subtree + // (mounted once at the app-shell level so it's visible on every + // screen — see components/GhostLog.tsx), so it can't read this + // context directly. Publish the frames it cares about onto the + // shared bus instead. + if (frame.type === 'entity') { + ghostLogBus.emit('entity', { name: frame.entity.name }) + } else if (frame.type === 'tell') { + ghostLogBus.emit('tell', { text: frame.text }) + } else if (frame.type === 'ritual_complete') { + ghostLogBus.emit('ritual_complete', { success: frame.success, revealed: frame.revealed }) + } }) const offState = socket.onState((s) => dispatch({ type: 'connection', state: s })) @@ -428,6 +441,10 @@ export function SeanceProvider({ children }: { children: ReactNode }) { offState() socket.close() player.clear() + // Leaving the séance ends this session as far as the Ghost Log is + // concerned — it should fall back to its idle/ambient state on every + // other screen rather than showing a stale entity. + ghostLogBus.emit('session_end', {}) } }, [])