First implementation of 10 finger typing

This commit is contained in:
2026-09-11 21:55:45 +02:00
parent 7e5fd5ab75
commit f97de193d8
63 changed files with 8275 additions and 0 deletions

124
tippen/src/lib/engine.ts Normal file
View File

@@ -0,0 +1,124 @@
/** 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<number>;
/** 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 (Welt 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 Blasenplatzen round still earns its Perlen. */
export function abandonRun(state: RunState, now: number): RunState {
if (isFinished(state) || state.startedAt === null) return state;
return { ...state, finishedAt: now };
}