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