Pearls were earned every run and spent on nothing: the aquarium fills up by finishing worlds, not by paying for it. A counter that only ever goes up is one more stat competing for attention on the result sheet and the home screen, and one more field to carry through the run payload, the progress file and both test suites. Stars and the animal ladder already say how a run went, so nothing is lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
4.9 KiB
TypeScript
125 lines
4.9 KiB
TypeScript
/** 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 (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 stars. */
|
|
export function abandonRun(state: RunState, now: number): RunState {
|
|
if (isFinished(state) || state.startedAt === null) return state;
|
|
return { ...state, finishedAt: now };
|
|
}
|