feat: possession presentation layer — glitchy text + degraded audio
First sub-project of the "make contact feel real" arc (spec: docs/superpowers/specs/2026-07-23-possession-presentation-design.md). Direct Contact replies now feel like a spirit fighting through static to hold the channel rather than a plain chat bubble: - backend/app/possession.py: compute_stability(rarity, magnitude, rng) — a 0.05-0.98 score per reply (rarer entity + stronger triggering anomaly = cleaner signal), rng injectable for a later quantum-RNG source. - ws.py sends stability on reply_start; audio synthesis for that reply gets noise/bitcrush scaled by instability (1 - stability) via a new instability param on synthesize_spirit_voice — effects.py itself is untouched, only the params fed into it. - frontend/src/lib/possession.ts: renderPossessedText — pure, deterministic (tick-seeded, no Math.random) text corruption with self-correcting glitch bursts, wired into Transcript.tsx's streaming reply display. Stored transcript/reply text is unaffected — this is presentation only. 78/78 backend, 137/137 frontend tests passing.
This commit is contained in:
91
frontend/src/lib/possession.test.ts
Normal file
91
frontend/src/lib/possession.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { GLITCH_GLYPHS, renderPossessedText } from './possession'
|
||||
|
||||
const SAMPLE = 'the veil grows thin between us tonight, listener'
|
||||
|
||||
function diffCount(a: string, b: string): number {
|
||||
let n = 0
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) n++
|
||||
return n
|
||||
}
|
||||
|
||||
describe('renderPossessedText', () => {
|
||||
it('is deterministic for a fixed (text, stability, tick)', () => {
|
||||
for (const tick of [0, 1, 7, 42, 999]) {
|
||||
const a = renderPossessedText(SAMPLE, 0.4, tick)
|
||||
const b = renderPossessedText(SAMPLE, 0.4, tick)
|
||||
expect(a).toBe(b)
|
||||
}
|
||||
})
|
||||
|
||||
it('never changes the length of the input', () => {
|
||||
for (const tick of [0, 3, 50]) {
|
||||
for (const stability of [0.05, 0.5, 0.98]) {
|
||||
expect(renderPossessedText(SAMPLE, stability, tick).length).toBe(SAMPLE.length)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('produces measurably more corrupted characters at low stability than high stability', () => {
|
||||
let lowTotal = 0
|
||||
let highTotal = 0
|
||||
for (let tick = 0; tick < 60; tick++) {
|
||||
lowTotal += diffCount(SAMPLE, renderPossessedText(SAMPLE, 0.1, tick))
|
||||
highTotal += diffCount(SAMPLE, renderPossessedText(SAMPLE, 0.95, tick))
|
||||
}
|
||||
expect(lowTotal).toBeGreaterThan(highTotal * 5)
|
||||
expect(lowTotal).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('stays very close to (or equal to) the true text at very high stability', () => {
|
||||
let total = 0
|
||||
let corrupted = 0
|
||||
for (let tick = 0; tick < 40; tick++) {
|
||||
total += SAMPLE.length
|
||||
corrupted += diffCount(SAMPLE, renderPossessedText(SAMPLE, 0.95, tick))
|
||||
}
|
||||
// Subtle flicker only: well under 5% of characters differ on average.
|
||||
expect(corrupted / total).toBeLessThan(0.05)
|
||||
})
|
||||
|
||||
it('leaves whitespace untouched at any stability', () => {
|
||||
for (const stability of [0.05, 0.3, 0.98]) {
|
||||
for (let tick = 0; tick < 20; tick++) {
|
||||
const out = renderPossessedText(SAMPLE, stability, tick)
|
||||
for (let i = 0; i < SAMPLE.length; i++) {
|
||||
if (/\s/.test(SAMPLE[i])) expect(out[i]).toBe(SAMPLE[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('only substitutes characters with known glitch glyphs or an echoed neighbour', () => {
|
||||
const allowed = new Set(GLITCH_GLYPHS as readonly string[])
|
||||
for (let tick = 0; tick < 30; tick++) {
|
||||
const out = renderPossessedText(SAMPLE, 0.05, tick)
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (out[i] === SAMPLE[i]) continue
|
||||
const isGlyph = allowed.has(out[i])
|
||||
const isEcho = i > 0 && out[i] === out[i - 1]
|
||||
expect(isGlyph || isEcho).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('never crashes on empty string input', () => {
|
||||
expect(renderPossessedText('', 0.5, 0)).toBe('')
|
||||
expect(renderPossessedText('', 0.05, 12345)).toBe('')
|
||||
expect(renderPossessedText('', 0.98, 0)).toBe('')
|
||||
})
|
||||
|
||||
it('never crashes at tick 0', () => {
|
||||
expect(() => renderPossessedText(SAMPLE, 0.05, 0)).not.toThrow()
|
||||
expect(renderPossessedText(SAMPLE, 0.05, 0).length).toBe(SAMPLE.length)
|
||||
})
|
||||
|
||||
it('clamps out-of-range stability instead of throwing', () => {
|
||||
expect(() => renderPossessedText(SAMPLE, 5, 0)).not.toThrow()
|
||||
expect(() => renderPossessedText(SAMPLE, -3, 0)).not.toThrow()
|
||||
expect(() => renderPossessedText(SAMPLE, Number.NaN, 0)).not.toThrow()
|
||||
})
|
||||
})
|
||||
125
frontend/src/lib/possession.ts
Normal file
125
frontend/src/lib/possession.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// The possession renderer: turns the true, already-streamed reply text into
|
||||
// a display string that looks like a spirit fighting through static to hold
|
||||
// the channel. Purely presentational — the stored transcript/reply text
|
||||
// (state.replyStreaming.text, and later the committed utterance) is never
|
||||
// touched; only what gets painted to the screen is glitched.
|
||||
//
|
||||
// Must stay pure and fully deterministic: same (text, stability, tick) in ->
|
||||
// same string out, every time, so it's trivially unit-testable and so two
|
||||
// renders of the same tick (e.g. StrictMode double-invoke) never visibly
|
||||
// differ. All "randomness" is a deterministic hash keyed on the tick and
|
||||
// the character index — this module never calls Math.random().
|
||||
|
||||
/**
|
||||
* Static-flavoured glyphs used for corrupted characters, matching the
|
||||
* terminal/occult vocabulary already on screen (⚡ ⟁ ▌ in Transcript.tsx).
|
||||
* Whitespace is never corrupted (see renderPossessedText) so these only
|
||||
* ever land inside words, keeping the line readable even when heavily
|
||||
* glitched.
|
||||
*/
|
||||
export const GLITCH_GLYPHS = ['█', '▓', '▒', '░', '▚', '▞', '╳', '§', '¤'] as const
|
||||
|
||||
/**
|
||||
* Deterministic 32-bit hash of (tick, index, salt) folded into [0, 1).
|
||||
* Not cryptographic — just a cheap avalanche so nearby ticks/indices don't
|
||||
* produce visibly correlated output. This is the *only* source of
|
||||
* pseudo-randomness in this module; every caller must route through it (or
|
||||
* a function that does) instead of Math.random().
|
||||
*/
|
||||
function hash01(tick: number, index: number, salt: number): number {
|
||||
let h = (tick * 374761393 + index * 668265263 + salt * 2246822519) >>> 0
|
||||
h = Math.imul(h ^ (h >>> 15), 2246822519) >>> 0
|
||||
h = Math.imul(h ^ (h >>> 13), 3266489917) >>> 0
|
||||
h ^= h >>> 16
|
||||
return (h >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
function pickGlyph(tick: number, index: number): string {
|
||||
const i = Math.floor(hash01(tick, index, 5) * GLITCH_GLYPHS.length)
|
||||
return GLITCH_GLYPHS[Math.min(GLITCH_GLYPHS.length - 1, i)]
|
||||
}
|
||||
|
||||
/** Clamp to the contract's documented stability range. */
|
||||
function clampStability(stability: number): number {
|
||||
if (Number.isNaN(stability)) return 1
|
||||
return Math.max(0.05, Math.min(0.98, stability))
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-character corruption probability for a given stability. Curved
|
||||
* (instability^1.6) so the high end (stability > ~0.85) drops off fast into
|
||||
* a barely-there flicker, while the low end climbs toward frequent,
|
||||
* sustained corruption.
|
||||
*/
|
||||
function corruptionChance(stability: number): number {
|
||||
const instability = 1 - stability
|
||||
return Math.min(0.9, instability ** 1.6 * 0.6)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many *additional* characters a corruption burst eats once it starts,
|
||||
* scaled so low stability produces longer runs of static instead of single
|
||||
* blipped characters.
|
||||
*/
|
||||
function burstSpan(stability: number, tick: number, index: number): number {
|
||||
const instability = 1 - stability
|
||||
const maxExtra = Math.round(instability * 4) // 0 (clean) .. 4 (chaos)
|
||||
if (maxExtra <= 0) return 0
|
||||
return Math.floor(hash01(tick, index, 11) * (maxExtra + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `trueText` (the accumulated streamed reply so far) as it should
|
||||
* appear on screen this frame: a mix of clean characters, glyph static, and
|
||||
* brief stutters (a character echoing the one before it), driven entirely
|
||||
* by `stability` (0.05-0.98, contract range — clamped defensively) and
|
||||
* `tick` (an incrementing frame/interval counter local to the caller).
|
||||
*
|
||||
* Same length as `trueText` always — corruption replaces characters in
|
||||
* place rather than inserting/deleting, so cursor position and layout stay
|
||||
* stable while the content glitches. Whitespace is never corrupted.
|
||||
*
|
||||
* Deterministic: calling this twice with identical arguments always
|
||||
* returns the identical string. Advancing `tick` (as the caller's ticking
|
||||
* effect does while streaming.active is true) reshuffles which characters
|
||||
* are affected, which is what makes bursts and stutters read as transient
|
||||
* and self-correcting rather than a permanently mangled line.
|
||||
*/
|
||||
export function renderPossessedText(trueText: string, stability: number, tick: number): string {
|
||||
if (!trueText) return trueText
|
||||
const s = clampStability(stability)
|
||||
const pCorrupt = corruptionChance(s)
|
||||
if (pCorrupt <= 0) return trueText
|
||||
|
||||
const t = Math.max(0, Math.floor(tick) || 0)
|
||||
const chars = trueText.split('')
|
||||
let burstRemaining = 0
|
||||
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const ch = chars[i]
|
||||
if (/\s/.test(ch)) {
|
||||
// Never corrupt whitespace — keeps words legible even at low
|
||||
// stability, and a burst never "restarts" mid-space.
|
||||
burstRemaining = 0
|
||||
continue
|
||||
}
|
||||
|
||||
let corrupt = burstRemaining > 0
|
||||
if (!corrupt) {
|
||||
corrupt = hash01(t, i, 1) < pCorrupt
|
||||
if (corrupt) burstRemaining = burstSpan(s, t, i)
|
||||
} else {
|
||||
burstRemaining--
|
||||
}
|
||||
|
||||
if (!corrupt) continue
|
||||
|
||||
// A minority of corrupted characters stutter (echo the previous
|
||||
// printable character) instead of turning to static glyph, giving the
|
||||
// "brief stutter" texture the design calls for.
|
||||
const useStutter = i > 0 && hash01(t, i, 23) < 0.35
|
||||
chars[i] = useStutter ? chars[i - 1] : pickGlyph(t, i)
|
||||
}
|
||||
|
||||
return chars.join('')
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export type ServerFrame =
|
||||
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
|
||||
| { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null }
|
||||
| { type: 'audio'; id: string; url: string }
|
||||
| { type: 'reply_start' }
|
||||
| { type: 'reply_start'; stability?: number }
|
||||
| { type: 'reply_token'; token: string }
|
||||
| { type: 'reply_end'; id: string; text: string }
|
||||
| { type: 'telemetry' } & Telemetry
|
||||
|
||||
Reference in New Issue
Block a user