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:
Indiana
2026-07-23 05:48:02 +00:00
parent bf8f352910
commit 58c30b7273
14 changed files with 638 additions and 21 deletions

View 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('')
}