// 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], } } }