/** The typing engine: one keystroke in, a new state and a list of events out. * * Kept pure, the way ../../../web/src/lib/keyboard.ts keeps the player's key map pure - * so every rule below is testable without a DOM, and so the six game modes can all * drive the same logic while differing only in how they *draw* the target. * * Three rules here are deliberate choices for a six-year-old rather than the obvious * implementation, and each one is load-bearing: * * 1. A wrong key does not advance and does not insert. There is no backspace to * manage and no corrupted line to read back; the right key still has to be found. * 2. A wrong key counts once per position. Hammering the same wrong key five times in * a moment of panic is one mistake, not five, so one bad second cannot wreck a run. * 3. The clock starts on the first keystroke, not when the screen opens. Staring at * the screen, getting distracted, or being called away mid-thought is free. */ import { grade, type RunResult } from "./grading"; export interface Stroke { /** What was actually pressed, lowercased for letters. */ key: string; /** What was wanted at that position. */ expected: string; correct: boolean; /** ms timestamp, from the same clock `press` is called with. */ at: number; } export interface RunState { /** The full line being typed. */ target: string; /** How far in we are - always an index into `target`, never past its length. */ index: number; strokes: Stroke[]; /** Positions where at least one wrong key has already been counted. Rule 2. */ missed: ReadonlySet; /** Consecutive correct keys, for the streak sound and the bubble chain. */ streak: number; startedAt: number | null; finishedAt: number | null; } export type RunEvent = | { type: "correct"; key: string; index: number; streak: number } | { type: "wrong"; key: string; expected: string; index: number; firstAt: boolean } | { type: "finished"; result: RunResult }; export function startRun(target: string): RunState { return { target, index: 0, strokes: [], missed: new Set(), streak: 0, startedAt: null, finishedAt: null, }; } /** Keys that are never typing input: pressing Shift to reach a capital must not count * as a stroke of its own, and neither must a stray Alt or a browser shortcut's Meta. */ const MODIFIERS = new Set(["Shift", "Control", "Alt", "AltGraph", "Meta", "CapsLock"]); /** Is this a key the engine should look at at all? Anything longer than one code point * is a named key ("Enter", "ArrowLeft", "F1") and belongs to the app, not the run. * Backspace is swallowed on purpose: rule 1 means there is nothing to delete. */ export function isTypingKey(key: string): boolean { if (MODIFIERS.has(key)) return false; return [...key].length === 1; } export function isFinished(state: RunState): boolean { return state.finishedAt !== null; } export function currentChar(state: RunState): string | null { return state.target[state.index] ?? null; } /** Apply one keystroke. Returns the state unchanged (and no events) for anything that * is not typing input, or once the run is over, so the caller can stay dumb. */ export function press(state: RunState, key: string, now: number): [RunState, RunEvent[]] { if (isFinished(state) || !isTypingKey(key)) return [state, []]; const expected = state.target[state.index]; if (expected === undefined) return [state, []]; // The layout is what decides case, not the run: typing "A" where "a" is wanted is // correct. Capitals are their own lesson (world 4), and that lesson's target text // carries the capital, so this comparison still teaches Shift where it matters. const correct = key.toLowerCase() === expected.toLowerCase(); const startedAt = state.startedAt ?? now; const stroke: Stroke = { key, expected, correct, at: now }; const strokes = [...state.strokes, stroke]; if (!correct) { const firstAt = !state.missed.has(state.index); const missed = firstAt ? new Set(state.missed).add(state.index) : state.missed; const next: RunState = { ...state, strokes, missed, streak: 0, startedAt }; return [next, [{ type: "wrong", key, expected, index: state.index, firstAt }]]; } const index = state.index + 1; const streak = state.streak + 1; const done = index >= state.target.length; const next: RunState = { ...state, index, strokes, streak, startedAt, finishedAt: done ? now : null, }; const events: RunEvent[] = [{ type: "correct", key, index: state.index, streak }]; if (done) events.push({ type: "finished", result: grade(next) }); return [next, events]; } /** Give up on the rest of the line - what Escape does. The run is still graded on what * was typed, so a half-finished bubbles round still earns its pearls. */ export function abandonRun(state: RunState, now: number): RunState { if (isFinished(state) || state.startedAt === null) return state; return { ...state, finishedAt: now }; }