Files
qtalker---/frontend/src/lib/haunting.ts
Indiana a52a781880 feat(frontend): ritual mini-game, judgment UI, and consequences (Workstream E)
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>
2026-07-23 11:29:29 +00:00

519 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 */
}
}
}