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