// GhostLog event bus — mirrors the tiny typed emitter pattern in
// lib/haunting.ts (HauntBus). It exists because is mounted once
// at the app-shell level (visible on every screen) while the live WS session
// lives inside , 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
}
type GhostLogHandler = (detail: T) => void
/** Tiny typed emitter — one bad listener never silences the others. */
export class GhostLogBus {
private handlers = new Map>>()
on(
type: K,
handler: GhostLogHandler,
): () => void {
let set = this.handlers.get(type)
if (!set) {
set = new Set()
this.handlers.set(type, set)
}
const h = handler as GhostLogHandler
set.add(h)
return () => {
set.delete(h)
}
}
emit(type: K, detail: GhostLogEventMap[K]): void {
const set = this.handlers.get(type)
if (!set) return
for (const h of [...set]) (h as GhostLogHandler)(detail)
}
}
/** The shared bus the app-shell GhostLog and the séance provider both use. */
export const ghostLogBus = new GhostLogBus()