feat: complete Quantumancy web app — full frontend + docs

Frontend (React 18 + TS + Vite):
- Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits
- Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive),
  Ouija planchette board spelling utterances, transcript with TTS replay,
  entity dossier, direct contact streaming, passive/active listening
- Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB
  RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact
- Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete
- State: VeilSocket (reconnect/backoff), seance reducer, auth context
- 72 vitest tests green; served by FastAPI at :7777

Docs: README + as-built plans 3-7
This commit is contained in:
Indiana
2026-07-20 21:11:49 +00:00
parent 1ec9ea5863
commit 6edbbbbc2a
48 changed files with 10494 additions and 168 deletions

View File

@@ -0,0 +1,158 @@
// Planchette word-queue / spelling state machine.
//
// The Ouija board spells out spirit utterances letter by letter. This module
// is the pure logic: queue words, step through letters at a fixed cadence,
// and report which character the planchette is currently hovering over.
// Rendering (canvas, DOM) lives elsewhere; this is unit-tested in isolation.
export type PlanchettePhase = 'idle' | 'moving' | 'dwelling' | 'returning'
export type PlanchetteSnapshot = {
phase: PlanchettePhase
/** Character currently hovered, or null when drifting idle. */
current: string | null
/** The full normalized word being spelled, or null. */
word: string | null
/** Index of `current` within `word`. */
index: number
/** Words still waiting to be spelled. */
queued: readonly string[]
/** Words already spelled this session (most recent last). */
spelled: readonly string[]
}
export type PlanchetteOptions = {
/** Milliseconds spent on each letter. Default 300. */
msPerLetter?: number
/** Milliseconds paused between words. Default 700. */
msBetweenWords?: number
/** Cap on the internal queue so a flood of utterances cannot wedge it. */
maxQueue?: number
}
/** Keep A–Z, 0–9 and spaces; uppercase; drop everything else. */
export function normalizeWord(text: string): string {
return text
.toUpperCase()
.replace(/[^A-Z0-9 ]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
/**
* Split an utterance into queueable word tokens: punctuation stripped,
* whitespace-split, empties removed.
*/
export function tokenize(text: string): string[] {
const norm = normalizeWord(text)
if (!norm) return []
return norm.split(' ').filter((w) => w.length > 0)
}
export class PlanchetteMachine {
private queue: string[] = []
private done: string[] = []
private word: string | null = null
private index = 0
private phase: PlanchettePhase = 'idle'
private clock = 0
private readonly msPerLetter: number
private readonly msBetweenWords: number
private readonly maxQueue: number
private version = 0
constructor(opts: PlanchetteOptions = {}) {
this.msPerLetter = opts.msPerLetter ?? 300
this.msBetweenWords = opts.msBetweenWords ?? 700
this.maxQueue = opts.maxQueue ?? 64
}
/** Monotonic counter bumped on every mutation — handy for render loops. */
getVersion(): number {
return this.version
}
/** Enqueue an utterance (may contain several words + punctuation). */
enqueue(text: string): void {
const words = tokenize(text)
for (const w of words) {
if (this.queue.length < this.maxQueue) this.queue.push(w)
}
if (words.length > 0) this.version++
}
clear(): void {
this.queue = []
this.word = null
this.index = 0
this.phase = 'idle'
this.clock = 0
this.version++
}
/**
* Advance the machine by `dtMs`. Call from a rAF loop or a timer.
* Returns a snapshot after advancing.
*/
tick(dtMs: number): PlanchetteSnapshot {
this.clock += dtMs
if (this.phase === 'idle') {
if (this.queue.length > 0) {
this.word = this.queue.shift() ?? null
this.index = 0
this.phase = this.word && this.word.length > 0 ? 'moving' : 'idle'
this.clock = 0
this.version++
}
return this.snapshot()
}
if (this.phase === 'returning') {
if (this.clock >= this.msBetweenWords) {
this.clock = 0
this.phase = 'idle'
this.word = null
this.index = 0
this.version++
}
return this.snapshot()
}
// moving / dwelling: advance letters on the per-letter cadence.
if (this.word) {
while (this.clock >= this.msPerLetter && this.phase !== 'returning') {
this.clock -= this.msPerLetter
this.index++
this.version++
if (this.index >= this.word.length) {
this.done.push(this.word)
if (this.done.length > 128) this.done.shift()
this.phase = 'returning'
this.clock = 0
} else {
this.phase = this.clock >= this.msPerLetter ? 'moving' : 'dwelling'
}
}
if (this.phase === 'dwelling' || this.phase === 'moving') {
this.phase = 'dwelling'
}
}
return this.snapshot()
}
snapshot(): PlanchetteSnapshot {
const active =
(this.phase === 'moving' || this.phase === 'dwelling') && this.word
? this.word
: null
return {
phase: this.phase,
current: active ? active[this.index] ?? null : null,
word: active,
index: active ? this.index : 0,
queued: [...this.queue],
spelled: [...this.done],
}
}
}