Merge Workstream D: Ghost Log HUD + evil meter + tells
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||||
import { AuthProvider } from './state/auth'
|
import { AuthProvider } from './state/auth'
|
||||||
import { HauntingLayer } from './components/HauntingLayer'
|
import { HauntingLayer } from './components/HauntingLayer'
|
||||||
|
import { GhostLog } from './components/GhostLog'
|
||||||
import { CodexEntityPage } from './pages/CodexEntityPage'
|
import { CodexEntityPage } from './pages/CodexEntityPage'
|
||||||
import { CodexPage } from './pages/CodexPage'
|
import { CodexPage } from './pages/CodexPage'
|
||||||
import { EnterPage } from './pages/EnterPage'
|
import { EnterPage } from './pages/EnterPage'
|
||||||
@@ -20,6 +21,7 @@ function App() {
|
|||||||
<div className="crt-vignette" />
|
<div className="crt-vignette" />
|
||||||
</div>
|
</div>
|
||||||
<HauntingLayer />
|
<HauntingLayer />
|
||||||
|
<GhostLog />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/enter" element={<EnterPage />} />
|
<Route path="/enter" element={<EnterPage />} />
|
||||||
|
|||||||
258
frontend/src/components/GhostLog.css
Normal file
258
frontend/src/components/GhostLog.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
frontend/src/components/GhostLog.test.tsx
Normal file
139
frontend/src/components/GhostLog.test.tsx
Normal file
@@ -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 <SeanceProvider> does in
|
||||||
|
// production: by emitting frames onto the bus and letting React flush.
|
||||||
|
function emit<K extends keyof GhostLogEventMap>(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(<GhostLog />)
|
||||||
|
expect(
|
||||||
|
screen.getByText('the veil is quiet — no presence detected'),
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no evil-meter gauge while idle', () => {
|
||||||
|
render(<GhostLog />)
|
||||||
|
expect(screen.queryByTestId('evil-meter')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries the idle class on the root element', () => {
|
||||||
|
const { container } = render(<GhostLog />)
|
||||||
|
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(<GhostLog />)
|
||||||
|
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(<GhostLog />)
|
||||||
|
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(<GhostLog />)
|
||||||
|
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(<GhostLog />)
|
||||||
|
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(<GhostLog />)
|
||||||
|
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(<GhostLog />)
|
||||||
|
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(<GhostLog />)
|
||||||
|
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(<GhostLog />)
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
254
frontend/src/components/GhostLog.tsx
Normal file
254
frontend/src/components/GhostLog.tsx
Normal file
@@ -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 <SeanceProvider> 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<string | null>(null)
|
||||||
|
const [tells, setTells] = useState<TellLine[]>([])
|
||||||
|
const [ritualReveal, setRitualReveal] = useState<RitualReveal | null>(null)
|
||||||
|
const bodyRef = useRef<HTMLDivElement | null>(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 (
|
||||||
|
<aside
|
||||||
|
className={`ghost-log ${active ? 'ghost-log--active' : 'ghost-log--idle'}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className="ghost-log-corner ghost-log-corner--tl" aria-hidden="true">
|
||||||
|
☽
|
||||||
|
</span>
|
||||||
|
<span className="ghost-log-corner ghost-log-corner--tr" aria-hidden="true">
|
||||||
|
☾
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<header className="ghost-log-header">
|
||||||
|
<span className="ghost-log-glyph" aria-hidden="true">
|
||||||
|
⟁
|
||||||
|
</span>
|
||||||
|
<span className="ghost-log-title">
|
||||||
|
{t('ghostLog.title', { defaultValue: 'GHOST LOG' })}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{active && <EvilMeterGauge meter={meter} label={evilMeterLabel(meter)} />}
|
||||||
|
|
||||||
|
<div className="ghost-log-divider" aria-hidden="true">
|
||||||
|
<span>·</span>
|
||||||
|
<span className="ghost-log-divider-sigil">⛧</span>
|
||||||
|
<span>·</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!active ? (
|
||||||
|
<p className="ghost-log-status flicker-soft">
|
||||||
|
{t('ghostLog.idle', { defaultValue: 'the veil is quiet — no presence detected' })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="ghost-log-body" ref={bodyRef}>
|
||||||
|
{entityName && (
|
||||||
|
<div className="ghost-log-line ghost-log-line--system">
|
||||||
|
<span className="ghost-log-rune" aria-hidden="true">
|
||||||
|
⟁
|
||||||
|
</span>
|
||||||
|
<span className="ghost-log-text ghost-log-entity-name">{entityName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tells.length === 0 ? (
|
||||||
|
<p className="ghost-log-status dim">
|
||||||
|
{t('ghostLog.waiting', { defaultValue: 'listening for tells…' })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
tells.map((tl) => (
|
||||||
|
<div key={tl.id} className="ghost-log-line">
|
||||||
|
<span className="ghost-log-rune" aria-hidden="true">
|
||||||
|
⚝
|
||||||
|
</span>
|
||||||
|
<span className="ghost-log-text">{tl.text}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={`evil-meter${certain ? ' evil-meter--certain' : ''}`}
|
||||||
|
data-testid="evil-meter"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 120 62"
|
||||||
|
className="evil-meter-svg"
|
||||||
|
role="meter"
|
||||||
|
aria-label={t('ghostLog.meterLabel', { defaultValue: 'evil meter' })}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={1}
|
||||||
|
aria-valuenow={Number(value.toFixed(3))}
|
||||||
|
aria-valuetext={label}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id={patternId}
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
patternTransform="rotate(45)"
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<rect width="4" height="4" fill="transparent" />
|
||||||
|
<line x1="0" y1="0" x2="0" y2="4" className="evil-meter-scanline" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<path d={trackPath} className="evil-meter-track" />
|
||||||
|
<path d={bandPath} className={`evil-meter-band${certain ? ' evil-meter-band--certain' : ''}`} />
|
||||||
|
<path d={bandPath} fill={`url(#${patternId})`} className="evil-meter-band-texture" />
|
||||||
|
<line
|
||||||
|
x1={CX}
|
||||||
|
y1={CY}
|
||||||
|
x2={needle.x}
|
||||||
|
y2={needle.y}
|
||||||
|
className="evil-meter-needle"
|
||||||
|
/>
|
||||||
|
<circle cx={CX} cy={CY} r={3} className="evil-meter-hub" />
|
||||||
|
</svg>
|
||||||
|
<div className="evil-meter-readout">
|
||||||
|
<span className="evil-meter-glyph evil-meter-glyph--malevolent" aria-hidden="true">
|
||||||
|
⛧
|
||||||
|
</span>
|
||||||
|
<span className="evil-meter-label">{label}</span>
|
||||||
|
<span className="evil-meter-glyph evil-meter-glyph--benevolent" aria-hidden="true">
|
||||||
|
✧
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -351,5 +351,11 @@
|
|||||||
"behindYou": "QUANTUM▓NCY — behind you",
|
"behindYou": "QUANTUM▓NCY — behind you",
|
||||||
"listening": "…listening…"
|
"listening": "…listening…"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ghostLog": {
|
||||||
|
"title": "GHOST LOG",
|
||||||
|
"idle": "the veil is quiet — no presence detected",
|
||||||
|
"waiting": "listening for tells…",
|
||||||
|
"meterLabel": "evil meter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,5 +351,11 @@
|
|||||||
"behindYou": "QUANTUM▓NCY — detrás de ti",
|
"behindYou": "QUANTUM▓NCY — detrás de ti",
|
||||||
"listening": "…escuchando…"
|
"listening": "…escuchando…"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ghostLog": {
|
||||||
|
"title": "REGISTRO FANTASMA",
|
||||||
|
"idle": "el velo está en silencio — no se detecta presencia",
|
||||||
|
"waiting": "escuchando indicios…",
|
||||||
|
"meterLabel": "medidor de maldad"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
169
frontend/src/lib/evilMeter.test.ts
Normal file
169
frontend/src/lib/evilMeter.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
132
frontend/src/lib/evilMeter.ts
Normal file
132
frontend/src/lib/evilMeter.ts
Normal file
@@ -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'
|
||||||
|
}
|
||||||
53
frontend/src/lib/ghostLogBus.ts
Normal file
53
frontend/src/lib/ghostLogBus.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// GhostLog event bus — mirrors the tiny typed emitter pattern in
|
||||||
|
// lib/haunting.ts (HauntBus). It exists because <GhostLog/> is mounted once
|
||||||
|
// at the app-shell level (visible on every screen) while the live WS session
|
||||||
|
// lives inside <SeanceProvider>, 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<string, never>
|
||||||
|
}
|
||||||
|
|
||||||
|
type GhostLogHandler<T> = (detail: T) => void
|
||||||
|
|
||||||
|
/** Tiny typed emitter — one bad listener never silences the others. */
|
||||||
|
export class GhostLogBus {
|
||||||
|
private handlers = new Map<keyof GhostLogEventMap, Set<GhostLogHandler<never>>>()
|
||||||
|
|
||||||
|
on<K extends keyof GhostLogEventMap>(
|
||||||
|
type: K,
|
||||||
|
handler: GhostLogHandler<GhostLogEventMap[K]>,
|
||||||
|
): () => void {
|
||||||
|
let set = this.handlers.get(type)
|
||||||
|
if (!set) {
|
||||||
|
set = new Set()
|
||||||
|
this.handlers.set(type, set)
|
||||||
|
}
|
||||||
|
const h = handler as GhostLogHandler<never>
|
||||||
|
set.add(h)
|
||||||
|
return () => {
|
||||||
|
set.delete(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends keyof GhostLogEventMap>(type: K, detail: GhostLogEventMap[K]): void {
|
||||||
|
const set = this.handlers.get(type)
|
||||||
|
if (!set) return
|
||||||
|
for (const h of [...set]) (h as GhostLogHandler<GhostLogEventMap[K]>)(detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared bus the app-shell GhostLog and the séance provider both use. */
|
||||||
|
export const ghostLogBus = new GhostLogBus()
|
||||||
@@ -83,6 +83,16 @@ export type Telemetry = {
|
|||||||
|
|
||||||
export type UtteranceKind = 'greeting' | 'fragment' | 'ambient' | 'reply'
|
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 =
|
export type ServerFrame =
|
||||||
| { type: 'session'; id: string }
|
| { type: 'session'; id: string }
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
@@ -98,6 +108,10 @@ export type ServerFrame =
|
|||||||
| { type: 'reply_end'; id: string; text: string }
|
| { type: 'reply_end'; id: string; text: string }
|
||||||
| { type: 'telemetry' } & Telemetry
|
| { type: 'telemetry' } & Telemetry
|
||||||
| { type: 'error'; code: 'rate_limited' | 'veil_crowded' | string; message: string }
|
| { type: 'error'; code: 'rate_limited' | 'veil_crowded' | string; message: string }
|
||||||
|
// 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
|
// REST helpers
|
||||||
export type SortOrder = 'recent' | 'contacted'
|
export type SortOrder = 'recent' | 'contacted'
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { ReactNode } from 'react'
|
|||||||
import { VeilSocket } from '../lib/ws'
|
import { VeilSocket } from '../lib/ws'
|
||||||
import type { VeilConnectionState } from '../lib/ws'
|
import type { VeilConnectionState } from '../lib/ws'
|
||||||
import { SpiritAudioPlayer } from '../lib/audio'
|
import { SpiritAudioPlayer } from '../lib/audio'
|
||||||
|
import { ghostLogBus } from '../lib/ghostLogBus'
|
||||||
import type {
|
import type {
|
||||||
Language,
|
Language,
|
||||||
Mode,
|
Mode,
|
||||||
@@ -414,6 +415,18 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
|
|||||||
// Auto-play spirit voice on arrival (queued behind anything playing).
|
// Auto-play spirit voice on arrival (queued behind anything playing).
|
||||||
player.enqueue(frame.id, frame.url)
|
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 }))
|
const offState = socket.onState((s) => dispatch({ type: 'connection', state: s }))
|
||||||
|
|
||||||
@@ -428,6 +441,10 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
|
|||||||
offState()
|
offState()
|
||||||
socket.close()
|
socket.close()
|
||||||
player.clear()
|
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', {})
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user