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>
This commit is contained in:
169
frontend/src/lib/evilMeter.test.ts
Normal file
169
frontend/src/lib/evilMeter.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
EVIL_METER_INITIAL_SPREAD,
|
||||
EVIL_METER_INITIAL_VALUE,
|
||||
applyTell,
|
||||
computeEvilMeter,
|
||||
evilMeterLabel,
|
||||
} from './evilMeter'
|
||||
|
||||
describe('computeEvilMeter — idle / empty history', () => {
|
||||
it('starts at the wide, uncertain midpoint with no tells', () => {
|
||||
const meter = computeEvilMeter([])
|
||||
expect(meter.value).toBe(EVIL_METER_INITIAL_VALUE)
|
||||
expect(meter.spread).toBe(EVIL_METER_INITIAL_SPREAD)
|
||||
expect(meter.certain).toBe(false)
|
||||
expect(meter.sampleCount).toBe(0)
|
||||
})
|
||||
|
||||
it('is unaffected by a null/undefined ritualComplete', () => {
|
||||
expect(computeEvilMeter([], null)).toEqual(computeEvilMeter([]))
|
||||
expect(computeEvilMeter([], undefined)).toEqual(computeEvilMeter([]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeEvilMeter — narrowing over a sequence of tells', () => {
|
||||
const tells = [
|
||||
{ text: 'the entity avoided a direct question' },
|
||||
{ text: 'a cold draft crossed the room' },
|
||||
{ text: 'it answered warmly, without hesitation' },
|
||||
{ text: 'the static thickened around your voice' },
|
||||
{ text: 'it lingered on your name' },
|
||||
]
|
||||
|
||||
it('spread narrows monotonically (never widens) as tells accumulate', () => {
|
||||
let spreads: number[] = []
|
||||
for (let n = 0; n <= tells.length; n++) {
|
||||
spreads.push(computeEvilMeter(tells.slice(0, n)).spread)
|
||||
}
|
||||
for (let i = 1; i < spreads.length; i++) {
|
||||
expect(spreads[i]).toBeLessThanOrEqual(spreads[i - 1])
|
||||
}
|
||||
// strictly narrower after at least one tell
|
||||
expect(spreads[1]).toBeLessThan(spreads[0])
|
||||
})
|
||||
|
||||
it('spread approaches but never drops below the narrowing floor', () => {
|
||||
const many = Array.from({ length: 200 }, (_, i) => ({ text: `tell number ${i}` }))
|
||||
const meter = computeEvilMeter(many)
|
||||
expect(meter.spread).toBeGreaterThan(0)
|
||||
expect(meter.spread).toBeLessThan(EVIL_METER_INITIAL_SPREAD)
|
||||
})
|
||||
|
||||
it('value stays within [0, 1] no matter how many tells arrive', () => {
|
||||
const many = Array.from({ length: 500 }, (_, i) => ({ text: `wildly evil tell ${i}` }))
|
||||
const meter = computeEvilMeter(many)
|
||||
expect(meter.value).toBeGreaterThanOrEqual(0)
|
||||
expect(meter.value).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('is a pure function: same tell sequence always yields the same belief', () => {
|
||||
const a = computeEvilMeter(tells)
|
||||
const b = computeEvilMeter(tells.map((t) => ({ text: t.text })))
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
|
||||
it('is sensitive to tell order (folds sequentially, not as a set)', () => {
|
||||
const forward = computeEvilMeter(tells)
|
||||
const reversed = computeEvilMeter([...tells].reverse())
|
||||
expect(forward).not.toEqual(reversed)
|
||||
})
|
||||
|
||||
it('never reports certain from tells alone, however many arrive', () => {
|
||||
const many = Array.from({ length: 1000 }, (_, i) => ({ text: `tell ${i}` }))
|
||||
expect(computeEvilMeter(many).certain).toBe(false)
|
||||
})
|
||||
|
||||
it('sampleCount tracks the tell history length', () => {
|
||||
expect(computeEvilMeter(tells).sampleCount).toBe(tells.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyTell — single-step narrowing primitive', () => {
|
||||
it('always narrows spread by the fixed decay factor', () => {
|
||||
const before = { value: 0.5, spread: 0.5 }
|
||||
const after = applyTell(before, { text: 'anything' })
|
||||
expect(after.spread).toBeCloseTo(before.spread * 0.82, 5)
|
||||
})
|
||||
|
||||
it('is deterministic for a given text', () => {
|
||||
const a = applyTell({ value: 0.5, spread: 0.5 }, { text: 'the shadows lengthened' })
|
||||
const b = applyTell({ value: 0.5, spread: 0.5 }, { text: 'the shadows lengthened' })
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
|
||||
it('different text can nudge in different directions', () => {
|
||||
const base = { value: 0.5, spread: 0.5 }
|
||||
const results = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta'].map(
|
||||
(text) => applyTell(base, { text }).value,
|
||||
)
|
||||
const above = results.some((v) => v > 0.5)
|
||||
const below = results.some((v) => v < 0.5)
|
||||
expect(above && below).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeEvilMeter — ritual_complete override/snap', () => {
|
||||
const tells = [
|
||||
{ text: 'the entity avoided a direct question' },
|
||||
{ text: 'a cold draft crossed the room' },
|
||||
]
|
||||
|
||||
it('snaps to the revealed alignment on a successful ritual, ignoring tell history', () => {
|
||||
const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 0.9 } })
|
||||
expect(meter.value).toBe(0.9)
|
||||
expect(meter.spread).toBe(0)
|
||||
expect(meter.certain).toBe(true)
|
||||
})
|
||||
|
||||
it('snaps to a malevolent reading too (alignment near 0)', () => {
|
||||
const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 0.05 } })
|
||||
expect(meter.value).toBe(0.05)
|
||||
expect(meter.certain).toBe(true)
|
||||
})
|
||||
|
||||
it('clamps an out-of-range revealed alignment defensively', () => {
|
||||
const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 1.4 } })
|
||||
expect(meter.value).toBe(1)
|
||||
})
|
||||
|
||||
it('does NOT snap on a failed ritual — falls back to the tell-driven belief', () => {
|
||||
const withRitual = computeEvilMeter(tells, { success: false, revealed: null })
|
||||
const withoutRitual = computeEvilMeter(tells)
|
||||
expect(withRitual).toEqual(withoutRitual)
|
||||
expect(withRitual.certain).toBe(false)
|
||||
})
|
||||
|
||||
it('sampleCount still reflects the tell history even when snapped', () => {
|
||||
const meter = computeEvilMeter(tells, { success: true, revealed: { alignment: 0.5 } })
|
||||
expect(meter.sampleCount).toBe(tells.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('evilMeterLabel', () => {
|
||||
it('reads "unknown" for the empty/idle state', () => {
|
||||
expect(evilMeterLabel(computeEvilMeter([]))).toBe('unknown')
|
||||
})
|
||||
|
||||
it('reads a confirmed label once certain', () => {
|
||||
expect(
|
||||
evilMeterLabel(computeEvilMeter([], { success: true, revealed: { alignment: 0.9 } })),
|
||||
).toBe('confirmed benevolent')
|
||||
expect(
|
||||
evilMeterLabel(computeEvilMeter([], { success: true, revealed: { alignment: 0.1 } })),
|
||||
).toBe('confirmed malevolent')
|
||||
})
|
||||
|
||||
it('reads a leaning label once tells push the value past the threshold', () => {
|
||||
// Hand-pick a value via the snap path (deterministic) to exercise the boundary text.
|
||||
expect(
|
||||
evilMeterLabel({ value: 0.7, spread: 0.2, certain: false, sampleCount: 3 }),
|
||||
).toBe('leaning benevolent')
|
||||
expect(
|
||||
evilMeterLabel({ value: 0.2, spread: 0.2, certain: false, sampleCount: 3 }),
|
||||
).toBe('leaning malevolent')
|
||||
expect(
|
||||
evilMeterLabel({ value: 0.5, spread: 0.2, certain: false, sampleCount: 3 }),
|
||||
).toBe('unreadable')
|
||||
})
|
||||
})
|
||||
132
frontend/src/lib/evilMeter.ts
Normal file
132
frontend/src/lib/evilMeter.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// The evil-meter: a pure function turning the accumulated `tell` history
|
||||
// into a belief about the entity's true nature along the malevolent <->
|
||||
// benevolent axis (mirrors the backend's hidden `traits.alignment`, where
|
||||
// 0 = malevolent/demon and 1 = benevolent spirit — see the Character Depth
|
||||
// spec's Contract section).
|
||||
//
|
||||
// Tells never carry the ground truth (that's the point — the backend keeps
|
||||
// `traits` hidden outside `ritual_complete`), so this reads only the tell
|
||||
// *text* itself. Each tell is opaque flavor text ("the entity avoided a
|
||||
// direct question"), so there is no shared vocabulary to parse reliably —
|
||||
// instead each tell's text is hashed into a deterministic pseudo-random
|
||||
// unit value. That value's distance from 0.5 becomes the nudge strength,
|
||||
// and its side of 0.5 becomes the nudge direction. This keeps the whole
|
||||
// thing a pure, deterministic function of the tell strings (same tells in
|
||||
// the same order always produce the same belief — directly unit-testable)
|
||||
// while still "reading between the lines" the way a real player would,
|
||||
// each new tell narrowing the uncertainty band a little further.
|
||||
//
|
||||
// A successful `ritual_complete` is ground truth: it snaps the meter to
|
||||
// definitive certainty at the entity's real `revealed.alignment`, ignoring
|
||||
// every tell that came before (and any that come after, until reset).
|
||||
|
||||
export type Tell = { text: string }
|
||||
|
||||
export type RitualReveal = {
|
||||
success: boolean
|
||||
revealed: { alignment: number } | null
|
||||
}
|
||||
|
||||
export type EvilMeterState = {
|
||||
/** 0 (malevolent) .. 1 (benevolent) — current best-guess mean. */
|
||||
value: number
|
||||
/** 0 (certain) .. 1 (maximally uncertain) — half-width of the belief band. */
|
||||
spread: number
|
||||
/** true once a successful ritual has snapped this to ground truth. */
|
||||
certain: boolean
|
||||
/** number of tells folded into this belief (0 when snapped by a ritual reset). */
|
||||
sampleCount: number
|
||||
}
|
||||
|
||||
export const EVIL_METER_INITIAL_VALUE = 0.5
|
||||
export const EVIL_METER_INITIAL_SPREAD = 0.5
|
||||
|
||||
/** Spread never narrows past this from tells alone — only a ritual reveal reaches 0. */
|
||||
const MIN_SPREAD = 0.04
|
||||
/** Multiplicative narrowing applied to the spread per tell (geometric decay). */
|
||||
const SPREAD_DECAY = 0.82
|
||||
/** Nudge strength is scaled by the *current* spread, so early tells (wide
|
||||
* spread) move the needle more than late ones (narrow spread) — this is
|
||||
* what makes the belief "settle" rather than jitter forever. */
|
||||
const NUDGE_GAIN = 0.9
|
||||
|
||||
function clamp01(n: number): number {
|
||||
return Math.min(1, Math.max(0, n))
|
||||
}
|
||||
|
||||
/** Deterministic 0..1 pseudo-random value from a string (FNV-1a-ish). */
|
||||
function hashUnit(s: string): number {
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = Math.imul(h, 0x01000193)
|
||||
}
|
||||
return (h >>> 0) / 0x100000000
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds one tell into a running (value, spread) belief. Exported separately
|
||||
* from `computeEvilMeter` so the narrowing step itself is directly testable
|
||||
* in isolation from the reduce-over-history driver.
|
||||
*/
|
||||
export function applyTell(
|
||||
state: { value: number; spread: number },
|
||||
tell: Tell,
|
||||
): { value: number; spread: number } {
|
||||
const r = hashUnit(tell.text)
|
||||
const direction = r >= 0.5 ? 1 : -1
|
||||
const magnitude = Math.abs(r - 0.5) * 2 // 0..1
|
||||
const step = direction * magnitude * state.spread * NUDGE_GAIN
|
||||
return {
|
||||
value: clamp01(state.value + step),
|
||||
spread: Math.max(MIN_SPREAD, state.spread * SPREAD_DECAY),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the current evil-meter belief from the full tell history.
|
||||
*
|
||||
* - No tells yet: wide/uncertain band centered on the midpoint.
|
||||
* - Each subsequent tell narrows the spread and nudges the value.
|
||||
* - A successful `ritualComplete` overrides everything: the meter snaps to
|
||||
* the revealed alignment with zero spread ("certain"). A failed ritual
|
||||
* (`success: false`, `revealed: null`) does not snap — the tell-driven
|
||||
* belief carries on as if the ritual hadn't resolved anything.
|
||||
*/
|
||||
export function computeEvilMeter(
|
||||
tells: readonly Tell[],
|
||||
ritualComplete?: RitualReveal | null,
|
||||
): EvilMeterState {
|
||||
if (ritualComplete && ritualComplete.success && ritualComplete.revealed) {
|
||||
return {
|
||||
value: clamp01(ritualComplete.revealed.alignment),
|
||||
spread: 0,
|
||||
certain: true,
|
||||
sampleCount: tells.length,
|
||||
}
|
||||
}
|
||||
|
||||
let running = { value: EVIL_METER_INITIAL_VALUE, spread: EVIL_METER_INITIAL_SPREAD }
|
||||
for (const tell of tells) {
|
||||
running = applyTell(running, tell)
|
||||
}
|
||||
|
||||
return {
|
||||
value: running.value,
|
||||
spread: running.spread,
|
||||
certain: false,
|
||||
sampleCount: tells.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable label for the current belief, for the HUD readout. */
|
||||
export function evilMeterLabel(state: EvilMeterState): string {
|
||||
const { value, certain } = state
|
||||
if (certain) {
|
||||
return value >= 0.5 ? 'confirmed benevolent' : 'confirmed malevolent'
|
||||
}
|
||||
if (state.sampleCount === 0) return 'unknown'
|
||||
if (value >= 0.66) return 'leaning benevolent'
|
||||
if (value <= 0.34) return 'leaning malevolent'
|
||||
return 'unreadable'
|
||||
}
|
||||
53
frontend/src/lib/ghostLogBus.ts
Normal file
53
frontend/src/lib/ghostLogBus.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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()
|
||||
@@ -83,6 +83,16 @@ export type Telemetry = {
|
||||
|
||||
export type UtteranceKind = 'greeting' | 'fragment' | 'ambient' | 'reply'
|
||||
|
||||
// Entity hidden traits, per the Character Depth spec's Contract section —
|
||||
// four floats each 0.0–1.0, rolled once at mint time. `alignment`: 0 =
|
||||
// malevolent/demon, 1 = benevolent spirit.
|
||||
export type EntityTraits = {
|
||||
alignment: number
|
||||
power: number
|
||||
volatility: number
|
||||
deceptiveness: number
|
||||
}
|
||||
|
||||
export type ServerFrame =
|
||||
| { type: 'session'; id: string }
|
||||
| { type: 'pong' }
|
||||
@@ -98,6 +108,10 @@ export type ServerFrame =
|
||||
| { type: 'reply_end'; id: string; text: string }
|
||||
| { type: 'telemetry' } & Telemetry
|
||||
| { type: 'error'; code: 'rate_limited' | 'veil_crowded' | string; message: string }
|
||||
// Character Depth / Ghost Log contract frames (backend built by a separate
|
||||
// workstream — see docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md).
|
||||
| { type: 'tell'; text: string }
|
||||
| { type: 'ritual_complete'; success: boolean; revealed: EntityTraits | null }
|
||||
|
||||
// REST helpers
|
||||
export type SortOrder = 'recent' | 'contacted'
|
||||
|
||||
Reference in New Issue
Block a user