Files
qtalker---/frontend/src/lib/planchette.ts
Indiana 6edbbbbc2a 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
2026-07-20 21:11:49 +00:00

159 lines
4.5 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.
// 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],
}
}
}