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>
133 lines
4.9 KiB
TypeScript
133 lines
4.9 KiB
TypeScript
// 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'
|
|
}
|