// 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' }