Files
qtalker---/frontend/src/lib/ghostLogBus.ts
Indiana e4a834620f feat: Ghost Log HUD + evil meter + tells (Workstream D)
App-shell HUD (mounted in App.tsx alongside HauntingLayer, visible on
every screen) that shows ambient idle status until a séance is active,
then streams `tell` WS frames as terminal-style log lines with a
"hacker witch" crescent-arc evil-meter gauge (occult sigils/runes fused
with Transcript.tsx's monospace log vocabulary).

- lib/evilMeter.ts: pure function computing a malevolent<->benevolent
  belief from the accumulated tell history — starts wide/uncertain,
  narrows geometrically and shifts per tell (deterministic hash of the
  tell text, since tells never leak ground truth), and snaps to
  definitive certainty on a successful ritual_complete's
  revealed.alignment. Fully unit tested (narrowing, ordering,
  determinism, ritual override, idle/empty history).
- lib/ghostLogBus.ts: tiny typed event bus (mirrors lib/haunting.ts's
  HauntBus) so the app-shell-level GhostLog can react to live séance
  frames — SeanceProvider is only mounted inside the séance route, so a
  shell-level sibling can't read its context directly.
- state/seance.tsx: publish entity/tell/ritual_complete onto the bus,
  and session_end on provider teardown so the HUD falls back to idle
  when the seeker leaves the séance page.
- lib/types.ts: add the `tell` and `ritual_complete` server frames from
  the Character Depth spec's Contract section (only what this
  workstream consumes).
- components/GhostLog.tsx/.css: the HUD itself, plus i18n keys in
  en.json/es.json.

168/168 frontend tests pass (137 pre-existing + 20 evilMeter + 11
GhostLog); tsc -b and the i18n coverage pretest are clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:28:33 +00:00

54 lines
2.0 KiB
TypeScript

// 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()