Implements the Character Depth / Ghost Log spec's Workstream E: - RitualPanel.tsx: a 4-step "focus the channel" hold-to-charge sequence (align/breathe/trace/lock), sending ritual_step frames as the seeker progresses. Success reveals the entity's true hidden traits; failure reveals nothing. Sequencing/timing logic lives in the pure, unit-tested lib/ritual.ts rather than inline in the component. - JudgmentPanel.tsx: Trust/Banish/Cross Over/Test verdict buttons with rune-style SVG icons, sending the judgment frame and rendering a distinct treatment per judgment_result consequence — reward, escalation (also spikes the ambient haunting), withdrawal, resisted, neutral, and a calm glyph-fade farewell for crossed_over (deliberately not the reward treatment, since it's a goodbye). - lib/haunting.ts: IdleEscalator gains forceEscalate()/forcedUntil so a judgment's "escalation" consequence can spike the ambient haunting immediately instead of waiting on the 90s idle clock; exports a sharedIdleEscalator singleton and a forceEscalate() free function. HauntingLayer now paces itself off that shared instance instead of a private one, so the forced spike actually reaches the running layer. - state/seance.tsx: new ritual/judgmentResult state, a local_ritual_start action, and reducer cases for the ritual_complete/judgment_result server frames; SeanceContext exported for component testing; startRitual/ sendRitualStep/sendJudgment added to the provider API. - lib/types.ts: EntityTraits/JudgmentVerdict/JudgmentConsequence types and the new client/server WS frames, per the spec's Contract section. - i18n: seance.ritual.* / seance.judgment.* keys in en.json and es.json. Fast-forwarded this worktree's branch onto master first — it had been created from a stale ancestor commit predating the frontend scaffold entirely, with zero commits of its own ahead of that point. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
// The possession engine: framework-free haunting primitives so the browser
|
||
// itself feels inhabited. A typed event bus ties the layers together (ghost
|
||
// apparition flares, full-screen manifests, fleeting whisper-words); a
|
||
// WebAudio dread-bed hums under everything once a gesture allows it; the
|
||
// document title glitches when nobody is looking. Everything here is
|
||
// jsdom-safe: no AudioContext, no window — no sound, no crash, no complaints.
|
||
|
||
// ---- event bus ----
|
||
|
||
export type HauntEventMap = {
|
||
/** Ghost apparition flash — strength 0..1. */
|
||
flare: { strength: number }
|
||
/** Full-screen CRT flicker — how long the veil tears, in ms. */
|
||
manifest: { durationMs: number }
|
||
/** Fleeting ghost-text at viewport fractions x/y (0..1). */
|
||
whisper: { word: string; x: number; y: number }
|
||
}
|
||
|
||
type HauntHandler<T> = (detail: T) => void
|
||
|
||
/** Tiny typed emitter — one bad listener never silences the others. */
|
||
export class HauntBus {
|
||
private handlers = new Map<keyof HauntEventMap, Set<HauntHandler<never>>>()
|
||
|
||
/** Subscribe; returns an unsubscribe function. */
|
||
on<K extends keyof HauntEventMap>(type: K, handler: HauntHandler<HauntEventMap[K]>): () => void {
|
||
let set = this.handlers.get(type)
|
||
if (!set) {
|
||
set = new Set()
|
||
this.handlers.set(type, set)
|
||
}
|
||
const h = handler as HauntHandler<never>
|
||
set.add(h)
|
||
return () => {
|
||
set.delete(h)
|
||
}
|
||
}
|
||
|
||
/** Dispatch to all current subscribers of `type`. */
|
||
emit<K extends keyof HauntEventMap>(type: K, detail: HauntEventMap[K]): void {
|
||
const set = this.handlers.get(type)
|
||
if (!set) return
|
||
// Snapshot so listeners may unsubscribe mid-dispatch.
|
||
for (const h of [...set]) (h as HauntHandler<HauntEventMap[K]>)(detail)
|
||
}
|
||
}
|
||
|
||
/** The shared bus the haunting layer, ghost scene and board all listen to. */
|
||
export const hauntBus = new HauntBus()
|
||
|
||
// ---- pure helpers ----
|
||
|
||
/** Pick a whisper word; deterministic given `rand`. Empty list → ''. */
|
||
export function pickWhisperWord(words: readonly string[], rand: () => number): string {
|
||
if (words.length === 0) return ''
|
||
const i = Math.min(words.length - 1, Math.floor(rand() * words.length))
|
||
return words[i]
|
||
}
|
||
|
||
/**
|
||
* Paces the haunting by how long the seeker has been still:
|
||
* 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
|
||
}
|
||
|
||
notifyActivity(nowMs: number = Date.now()): void {
|
||
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
|
||
return 2
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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. */
|
||
export type HauntScheduler = (fn: () => void, ms: number) => () => void
|
||
|
||
const timeoutScheduler: HauntScheduler = (fn, ms) => {
|
||
const id = setTimeout(fn, ms)
|
||
return () => clearTimeout(id)
|
||
}
|
||
|
||
export const DEFAULT_GLITCH_TITLES: readonly string[] = [
|
||
'QUANTUMANCY — it sees you',
|
||
'QUANTUM▓NCY — behind you',
|
||
'…listening…',
|
||
]
|
||
|
||
export type FlickerTitleOptions = {
|
||
rand?: () => number
|
||
schedule?: HauntScheduler
|
||
/** Glitch variants, or a getter re-read on every glitch (e.g. for i18n). */
|
||
titles?: readonly string[] | (() => readonly string[])
|
||
doc?: { title: string }
|
||
minDelayMs?: number
|
||
maxDelayMs?: number
|
||
glitchMs?: number
|
||
}
|
||
|
||
/**
|
||
* Every 45–90s (jittered) swaps document.title to a glitch variant for
|
||
* ~2.5s, then restores whatever was there. Returns a stop function that
|
||
* cancels pending work and restores a mid-glitch title.
|
||
*/
|
||
export function flickerTitle(opts: FlickerTitleOptions = {}): () => void {
|
||
const rand = opts.rand ?? Math.random
|
||
const schedule = opts.schedule ?? timeoutScheduler
|
||
const titles = opts.titles ?? DEFAULT_GLITCH_TITLES
|
||
const min = opts.minDelayMs ?? 45_000
|
||
const max = opts.maxDelayMs ?? 90_000
|
||
const glitchMs = opts.glitchMs ?? 2_500
|
||
const doc = opts.doc ?? (typeof document !== 'undefined' ? document : undefined)
|
||
if (!doc) return () => undefined
|
||
|
||
let stopped = false
|
||
let cancelNext: (() => void) | null = null
|
||
let cancelRestore: (() => void) | null = null
|
||
let original: string | null = null
|
||
|
||
const restore = () => {
|
||
if (original !== null) {
|
||
doc.title = original
|
||
original = null
|
||
}
|
||
}
|
||
|
||
const scheduleNext = () => {
|
||
cancelNext = schedule(() => {
|
||
if (stopped) return
|
||
const pool = typeof titles === 'function' ? titles() : titles
|
||
if (pool.length === 0) {
|
||
scheduleNext()
|
||
return
|
||
}
|
||
original = doc.title
|
||
doc.title = pool[Math.min(pool.length - 1, Math.floor(rand() * pool.length))]
|
||
cancelRestore = schedule(() => {
|
||
restore()
|
||
if (!stopped) scheduleNext()
|
||
}, glitchMs)
|
||
}, min + rand() * (max - min))
|
||
}
|
||
|
||
scheduleNext()
|
||
|
||
return () => {
|
||
stopped = true
|
||
cancelNext?.()
|
||
cancelRestore?.()
|
||
restore()
|
||
}
|
||
}
|
||
|
||
// ---- WebAudio helpers (all guarded: jsdom has no AudioContext) ----
|
||
|
||
/** The AudioContext constructor, or null where WebAudio does not exist. */
|
||
export function getAudioContextCtor(): typeof AudioContext | null {
|
||
if (typeof window === 'undefined') return null
|
||
const w = window as unknown as {
|
||
AudioContext?: typeof AudioContext
|
||
webkitAudioContext?: typeof AudioContext
|
||
}
|
||
return w.AudioContext ?? w.webkitAudioContext ?? null
|
||
}
|
||
|
||
/**
|
||
* Autoplay-policy bridge: runs `onGesture` exactly once on the first
|
||
* pointerdown/keydown, then removes itself. Returns a disarm function.
|
||
* No WebAudio in this environment → a no-op disposer.
|
||
*/
|
||
export function armAudioOnFirstGesture(onGesture: () => void): () => void {
|
||
if (typeof window === 'undefined' || !getAudioContextCtor()) return () => undefined
|
||
let fired = false
|
||
const handler = () => {
|
||
if (fired) return
|
||
fired = true
|
||
cleanup()
|
||
try {
|
||
onGesture()
|
||
} catch {
|
||
// The haunting must never crash the page it possesses.
|
||
}
|
||
}
|
||
window.addEventListener('pointerdown', handler, { once: true, passive: true })
|
||
window.addEventListener('keydown', handler, { once: true })
|
||
function cleanup() {
|
||
window.removeEventListener('pointerdown', handler)
|
||
window.removeEventListener('keydown', handler)
|
||
}
|
||
return cleanup
|
||
}
|
||
|
||
function makeBrownNoise(ctx: AudioContext, rand: () => number): AudioBuffer {
|
||
const len = Math.floor(ctx.sampleRate * 3)
|
||
const buffer = ctx.createBuffer(1, len, ctx.sampleRate)
|
||
const data = buffer.getChannelData(0)
|
||
let last = 0
|
||
for (let i = 0; i < len; i++) {
|
||
const white = rand() * 2 - 1
|
||
last = (last + 0.02 * white) / 1.02
|
||
data[i] = last * 3.5
|
||
}
|
||
return buffer
|
||
}
|
||
|
||
/**
|
||
* A short filtered noise-tap — the spirit knock. Scheduling on a suspended
|
||
* context is harmless (it never sounds), so callers may be lazy.
|
||
*/
|
||
export function createTick(ctx: AudioContext, when?: number): void {
|
||
try {
|
||
const at = when ?? ctx.currentTime
|
||
const dur = 0.09
|
||
const len = Math.max(1, Math.floor(ctx.sampleRate * dur))
|
||
const buffer = ctx.createBuffer(1, len, ctx.sampleRate)
|
||
const data = buffer.getChannelData(0)
|
||
for (let i = 0; i < len; i++) {
|
||
data[i] = (Math.random() * 2 - 1) * (1 - i / len) ** 2
|
||
}
|
||
const src = ctx.createBufferSource()
|
||
src.buffer = buffer
|
||
const bp = ctx.createBiquadFilter()
|
||
bp.type = 'bandpass'
|
||
bp.frequency.value = 1500 + Math.random() * 900
|
||
bp.Q.value = 9
|
||
const g = ctx.createGain()
|
||
g.gain.setValueAtTime(0.4, at)
|
||
g.gain.exponentialRampToValueAtTime(0.001, at + dur)
|
||
src.connect(bp)
|
||
bp.connect(g)
|
||
g.connect(ctx.destination)
|
||
src.start(at)
|
||
src.stop(at + dur + 0.02)
|
||
} catch {
|
||
// Silence is an acceptable haunting outcome.
|
||
}
|
||
}
|
||
|
||
export type HauntBedOptions = {
|
||
rand?: () => number
|
||
}
|
||
|
||
/**
|
||
* The ambient dread-bed: looped brown noise through a lowpass (300–600Hz)
|
||
* with a slow LFO breathing on the gain, a faint detuned dual-sine drone
|
||
* (55Hz + 55.7Hz — the 0.7Hz beat you feel more than hear), and occasional
|
||
* reversed-swell bursts every 20–40s. Obeys autoplay policy: nothing sounds
|
||
* before `armOnFirstGesture()` sees a real gesture. Never throws.
|
||
*/
|
||
export class HauntBed {
|
||
private ctx: AudioContext | null = null
|
||
private master: GainNode | null = null
|
||
private noiseGain: GainNode | null = null
|
||
private noiseBuffer: AudioBuffer | null = null
|
||
private noiseSource: AudioBufferSourceNode | null = null
|
||
private lfo: OscillatorNode | null = null
|
||
private droneOscs: OscillatorNode[] = []
|
||
private running = false
|
||
private graphBuilt = false
|
||
private intensity = 0.2
|
||
private swellTimer: ReturnType<typeof setTimeout> | null = null
|
||
private disarmGestures: (() => void) | null = null
|
||
private readonly rand: () => number
|
||
|
||
constructor(opts: HauntBedOptions = {}) {
|
||
this.rand = opts.rand ?? Math.random
|
||
}
|
||
|
||
/**
|
||
* Install one-time first-gesture listeners that create/resume the
|
||
* AudioContext. Idempotent; a no-op where WebAudio does not exist.
|
||
*/
|
||
armOnFirstGesture(): void {
|
||
if (this.disarmGestures) return
|
||
this.disarmGestures = armAudioOnFirstGesture(() => {
|
||
this.ensureContext()
|
||
this.wake()
|
||
})
|
||
}
|
||
|
||
/** Begin (or mark intent to begin) the bed. Safe to call pre-gesture. */
|
||
start(): void {
|
||
this.running = true
|
||
this.wake()
|
||
}
|
||
|
||
/** Silence the bed, close the context, drop the gesture arm. */
|
||
stop(): void {
|
||
this.running = false
|
||
this.clearSwellTimer()
|
||
this.stopGraph()
|
||
this.disarmGestures?.()
|
||
this.disarmGestures = null
|
||
const ctx = this.ctx
|
||
this.ctx = null
|
||
if (ctx && ctx.state !== 'closed') {
|
||
void ctx.close().catch(() => undefined)
|
||
}
|
||
}
|
||
|
||
/** 0..1 — raises the noise level and shortens the swell cadence. */
|
||
setIntensity(v: number): void {
|
||
this.intensity = Math.max(0, Math.min(1, v))
|
||
if (this.ctx && this.noiseGain) {
|
||
try {
|
||
this.noiseGain.gain.setTargetAtTime(this.noiseLevel(), this.ctx.currentTime, 0.8)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
|
||
private noiseLevel(): number {
|
||
return 0.05 + this.intensity * 0.16
|
||
}
|
||
|
||
private ensureContext(): void {
|
||
if (this.ctx) return
|
||
const AC = getAudioContextCtor()
|
||
if (!AC) return
|
||
try {
|
||
this.ctx = new AC()
|
||
if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => undefined)
|
||
} catch {
|
||
this.ctx = null
|
||
}
|
||
}
|
||
|
||
private wake(): void {
|
||
if (!this.ctx || !this.running || this.graphBuilt) return
|
||
if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => undefined)
|
||
try {
|
||
this.buildGraph()
|
||
this.graphBuilt = true
|
||
this.scheduleSwell()
|
||
} catch {
|
||
this.stopGraph()
|
||
}
|
||
}
|
||
|
||
private buildGraph(): void {
|
||
const ctx = this.ctx
|
||
if (!ctx) return
|
||
const now = ctx.currentTime
|
||
|
||
this.master = ctx.createGain()
|
||
this.master.gain.value = 0.9
|
||
this.master.connect(ctx.destination)
|
||
|
||
// Brown noise → lowpass (300–600Hz) → breathing gain → master.
|
||
this.noiseBuffer = makeBrownNoise(ctx, this.rand)
|
||
const src = ctx.createBufferSource()
|
||
src.buffer = this.noiseBuffer
|
||
src.loop = true
|
||
const lp = ctx.createBiquadFilter()
|
||
lp.type = 'lowpass'
|
||
lp.frequency.value = 300 + this.rand() * 300
|
||
this.noiseGain = ctx.createGain()
|
||
this.noiseGain.gain.value = 0
|
||
this.noiseGain.gain.setTargetAtTime(this.noiseLevel(), now, 2.5)
|
||
src.connect(lp)
|
||
lp.connect(this.noiseGain)
|
||
this.noiseGain.connect(this.master)
|
||
src.start()
|
||
this.noiseSource = src
|
||
|
||
// Slow LFO breathing on the noise gain.
|
||
this.lfo = ctx.createOscillator()
|
||
this.lfo.frequency.value = 0.07
|
||
const lfoDepth = ctx.createGain()
|
||
lfoDepth.gain.value = 0.035
|
||
this.lfo.connect(lfoDepth)
|
||
lfoDepth.connect(this.noiseGain.gain)
|
||
this.lfo.start()
|
||
|
||
// Detuned dual-sine drone: 55Hz + 55.7Hz, a slow beating under it all.
|
||
for (const freq of [55, 55.7]) {
|
||
const osc = ctx.createOscillator()
|
||
osc.type = 'sine'
|
||
osc.frequency.value = freq
|
||
const g = ctx.createGain()
|
||
g.gain.value = 0
|
||
g.gain.setTargetAtTime(0.022, now, 3)
|
||
osc.connect(g)
|
||
g.connect(this.master)
|
||
osc.start()
|
||
this.droneOscs.push(osc)
|
||
}
|
||
}
|
||
|
||
private stopGraph(): void {
|
||
try {
|
||
this.noiseSource?.stop()
|
||
} catch {
|
||
/* already stopped */
|
||
}
|
||
try {
|
||
this.lfo?.stop()
|
||
} catch {
|
||
/* already stopped */
|
||
}
|
||
for (const osc of this.droneOscs) {
|
||
try {
|
||
osc.stop()
|
||
} catch {
|
||
/* already stopped */
|
||
}
|
||
}
|
||
this.droneOscs = []
|
||
this.noiseSource = null
|
||
this.lfo = null
|
||
this.noiseGain = null
|
||
this.noiseBuffer = null
|
||
try {
|
||
this.master?.disconnect()
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
this.master = null
|
||
this.graphBuilt = false
|
||
}
|
||
|
||
private scheduleSwell(): void {
|
||
this.clearSwellTimer()
|
||
if (!this.running || !this.ctx) return
|
||
// Every 40s at intensity 0, tightening to 20s at intensity 1, jittered.
|
||
const base = 40_000 - this.intensity * 20_000
|
||
const delay = base * (0.75 + this.rand() * 0.5)
|
||
this.swellTimer = setTimeout(() => {
|
||
this.swellTimer = null
|
||
this.playSwell()
|
||
this.scheduleSwell()
|
||
}, delay)
|
||
}
|
||
|
||
private clearSwellTimer(): void {
|
||
if (this.swellTimer !== null) {
|
||
clearTimeout(this.swellTimer)
|
||
this.swellTimer = null
|
||
}
|
||
}
|
||
|
||
/** Reversed-swell burst: a slow 1.6s rise cut off almost instantly. */
|
||
private playSwell(): void {
|
||
const ctx = this.ctx
|
||
if (!ctx || !this.master || !this.noiseBuffer || !this.running) return
|
||
try {
|
||
const src = ctx.createBufferSource()
|
||
src.buffer = this.noiseBuffer
|
||
const bp = ctx.createBiquadFilter()
|
||
bp.type = 'bandpass'
|
||
bp.frequency.value = 500 + this.rand() * 700
|
||
bp.Q.value = 2.5
|
||
const g = ctx.createGain()
|
||
const now = ctx.currentTime
|
||
const peak = 0.05 + this.intensity * 0.12
|
||
g.gain.setValueAtTime(0.0001, now)
|
||
g.gain.exponentialRampToValueAtTime(peak, now + 1.6)
|
||
g.gain.exponentialRampToValueAtTime(0.0001, now + 2.1)
|
||
src.connect(bp)
|
||
bp.connect(g)
|
||
g.connect(this.master)
|
||
src.start(now)
|
||
src.stop(now + 2.3)
|
||
} catch {
|
||
/* a swell that never swells */
|
||
}
|
||
}
|
||
}
|