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>
170 lines
6.3 KiB
TypeScript
170 lines
6.3 KiB
TypeScript
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')
|
|
})
|
|
})
|