Files
qtalker---/frontend/src/lib/possession.ts
Indiana 58c30b7273 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.
2026-07-23 05:48:02 +00:00

126 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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('')
}