// 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 = (detail: T) => void /** Tiny typed emitter — one bad listener never silences the others. */ export class HauntBus { private handlers = new Map>>() /** Subscribe; returns an unsubscribe function. */ on(type: K, handler: HauntHandler): () => void { let set = this.handlers.get(type) if (!set) { set = new Set() this.handlers.set(type, set) } const h = handler as HauntHandler set.add(h) return () => { set.delete(h) } } /** Dispatch to all current subscribers of `type`. */ emit(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)(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 | 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 */ } } }