Merge the typing game into the music player as a tab, with lock/unlock UI

Moves tippen from a standalone app into web/ as a third tab (audio player /
smarthome / typing), replacing the old single room-toggle corner button with a
vertical icon tab rail. Curriculum and progress now come from the backend
(musicmouse/tippen/*) instead of a build-time YAML import and localStorage.

Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question
mark for locked albums/tracks with a hint on what unlocks them, and
ResultSheet gets a new unlock-animation block alongside the existing
lesson-unlock and aquarium-creature celebrations.

CSS from the two apps is merged carefully: identical rules (bubble/card/
key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is,
while rules that bake in each app's own hue are kept separate under a
`tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so
neither app's look bleeds into the other's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 21:25:42 +02:00
parent f7a5d24d8d
commit 7f5e2733c2
84 changed files with 1577 additions and 4642 deletions

View File

@@ -28,7 +28,8 @@ function album(over: Partial<Album> = {}): Album {
colors: ["#111111", "#222222", "#333333"],
has_cover: false,
duration: 120,
tracks: [{ title: "Lied", duration: 60, analysis: null }],
locked: false,
tracks: [{ title: "Lied", duration: 60, analysis: null, locked: false, unlock_hint: null }],
...over,
};
}

View File

@@ -17,9 +17,10 @@ function album(id: string, over: Partial<Album> = {}): Album {
colors: ["#111111", "#222222", "#333333"],
has_cover: false,
duration: 120,
locked: false,
tracks: [
{ title: `Lied ${id}`, duration: 60, analysis: null },
{ title: "Zweites Lied", duration: 60, analysis: null },
{ title: `Lied ${id}`, duration: 60, analysis: null, locked: false, unlock_hint: null },
{ title: "Zweites Lied", duration: 60, analysis: null, locked: false, unlock_hint: null },
],
...over,
};

View File

@@ -16,6 +16,7 @@ function album(id: string, over: Partial<Album> = {}): Album {
colors: ["#111111", "#222222", "#333333"],
has_cover: false,
duration: 120,
locked: false,
tracks: [],
...over,
};

View File

@@ -25,8 +25,8 @@ export interface UiState {
* that row", the way it means "which flat position" everywhere else. */
shelfRow: number;
/** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below,
* so toggling to the room and back leaves the music side exactly as it was. */
page: "music" | "room";
* so switching pages and back leaves the music side exactly as it was. */
page: "music" | "room" | "typing";
view: "browse" | "play";
openAlbumId: string | null;
/** Which track is highlighted in the open album's track list. Only meaningful while
@@ -232,6 +232,12 @@ export function handleKey(
): Action[] {
const { key } = event;
// The typing tab is a second app mounted alongside this one (TippenApp.tsx) with its
// own complete keyboard handling, including its own Escape/F1 bindings. It must own
// every key while active - even Shift+media and Space below, which would otherwise
// hijack a key a lesson happens to be drilling (see TippenApp.tsx's own doc comment).
if (state.page === "typing") return [];
if (event.shiftKey && !event.ctrlKey && !event.metaKey) {
const media = SHIFT_MEDIA[key.toUpperCase()];
if (media) return media;

View File

@@ -114,6 +114,9 @@ export function songMatches(query: BrowseQuery): SongHit[] {
const hits: SongHit[] = [];
for (const album of pool(query.albums, query.group)) {
album.tracks.forEach((track, index) => {
// A locked track has no real title to search by - it shows as a question mark
// wherever it appears, so it has nothing useful to match here either.
if (track.locked) return;
if (!words.length || matchesWords(normalize(track.title), words)) {
hits.push({ album, index, title: track.title, duration: track.duration });
}

View File

@@ -12,6 +12,7 @@
import type { CSSProperties } from "react";
import type { Group } from "./search";
import type { UiState } from "./keyboard";
// ---------------------------------------------------------------- toggles --
@@ -74,6 +75,21 @@ export const GROUP_LABEL: Record<Group, string> = {
podcasts: "Podcasts",
};
/** Icon/label for the vertical tab rail - one entry per `UiState["page"]`. The
* smarthome tab only renders when Home Assistant is configured (see `App.tsx`); the
* other two always show. */
export const PAGE_ICON: Record<UiState["page"], string> = {
music: "🎵",
room: "💡",
typing: "⌨️",
};
export const PAGE_LABEL: Record<UiState["page"], string> = {
music: "Musik",
room: "Mein Zimmer",
typing: "Tippen",
};
/** A shelf/row's frosted background and border, tinted with its group's hue - or,
* with `SHOW_ROW_TINT` off, `{}` so `.glass-panel`'s own neutral CSS shows through. */
export function glassTint(hue: number): CSSProperties {

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { CREATURES, creatureFromRaw, creatureById, createSwimmer, pose, stepSwimmer } from "../aquarium";
import type { Tank, Swimmer } from "../aquarium";
import { mulberry32 } from "../generator";
const TANK: Tank = { width: 1280, height: 800 };
const MARGIN = 60;
const SPEED = 55;
const DT = 1 / 60;
function swim(s: Swimmer, seconds: number, tank = TANK, rng = mulberry32(7)): Swimmer {
for (let t = 0; t < seconds; t += DT) s = stepSwimmer(s, DT, tank, MARGIN, SPEED, rng);
return s;
}
describe("Creatures", () => {
it("looks pets up by id", () => {
for (const creature of CREATURES) expect(creatureById(creature.id)).toBe(creature);
});
it("reads today's ids and the emoji old saves stored, and nothing else", () => {
expect(creatureFromRaw("octopus")).toBe("octopus");
expect(creatureFromRaw("🐠")).toBe("clownfish");
expect(creatureFromRaw("🧜")).toBe("pearlmussel");
expect(creatureFromRaw("🦈")).toBeNull();
expect(creatureFromRaw(42)).toBeNull();
});
});
describe("stepSwimmer", () => {
it("never lets a pet's centre leave the tank", () => {
const rng = mulberry32(3);
let s = createSwimmer(TANK, MARGIN, rng);
// Ten minutes of swimming, checked every frame.
for (let t = 0; t < 600; t += DT) {
s = stepSwimmer(s, DT, TANK, MARGIN, SPEED, rng);
expect(s.x).toBeGreaterThanOrEqual(0);
expect(s.x).toBeLessThanOrEqual(TANK.width);
expect(s.y).toBeGreaterThanOrEqual(0);
expect(s.y).toBeLessThanOrEqual(TANK.height);
}
});
it("keeps moving rather than settling", () => {
const rng = mulberry32(11);
const start = createSwimmer(TANK, MARGIN, rng);
const later = swim(start, 60, TANK, rng);
expect(Math.hypot(later.x - start.x, later.y - start.y)).toBeGreaterThan(0);
expect(later.targetX !== start.targetX || later.targetY !== start.targetY).toBe(true);
});
it("turns to face the way it swims", () => {
const base: Swimmer = { x: 640, y: 400, vx: 0, vy: 0, targetX: 100, targetY: 400, facing: 1, age: 0 };
const left = swim(base, 3);
expect(left.vx).toBeLessThan(0);
expect(left.facing).toBeLessThan(-0.9);
const right = swim({ ...base, targetX: 1180, facing: -1 }, 3);
expect(right.vx).toBeGreaterThan(0);
expect(right.facing).toBeGreaterThan(0.9);
});
it("brings a newly earned pet in from outside the tank", () => {
const rng = mulberry32(5);
const fresh = createSwimmer(TANK, MARGIN, rng, true);
expect(fresh.x < 0 || fresh.x > TANK.width).toBe(true);
const inside = swim(fresh, 40, TANK, rng);
expect(inside.x).toBeGreaterThan(0);
expect(inside.x).toBeLessThan(TANK.width);
});
it("finds a new target when the window shrinks under the old one", () => {
const s: Swimmer = { x: 200, y: 200, vx: 0, vy: 0, targetX: 1200, targetY: 700, facing: 1, age: 0 };
const small = { width: 500, height: 400 };
const next = stepSwimmer(s, DT, small, MARGIN, SPEED, mulberry32(1));
expect(next.targetX).toBeLessThanOrEqual(small.width - MARGIN);
expect(next.targetY).toBeLessThanOrEqual(small.height - MARGIN);
});
});
describe("pose", () => {
const s: Swimmer = { x: 10, y: 20, vx: -30, vy: 0, targetX: 0, targetY: 0, facing: -1, age: 0 };
it("mirrors side-view pets to look where they swim", () => {
expect(pose(s, creatureById("clownfish"), SPEED).mirror).toBe(-1);
});
it("never mirrors a front-view pet", () => {
expect(pose(s, creatureById("octopus"), SPEED).mirror).toBe(1);
expect(pose(s, creatureById("pearlmussel"), SPEED).mirror).toBe(1);
});
});

View File

@@ -0,0 +1,93 @@
/** What's left client-side of the old curriculum.test.ts, now that the lesson plan's
* content lives in the backend (see musicmouse/tippen/curriculum.py and its own tests
* against the real curriculum file) - just `fromApi`'s wire-to-app-shape conversion and
* the navigation helpers. */
import { describe, expect, it } from "vitest";
import type { TippenCurriculum as ApiCurriculum, TippenLesson as ApiLesson } from "../../../api/types";
import { firstLessonId, fromApi, lessonById, nextLesson } from "../curriculum";
function apiLesson(over: Partial<ApiLesson> = {}): ApiLesson {
return {
id: "l01",
world: 1,
number: 1,
title: "F und J",
subtitle: "Die Zeigefinger",
kind: "letters",
new_keys: ["f", "j"],
spotlight_keys: ["f", "j"],
emphasis: "isolated",
active_keys: ["f", "j"],
primary_mode: "bubbles",
bonus_modes: [],
words: [],
is_drill: false,
chunks: 24,
chunk_size: 3,
reward: { resolved: false, album_id: null, has_cover: false, kind: null },
...over,
};
}
const API: ApiCurriculum = {
worlds: [{ number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" }],
lessons: [apiLesson({ id: "l01", number: 1 }), apiLesson({ id: "l02", number: 2, new_keys: [] })],
};
describe("fromApi", () => {
it("converts snake_case wire fields to the app's own camelCase shape", () => {
const curriculum = fromApi(API);
expect(curriculum.worlds).toEqual([
{ number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" },
]);
const [lesson] = curriculum.lessons;
expect(lesson).toMatchObject({
id: "l01",
newKeys: ["f", "j"],
spotlightKeys: ["f", "j"],
activeKeys: ["f", "j"],
primaryMode: "bubbles",
bonusModes: [],
isDrill: false,
chunkSize: 3,
});
});
it("carries a resolved reward through unchanged", () => {
const api: ApiCurriculum = {
worlds: API.worlds,
lessons: [
apiLesson({
reward: { resolved: true, album_id: "abc123", has_cover: true, kind: "tracks" },
}),
],
};
expect(fromApi(api).lessons[0]!.reward).toEqual({
resolved: true,
albumId: "abc123",
hasCover: true,
kind: "tracks",
});
});
});
describe("navigation", () => {
const curriculum = fromApi(API);
it("chains every lesson to the next and stops at the end", () => {
expect(nextLesson(curriculum, "l01")?.id).toBe("l02");
expect(nextLesson(curriculum, "l02")).toBeNull();
expect(nextLesson(curriculum, "nope")).toBeNull();
});
it("looks lessons up by id", () => {
expect(lessonById(curriculum, "l01")?.number).toBe(1);
expect(lessonById(curriculum, "nope")).toBeNull();
});
it("names the first lesson", () => {
expect(firstLessonId(curriculum)).toBe("l01");
});
});

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { abandonRun, isTypingKey, press, startRun } from "../engine";
import type { RunState } from "../engine";
/** Type a whole string, one key per 100ms, and hand back the final state. */
function typeAll(target: string, keys: string, from = 1000): RunState {
let state = startRun(target);
[...keys].forEach((key, i) => {
[state] = press(state, key, from + i * 100);
});
return state;
}
describe("press", () => {
it("advances on the right key", () => {
const [state, events] = press(startRun("asdf"), "a", 0);
expect(state.index).toBe(1);
expect(events).toEqual([{ type: "correct", key: "a", index: 0, streak: 1 }]);
});
it("does not advance on the wrong key", () => {
const [state, events] = press(startRun("asdf"), "x", 0);
expect(state.index).toBe(0);
expect(events[0]).toMatchObject({ type: "wrong", key: "x", expected: "a", firstAt: true });
});
it("counts a repeated wrong key at one position only once", () => {
const state = typeAll("asdf", "xxxxx");
expect(state.missed.size).toBe(1);
expect(state.strokes).toHaveLength(5);
});
it("counts wrong keys at different positions separately", () => {
const state = typeAll("asdf", "xaysz");
expect(state.missed.size).toBe(3);
});
it("accepts a capital where a lowercase letter is wanted", () => {
const [state] = press(startRun("asdf"), "A", 0);
expect(state.index).toBe(1);
});
it("ignores modifiers and named keys", () => {
const start = startRun("asdf");
for (const key of ["Shift", "Control", "Backspace", "ArrowLeft", "F1", "Enter"]) {
const [state, events] = press(start, key, 0);
expect(state).toBe(start);
expect(events).toEqual([]);
}
});
it("starts the clock on the first keystroke, not before", () => {
const fresh = startRun("as");
expect(fresh.startedAt).toBeNull();
const [state] = press(fresh, "a", 5000);
expect(state.startedAt).toBe(5000);
});
it("finishes on the last character and reports a result", () => {
let state = startRun("as");
[state] = press(state, "a", 0);
const [done, events] = press(state, "s", 1000);
expect(done.finishedAt).toBe(1000);
const finished = events.find((event) => event.type === "finished");
expect(finished).toBeDefined();
expect(finished?.type === "finished" && finished.result.characters).toBe(2);
});
it("does nothing once the run is over", () => {
const done = typeAll("as", "as");
const [state, events] = press(done, "a", 9999);
expect(state).toBe(done);
expect(events).toEqual([]);
});
it("tracks and resets the streak", () => {
expect(typeAll("asdf", "asd").streak).toBe(3);
expect(typeAll("asdf", "asx").streak).toBe(0);
});
});
describe("isTypingKey", () => {
it("accepts single characters including umlauts and space", () => {
for (const key of ["a", "ö", "ü", "ß", " ", "A"]) expect(isTypingKey(key)).toBe(true);
});
it("rejects named keys", () => {
for (const key of ["Enter", "Shift", "Tab", "ArrowUp"]) expect(isTypingKey(key)).toBe(false);
});
});
describe("abandonRun", () => {
it("grades what was typed so far", () => {
const partial = typeAll("asdfjklö", "asdf");
const stopped = abandonRun(partial, 2000);
expect(stopped.finishedAt).toBe(2000);
expect(stopped.index).toBe(4);
});
it("leaves an untouched run alone", () => {
const fresh = startRun("asdf");
expect(abandonRun(fresh, 100)).toBe(fresh);
});
});

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
FINGERS,
HOME_ROW,
KEYBOARD_ROWS,
fingerOf,
handOf,
homeKeyOf,
keysInRow,
rowOf,
shiftHandFor,
} from "../fingers";
const ALPHABET = "abcdefghijklmnopqrstuvwxyzäöüß";
describe("fingerOf", () => {
it("maps every German letter to exactly one finger", () => {
for (const key of ALPHABET) expect(fingerOf(key), key).not.toBeNull();
});
it("is case-insensitive", () => {
for (const key of ALPHABET) {
// "ß".toUpperCase() is "SS" - two characters, and not a key. `event.key` never
// reports that, so the single-character case is the one that has to hold.
const upper = key.toUpperCase();
if ([...upper].length !== 1) continue;
expect(fingerOf(upper)?.id, upper).toBe(fingerOf(key)?.id);
}
});
it("returns null for keys that are not on the layout", () => {
for (const key of ["Enter", "F1", "€", ""]) expect(fingerOf(key)).toBeNull();
});
it("assigns the home row to the eight home fingers, left to right", () => {
const expected = [
"left-pinky",
"left-ring",
"left-middle",
"left-index",
"right-index",
"right-middle",
"right-ring",
"right-pinky",
];
HOME_ROW.forEach((key, i) => expect(fingerOf(key)?.id).toBe(expected[i]));
});
it("gives each finger the home key it actually rests on", () => {
for (const key of HOME_ROW) expect(homeKeyOf(key)).toBe(key);
expect(homeKeyOf(" ")).toBe(" ");
});
it("sends the two index fingers to their stretch keys", () => {
for (const key of "rtfgvb") expect(fingerOf(key)?.id).toBe("left-index");
for (const key of "zuhjnm") expect(fingerOf(key)?.id).toBe("right-index");
});
});
describe("hands", () => {
it("splits the letters into two disjoint, non-empty sets", () => {
const left = [...ALPHABET].filter((key) => handOf(key) === "left");
const right = [...ALPHABET].filter((key) => handOf(key) === "right");
expect(left.length).toBeGreaterThan(0);
expect(right.length).toBeGreaterThan(0);
expect(left.length + right.length).toBe(ALPHABET.length);
expect(left.some((key) => right.includes(key))).toBe(false);
});
it("shifts with the opposite hand", () => {
expect(shiftHandFor("a")).toBe("right");
expect(shiftHandFor("l")).toBe("left");
expect(shiftHandFor("Enter")).toBeNull();
});
});
describe("rows", () => {
it("places every letter in a row", () => {
for (const key of ALPHABET) expect(rowOf(key), key).not.toBeNull();
});
it("has the home row in the home row", () => {
for (const key of HOME_ROW) expect(rowOf(key)).toBe("home");
});
it("draws three rows, each with keys", () => {
expect(KEYBOARD_ROWS).toHaveLength(3);
for (const row of KEYBOARD_ROWS) expect(keysInRow(row).length).toBeGreaterThan(0);
});
});
describe("FINGERS", () => {
it("gives every finger a distinct hue so the colours can be named", () => {
const hues = Object.values(FINGERS).map((finger) => finger.hue);
expect(new Set(hues).size).toBe(hues.length);
});
it("keeps each finger's id and key consistent", () => {
for (const [id, finger] of Object.entries(FINGERS)) expect(finger.id).toBe(id);
});
});

View File

@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import type { Lesson } from "../curriculum";
import {
chunkOffsets,
drillChunks,
letterStream,
lineFor,
lineText,
mulberry32,
wordChunks,
} from "../generator";
const keys = ["a", "s", "d", "f"];
function lesson(over: Partial<Lesson> = {}): Lesson {
return {
id: "l01",
world: 1,
number: 1,
title: "Test",
subtitle: "",
kind: "letters",
newKeys: [],
spotlightKeys: [],
emphasis: null,
activeKeys: keys,
primaryMode: "dive",
bonusModes: [],
words: [],
isDrill: false,
chunks: 10,
chunkSize: 4,
reward: { resolved: false, albumId: null, hasCover: false, kind: null },
...over,
};
}
// A small spread of shapes - a plain letters lesson, one with real words, and one
// whose active-key set differs from the others - standing in for the real curriculum's
// variety without depending on its (now backend-fetched) content.
const FIXTURE_LESSONS: readonly Lesson[] = [
lesson({ id: "l01", number: 1, activeKeys: ["a", "s"] }),
lesson({ id: "l02", number: 2, activeKeys: ["a", "s", "d", "f"] }),
lesson({
id: "l03",
number: 3,
kind: "words",
activeKeys: [..."asdfjklö"],
words: ["das", "sass", "fass"],
}),
];
describe("mulberry32", () => {
it("is deterministic for a seed and different across seeds", () => {
expect(drillChunks(keys, mulberry32(7))).toEqual(drillChunks(keys, mulberry32(7)));
expect(drillChunks(keys, mulberry32(7))).not.toEqual(drillChunks(keys, mulberry32(8)));
});
it("stays in [0, 1)", () => {
const rng = mulberry32(3);
for (let i = 0; i < 1000; i++) {
const value = rng();
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(1);
}
});
});
describe("drillChunks", () => {
it("only ever emits active keys", () => {
for (const fixture of FIXTURE_LESSONS) {
const chunks = drillChunks(fixture.activeKeys, mulberry32(fixture.number), { chunks: 20 });
const active = new Set(fixture.activeKeys);
for (const char of chunks.join("")) expect(active.has(char)).toBe(true);
}
});
it("honours the requested shape", () => {
const chunks = drillChunks(keys, mulberry32(1), { chunks: 7, chunkSize: 3 });
expect(chunks).toHaveLength(7);
for (const chunk of chunks) expect(chunk).toHaveLength(3);
});
it("over-represents the focus key once there are enough keys to spare", () => {
const many = [..."asdfjklöei"];
const plain = drillChunks(many, mulberry32(42), { chunks: 200 }).join("");
const focused = drillChunks(many, mulberry32(42), { chunks: 200, focusKey: "e" }).join("");
const count = (text: string) => [...text].filter((char) => char === "e").length;
expect(count(focused)).toBeGreaterThan(count(plain));
});
it("never lets one key take over a line", () => {
// The failure this guards against: on a fresh profile every key looks equally
// unpractised, and an unchecked focus weight drilled `a` for half of lesson 1
// while three other fingers went untrained.
for (const set of [[..."asdf"], [..."asdfjklö"], [..."asdfjklöei"]]) {
for (const focusKey of set) {
const text = drillChunks(set, mulberry32(3), { chunks: 200, focusKey }).join("");
const share = [...text].filter((char) => char === focusKey).length / text.length;
expect(share, `${focusKey} in ${set.join("")}`).toBeLessThanOrEqual(0.35);
}
}
});
it("spreads a small key set evenly - every finger gets a turn", () => {
const text = drillChunks(keys, mulberry32(11), { chunks: 200, focusKey: "a" }).join("");
for (const key of keys) {
const share = [...text].filter((char) => char === key).length / text.length;
expect(share, key).toBeGreaterThan(0.15);
}
});
it("gives every finger a turn inside a single line, not just on average", () => {
// The bag draw exists for exactly this. A real sampled line came out as
// `saadaaafasaassfssffsafsf` - one `d` in twenty-four characters - which is
// acceptable averaged over a hundred lines and useless for the one line she types.
for (let seed = 0; seed < 60; seed++) {
const text = drillChunks(keys, mulberry32(seed), { chunks: 6, chunkSize: 4 }).join("");
for (const key of keys) {
const count = [...text].filter((char) => char === key).length;
expect(count, `"${key}" in "${text}" (seed ${seed})`).toBeGreaterThanOrEqual(4);
}
}
});
it("still varies the order between seeds", () => {
const a = drillChunks(keys, mulberry32(1), { chunks: 6 }).join("");
const b = drillChunks(keys, mulberry32(2), { chunks: 6 }).join("");
expect(a).not.toBe(b);
});
it("ignores a focus key that is not active, rather than looping", () => {
const chunks = drillChunks(keys, mulberry32(1), { chunks: 5, focusKey: "z" });
expect(chunks.join("")).not.toContain("z");
});
it("returns nothing when there is nothing to type", () => {
expect(drillChunks([], mulberry32(1))).toEqual([]);
expect(drillChunks([" "], mulberry32(1))).toEqual([]);
});
it("never emits a space inside a chunk", () => {
const withSpace = ["a", "s", " "];
expect(drillChunks(withSpace, mulberry32(5), { chunks: 20 }).join("")).not.toContain(" ");
});
});
describe("wordChunks", () => {
it("returns null when a lesson has no words yet", () => {
expect(wordChunks([], mulberry32(1))).toBeNull();
});
it("draws only from the given list", () => {
const words = ["die", "ei", "elf"];
const chunks = wordChunks(words, mulberry32(2), { chunks: 10 })!;
for (const chunk of chunks) expect(words).toContain(chunk);
});
it("survives a single-word list", () => {
expect(wordChunks(["ei"], mulberry32(1), { chunks: 4 })).toEqual(["ei", "ei", "ei", "ei"]);
});
it("uses every word once before repeating any, across a long round", () => {
// A real ten-sentence round from a four-sentence list showed one sentence four times.
const words = ["a1", "b2", "c3", "d4", "e5", "f6", "g7", "h8"];
for (let seed = 0; seed < 40; seed++) {
const round = wordChunks(words, mulberry32(seed), { chunks: 8 })!;
expect(new Set(round).size, `seed ${seed}: ${round.join(" ")}`).toBe(8);
}
});
it("never puts the same word twice in a row, even across a bag refill", () => {
for (let seed = 0; seed < 60; seed++) {
const round = wordChunks(["eins", "zwei", "drei"], mulberry32(seed), { chunks: 30 })!;
for (let i = 1; i < round.length; i++) {
expect(round[i], `seed ${seed} at ${i}: ${round.join(" ")}`).not.toBe(round[i - 1]);
}
}
});
});
describe("lineFor", () => {
it("prefers real words once a lesson has them", () => {
const withWords = FIXTURE_LESSONS.find((l) => l.words.length > 0)!;
const chunks = lineFor(withWords, mulberry32(1));
for (const chunk of chunks) expect(withWords.words).toContain(chunk);
});
it("falls back to letters for a lesson with no words", () => {
const noWords = FIXTURE_LESSONS[0]!;
const chunks = lineFor(noWords, mulberry32(1));
expect(chunks.length).toBeGreaterThan(0);
for (const char of chunks.join("")) expect(noWords.activeKeys).toContain(char);
});
it("can be asked for letters even in a word lesson", () => {
const withWords = FIXTURE_LESSONS.find((l) => l.words.length > 0)!;
const chunks = lineFor(withWords, mulberry32(1), { preferWords: false, chunks: 4 });
for (const chunk of chunks) expect(withWords.words).not.toContain(chunk);
});
});
describe("lineText and chunkOffsets", () => {
it("joins with spaces only once the space bar is taught", () => {
expect(lineText(["as", "df"], true)).toBe("as df");
expect(lineText(["as", "df"], false)).toBe("asdf");
});
it("points each chunk at its own first character", () => {
const chunks = ["as", "df", "jk"];
for (const spaced of [true, false]) {
const text = lineText(chunks, spaced);
chunkOffsets(chunks, spaced).forEach((offset, i) => {
expect(text.slice(offset, offset + chunks[i]!.length)).toBe(chunks[i]);
});
}
});
});
describe("letterStream", () => {
it("produces one active letter per bubble", () => {
const stream = letterStream(keys, mulberry32(9), 25);
expect(stream).toHaveLength(25);
for (const letter of stream) expect(keys).toContain(letter);
});
it("shows every active letter across a round of bubbles", () => {
const stream = letterStream(keys, mulberry32(9), 20);
for (const key of keys) expect(stream).toContain(key);
});
it("is empty when there is nothing to type", () => {
expect(letterStream([], mulberry32(1), 10)).toEqual([]);
});
});

View File

@@ -0,0 +1,184 @@
import { describe, expect, it } from "vitest";
import { press, startRun } from "../engine";
import type { RunState } from "../engine";
import {
ANIMALS,
SURPRISE_FROM,
grade,
isBetter,
isPassed,
visibleAnimals,
starsFor,
animalFor,
animalIndex,
animalProgress,
} from "../grading";
/** A run of `target` where `wrong` positions get one wrong key first, paced so the whole
* run takes exactly `durationMs`. */
function run(target: string, wrongAt: number[] = [], durationMs = 60000): RunState {
let state = startRun(target);
const steps = target.length + wrongAt.length;
const tick = durationMs / Math.max(1, steps - 1);
let t = 0;
for (let i = 0; i < target.length; i++) {
if (wrongAt.includes(i)) {
[state] = press(state, target[i] === "x" ? "q" : "x", t);
t += tick;
}
[state] = press(state, target[i]!, t);
t += tick;
}
return state;
}
describe("grade", () => {
it("measures characters per minute over exactly one minute", () => {
const result = grade(run("a".repeat(60)));
expect(result.characters).toBe(60);
expect(result.speed).toBeCloseTo(60, 0);
expect(result.accuracy).toBe(1);
});
it("weights accuracy cubically", () => {
// 90 correct, 10 wrong -> 90% accuracy, so 0.9^3 = 0.729 of the raw speed survives.
const result = grade(run("a".repeat(90), Array.from({ length: 10 }, (_, i) => i)));
expect(result.accuracy).toBeCloseTo(0.9, 2);
expect(result.points / result.speed).toBeCloseTo(0.729, 3);
});
it("never produces a negative score, however bad the run", () => {
const result = grade(run("asdf", [0, 1, 2, 3]));
expect(result.points).toBeGreaterThanOrEqual(0);
});
it("does not divide by zero on an instant run", () => {
let state = startRun("a");
[state] = press(state, "a", 1000);
const result = grade(state);
expect(Number.isFinite(result.speed)).toBe(true);
expect(result.speed).toBeLessThanOrEqual(60);
});
it("counts a hammered wrong key once", () => {
let state = startRun("as");
for (const key of ["x", "x", "x", "a", "s"]) [state] = press(state, key, 0);
expect(grade(state).errors).toBe(1);
});
it("awards pearls even for a bad run", () => {
expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).pearls).toBeGreaterThan(0);
});
});
describe("stars", () => {
it("uses the documented accuracy thresholds", () => {
expect(starsFor(1)).toBe(3);
expect(starsFor(0.97)).toBe(3);
expect(starsFor(0.969)).toBe(2);
expect(starsFor(0.93)).toBe(2);
expect(starsFor(0.929)).toBe(1);
expect(starsFor(0.85)).toBe(1);
expect(starsFor(0.849)).toBe(0);
});
it("gates the unlock at two stars and ignores speed entirely", () => {
expect(isPassed(0.93)).toBe(true);
expect(isPassed(0.929)).toBe(false);
});
});
describe("animalFor", () => {
it("returns the right animal at every boundary", () => {
for (const animal of ANIMALS) {
expect(animalFor(animal.from).id).toBe(animal.id);
}
});
it("clamps below the slowest and above the fastest", () => {
expect(animalFor(0).id).toBe("snail");
expect(animalFor(-5).id).toBe("snail");
expect(animalFor(9999).id).toBe("orca");
});
it("is monotone - more points never means a slower animal", () => {
let seen = 0;
for (let points = 0; points < 200; points += 1) {
const index = ANIMALS.findIndex((animal) => animal.id === animalFor(points).id);
expect(index).toBeGreaterThanOrEqual(seen);
seen = index;
}
});
it("reports progress toward the next animal", () => {
expect(animalProgress(15)).toBeCloseTo(0, 5);
expect(animalProgress(20)).toBeCloseTo(0.5, 5);
expect(animalProgress(9999)).toBe(1);
});
});
describe("ANIMALS", () => {
it("gives every animal a distinct name and emoji", () => {
expect(new Set(ANIMALS.map((animal) => animal.emoji)).size).toBe(ANIMALS.length);
expect(new Set(ANIMALS.map((animal) => animal.name)).size).toBe(ANIMALS.length);
expect(new Set(ANIMALS.map((animal) => animal.id)).size).toBe(ANIMALS.length);
});
it("rises in speed with no gaps", () => {
for (let i = 1; i < ANIMALS.length; i++) {
expect(ANIMALS[i]!.from).toBeGreaterThan(ANIMALS[i - 1]!.from);
}
expect(ANIMALS[0]!.from).toBe(0);
});
it("does not use the rating star as an animal", () => {
for (const animal of ANIMALS) expect(animal.emoji).not.toBe("⭐");
});
});
describe("visibleAnimals", () => {
it("always shows the ladder up to the dolphin, however slow the run", () => {
const { animals } = visibleAnimals("snail", null);
expect(animals.at(-1)!.id).toBe(SURPRISE_FROM);
expect(animals.map((animal) => animal.id)).toContain("snail");
});
it("keeps the animals above the dolphin hidden until they are reached", () => {
const { animals, moreHidden } = visibleAnimals("jellyfish", "dolphin");
expect(animals.some((animal) => animal.id === "shark")).toBe(false);
expect(animals.some((animal) => animal.id === "orca")).toBe(false);
expect(moreHidden).toBe(true);
});
it("reveals a surprise animal once this run earns it", () => {
const { animals } = visibleAnimals("shark", null);
expect(animals.at(-1)!.id).toBe("shark");
});
it("keeps a surprise animal revealed on later, slower runs", () => {
const { animals, moreHidden } = visibleAnimals("crab", "shark");
expect(animals.at(-1)!.id).toBe("shark");
expect(moreHidden).toBe(true);
});
it("stops promising more once the ladder is complete", () => {
expect(visibleAnimals("orca", "orca").moreHidden).toBe(false);
});
it("has something to keep secret in the first place", () => {
expect(animalIndex(SURPRISE_FROM)).toBeLessThan(ANIMALS.length - 1);
});
});
describe("isBetter", () => {
it("prefers more stars over more points", () => {
expect(isBetter({ stars: 3, points: 10 } as never, { stars: 2, points: 500 })).toBe(true);
expect(isBetter({ stars: 2, points: 500 } as never, { stars: 3, points: 10 })).toBe(false);
});
it("breaks a tie on points", () => {
expect(isBetter({ stars: 2, points: 50 } as never, { stars: 2, points: 49 })).toBe(true);
expect(isBetter({ stars: 2, points: 49 } as never, { stars: 2, points: 50 })).toBe(false);
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { PATH_AMPLITUDE, pathD, pointFor, xOffsetFor } from "../lessonPath";
describe("xOffsetFor", () => {
it("starts centred", () => {
expect(xOffsetFor(0)).toBeCloseTo(0);
});
it("never strays further than the amplitude", () => {
for (let i = 0; i < 40; i++) {
expect(Math.abs(xOffsetFor(i))).toBeLessThanOrEqual(PATH_AMPLITUDE + 1e-9);
}
});
it("is deterministic", () => {
expect(xOffsetFor(5)).toBe(xOffsetFor(5));
});
});
describe("pointFor", () => {
it("places nodes strictly further down as the index grows", () => {
let previousY = -Infinity;
for (let i = 0; i < 10; i++) {
const point = pointFor(i);
expect(point.y).toBeGreaterThan(previousY);
previousY = point.y;
}
});
});
describe("pathD", () => {
it("draws nothing for fewer than two points", () => {
expect(pathD([])).toBe("");
expect(pathD([{ x: 0, y: 0 }])).toBe("");
});
it("starts at the first point and mentions every point", () => {
const points = [pointFor(0), pointFor(1), pointFor(2)];
const d = pathD(points);
expect(d.startsWith(`M ${points[0]!.x} ${points[0]!.y}`)).toBe(true);
for (const point of points) expect(d).toContain(String(point.x));
});
});

View File

@@ -0,0 +1,67 @@
/** What's left client-side of the old progress.ts test suite, now that
* `recordRun`/`freshProgress`/`migrate` live in the backend (see
* musicmouse/tippen/progress.py and its own tests) - just the pure derivations that
* still run here: which key to drill next, and how worn-in a key looks on the
* on-screen keyboard. */
import { describe, expect, it } from "vitest";
import { focusKeyFor, mastery } from "../progress";
import type { Progress } from "../progress";
function basicProgress(over: Partial<Progress> = {}): Progress {
return {
lessons: {},
keyStats: {},
pearls: 0,
aquarium: [],
streak: { days: 0, lastPlayed: null },
settings: { sound: true, keyboardHint: "auto" },
...over,
};
}
describe("focusKeyFor", () => {
it("has no focus key on a lesson that has never been played", () => {
// Otherwise "pick an unpractised key" picks whichever sorts first and drills it half
// the line, starving the other three fingers on lesson 1.
expect(focusKeyFor(basicProgress(), ["a", "s", "d", "f"])).toBeNull();
});
it("picks an unpractised key before a merely slow one", () => {
const progress = basicProgress({ keyStats: { a: { ema: 5000, attempts: 50, errors: 20 } } });
expect(focusKeyFor(progress, ["a", "s"])).toBe("s");
});
it("picks the slowest and most error-prone once all are practised", () => {
const progress = basicProgress({
keyStats: {
a: { ema: 300, attempts: 50, errors: 0 },
s: { ema: 900, attempts: 50, errors: 10 },
},
});
expect(focusKeyFor(progress, ["a", "s"])).toBe("s");
});
it("ignores the space bar and copes with an empty lesson", () => {
expect(focusKeyFor(basicProgress(), [" "])).toBeNull();
expect(focusKeyFor(basicProgress(), [])).toBeNull();
});
});
describe("mastery", () => {
it("is zero until a key has been seen enough times", () => {
const progress = basicProgress({ keyStats: { a: { ema: 200, attempts: 2, errors: 0 } } });
expect(mastery(progress, "a")).toBe(0);
expect(mastery(progress, "q")).toBe(0);
});
it("rises with speed and accuracy, and stays within 0..1", () => {
const stat = (ema: number, errors: number) =>
basicProgress({ keyStats: { a: { ema, attempts: 100, errors } } });
expect(mastery(stat(1500, 0), "a")).toBe(0);
expect(mastery(stat(300, 0), "a")).toBe(1);
expect(mastery(stat(900, 0), "a")).toBeCloseTo(0.5, 5);
expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5);
});
});

View File

@@ -0,0 +1,208 @@
/** The aquarium's pets, and how they swim.
*
* A pet moves in when a world is finished and then stays - not in a list on the home
* screen, but swimming around behind every screen of the game. A reward that is always
* in view, drifting past while she types, is the strongest version of "the reward
* persists", and it costs nothing to look at.
*
* Pets are illustrations, speed trophies are emoji. The ladder in grading.ts changes
* with every run; a pet arrives once and never leaves. Keeping the two in different
* visual languages is what lets a turtle be both a speed trophy (🐢) and a pet (the
* drawing) without a six-year-old having to work out which is which.
*
* The swimming lives here rather than in the component because it is the part worth
* testing: `stepSwimmer` is a pure step - a swimmer and a time slice in, a swimmer out -
* so "never leaves the tank" and "looks where it is going" are checkable without a DOM
* or a clock. The component only calls it once per frame and writes the transform. */
export type CreatureId = "clownfish" | "octopus" | "seahorse" | "turtle" | "pearlmussel";
export interface Creature {
id: CreatureId;
name: string;
/** For the sentence read aloud on arrival: "Die Krake ist ins Aquarium gezogen!" */
article: "Der" | "Die" | "Das";
/** Under public/, made from the original drawing by scripts/aquarium-bild.sh. */
image: string;
/** Height as a fraction of the stage height - so a pet is the same size relative to
* the sea on a small laptop and on a big screen. */
size: number;
/** Cruising speed as a fraction of the stage width per second. Slow on purpose: these
* are in the background of a typing drill, and anything darting reads as an event. */
speed: number;
/** Which way the drawing faces. Side-view creatures are mirrored to look where they
* swim; front-view ones never are - an octopus flipping on every turn looks broken. */
facing: "side" | "front";
}
export const CREATURES: readonly Creature[] = [
{ id: "clownfish", name: "Clownfisch", article: "Der", image: "/aquarium/clownfisch.webp", size: 0.13, speed: 0.045, facing: "side" },
{ id: "octopus", name: "Krake", article: "Die", image: "/aquarium/krake.webp", size: 0.17, speed: 0.025, facing: "front" },
{ id: "seahorse", name: "Seepferdchen", article: "Das", image: "/aquarium/seepferdchen.webp", size: 0.19, speed: 0.02, facing: "side" },
{ id: "turtle", name: "Schildkröte", article: "Die", image: "/aquarium/schildkroete.webp", size: 0.17, speed: 0.032, facing: "side" },
{ id: "pearlmussel", name: "Perlmuschel", article: "Die", image: "/aquarium/perlmuschel.webp", size: 0.12, speed: 0.016, facing: "front" },
];
const CREATURE_BY_ID = new Map(CREATURES.map((creature) => [creature.id, creature]));
export function creatureById(id: CreatureId): Creature {
// Every CreatureId is in CREATURES, so this cannot miss; the fallback only satisfies
// `noUncheckedIndexedAccess`.
return CREATURE_BY_ID.get(id) ?? CREATURES[0]!;
}
/** Saves from before the pets were drawings stored the world's emoji. Each world kept
* its slot, so the old emoji map one-to-one onto the creature that now fills it. */
const LEGACY_EMOJI: Readonly<Record<string, CreatureId>> = {
"🐠": "clownfish",
"🐙": "octopus",
"🦑": "seahorse",
"🐳": "turtle",
"🧜": "pearlmussel",
};
/** A stored aquarium entry as a creature, or `null` for anything unrecognisable. */
export function creatureFromRaw(raw: unknown): CreatureId | null {
if (typeof raw !== "string") return null;
if (CREATURE_BY_ID.has(raw as CreatureId)) return raw as CreatureId;
return LEGACY_EMOJI[raw] ?? null;
}
// --- swimming ---------------------------------------------------------------
export interface Tank {
width: number;
height: number;
}
export interface Swimmer {
/** Centre, in px. */
x: number;
y: number;
/** px per second. */
vx: number;
vy: number;
/** Where it is currently drifting towards. */
targetX: number;
targetY: number;
/** -1 looking left … 1 looking right. Eased rather than switched, so a turn is a
* visible flip through the middle instead of a jump. */
facing: number;
/** Seconds swum, for the bob. Started at a random offset so pets do not bob in step. */
age: number;
}
/** How long the velocity takes to swing round to a new heading. Over a second, so every
* change of course is a lazy curve and nothing ever jerks. */
const STEER_TAU = 1.4;
/** How long a turn-around takes. */
const TURN_TAU = 0.25;
/** One gentle bob per this many seconds. */
const BOB_PERIOD = 3.6;
function randomTarget(tank: Tank, margin: number, rng: () => number): { x: number; y: number } {
// A tank smaller than the creature (a collapsed window) still needs a valid target:
// the middle.
const span = (length: number) => Math.max(0, length - 2 * margin);
return {
x: margin + rng() * span(tank.width),
y: margin + rng() * span(tank.height),
};
}
/** A new swimmer. `fromOutside` starts it just past a side edge, so a pet that has only
* just been earned visibly swims in rather than popping into existence mid-screen. */
export function createSwimmer(
tank: Tank,
margin: number,
rng: () => number,
fromOutside = false,
): Swimmer {
const target = randomTarget(tank, margin, rng);
const start = fromOutside
? { x: rng() < 0.5 ? -margin : tank.width + margin, y: target.y }
: randomTarget(tank, margin, rng);
return {
x: start.x,
y: start.y,
vx: 0,
vy: 0,
targetX: target.x,
targetY: target.y,
facing: target.x >= start.x ? 1 : -1,
age: rng() * BOB_PERIOD,
};
}
/** One time slice of swimming: steer towards the target, pick a new one on arrival, and
* turn to face the direction of travel.
*
* `margin` is half the creature's size - how far its centre stays from the edges - and
* `speed` its cruising speed in px/s. */
export function stepSwimmer(
s: Swimmer,
dt: number,
tank: Tank,
margin: number,
speed: number,
rng: () => number,
): Swimmer {
let { targetX, targetY } = s;
const inTank = (x: number, y: number) =>
x >= margin && x <= tank.width - margin && y >= margin && y <= tank.height - margin;
// Arrived, or the window shrank and the target is now outside it: drift somewhere new.
const dx = targetX - s.x;
const dy = targetY - s.y;
if (Math.hypot(dx, dy) < Math.max(margin, 24) || !inTank(targetX, targetY)) {
const target = randomTarget(tank, margin, rng);
targetX = target.x;
targetY = target.y;
}
const directionX = targetX - s.x;
const directionY = targetY - s.y;
const distance = Math.hypot(directionX, directionY) || 1;
// Vertical drift at half speed: fish cruise, they do not climb.
const targetVx = (directionX / distance) * speed;
const targetVy = (directionY / distance) * speed * 0.5;
const steer = 1 - Math.exp(-dt / STEER_TAU);
const vx = s.vx + (targetVx - s.vx) * steer;
const vy = s.vy + (targetVy - s.vy) * steer;
// Only turn round once it is really swimming that way - hovering on the spot must not
// make it flicker left and right.
const targetFacing = Math.abs(vx) > speed * 0.2 ? Math.sign(vx) : Math.sign(s.facing) || 1;
const facing = s.facing + (targetFacing - s.facing) * (1 - Math.exp(-dt / TURN_TAU));
return {
x: s.x + vx * dt,
y: s.y + vy * dt,
vx,
vy,
targetX,
targetY,
facing,
age: s.age + dt,
};
}
/** What the component draws for a swimmer: the bob and the tilt layered on top of the
* position, and the mirroring for side-view drawings. */
export function pose(
s: Swimmer,
creature: Creature,
speed: number,
): { x: number; y: number; mirror: number; rotation: number } {
const bob = Math.sin((s.age / BOB_PERIOD) * 2 * Math.PI);
const isSide = creature.facing === "side";
// Nose up when rising, down when sinking - a few degrees, in the direction it faces.
const tilt = speed > 0 ? Math.max(-1, Math.min(1, s.vy / speed)) : 0;
return {
x: s.x,
y: s.y + bob * 7,
mirror: isSide ? s.facing : 1,
rotation: isSide ? tilt * 10 * Math.sign(s.facing || 1) : bob * 3,
};
}

View File

@@ -0,0 +1,109 @@
/** The lesson plan, fetched from the backend rather than parsed from a YAML file at
* build time - see `musicmouse/tippen/curriculum.py` for how it is loaded and
* validated, and `musicmouse/tippen/rewards.py` for how a lesson's reward resolves
* against the library. `fromApi` is the only place the wire's snake_case shape
* (`api/types.ts`) meets this module's own camelCase one, which every other tippen
* module (ported near-unchanged from the old standalone app) still expects. */
import type {
TippenCurriculum as ApiCurriculum,
TippenLesson as ApiLesson,
TippenReward as ApiReward,
} from "../../api/types";
import type { CreatureId } from "./aquarium";
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
/** What this lesson unlocks in the music library, if anything - see `rewards.py`.
* `resolved: false` means the curriculum names a path that matches nothing right now. */
export interface MediaReward {
resolved: boolean;
albumId: string | null;
hasCover: boolean;
kind: "tracks" | "episode" | null;
}
export interface Lesson {
id: string;
world: number;
number: number;
title: string;
subtitle: string;
kind: LessonKind;
newKeys: readonly string[];
spotlightKeys: readonly string[];
emphasis: "isolated" | "mixed" | null;
activeKeys: readonly string[];
primaryMode: ModeId;
bonusModes: readonly ModeId[];
words: readonly string[];
isDrill: boolean;
chunks: number;
chunkSize: number;
reward: MediaReward;
}
export interface World {
number: number;
title: string;
emoji: string;
reward: CreatureId;
}
export interface Curriculum {
worlds: readonly World[];
lessons: readonly Lesson[];
}
function toReward(reward: ApiReward): MediaReward {
return { resolved: reward.resolved, albumId: reward.album_id, hasCover: reward.has_cover, kind: reward.kind };
}
function toLesson(lesson: ApiLesson): Lesson {
return {
id: lesson.id,
world: lesson.world,
number: lesson.number,
title: lesson.title,
subtitle: lesson.subtitle,
kind: lesson.kind,
newKeys: lesson.new_keys,
spotlightKeys: lesson.spotlight_keys,
emphasis: lesson.emphasis,
activeKeys: lesson.active_keys,
primaryMode: lesson.primary_mode,
bonusModes: lesson.bonus_modes,
words: lesson.words,
isDrill: lesson.is_drill,
chunks: lesson.chunks,
chunkSize: lesson.chunk_size,
reward: toReward(lesson.reward),
};
}
export function fromApi(curriculum: ApiCurriculum): Curriculum {
return {
worlds: curriculum.worlds.map((world) => ({
number: world.number,
title: world.title,
emoji: world.emoji,
reward: world.reward as CreatureId,
})),
lessons: curriculum.lessons.map(toLesson),
};
}
export function lessonById(curriculum: Curriculum, id: string): Lesson | null {
return curriculum.lessons.find((lesson) => lesson.id === id) ?? null;
}
export function nextLesson(curriculum: Curriculum, id: string): Lesson | null {
const index = curriculum.lessons.findIndex((lesson) => lesson.id === id);
if (index < 0) return null;
return curriculum.lessons[index + 1] ?? null;
}
export function firstLessonId(curriculum: Curriculum): string | null {
return curriculum.lessons[0]?.id ?? null;
}

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 (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 };
}

View File

@@ -0,0 +1,165 @@
/** The German QWERTZ layout, as a finger map.
*
* This is the source of truth for three things that must never disagree: which finger
* the on-screen keyboard colours a key with, which hand the hint names, and which home
* key that finger returns to. Keeping them in one frozen table means a wrong finger
* assignment is one edit to fix, not three.
*
* Keys are stored lowercase and compared lowercase - `event.key` for a capital letter
* is "A", but it is still typed with the same finger as "a". */
export type Hand = "left" | "right";
/** Finger ids, left pinky through right pinky, thumbs last. The order matters: it is
* the left-to-right order the parent screen lists them in. */
export type FingerId =
| "left-pinky"
| "left-ring"
| "left-middle"
| "left-index"
| "right-index"
| "right-middle"
| "right-ring"
| "right-pinky"
| "thumb";
export interface Finger {
id: FingerId;
hand: Hand;
/** What a six-year-old is told out loud: "der kleine Finger links". */
label: string;
/** The key this finger rests on in the home row. */
home: string;
/** oklch hue for the keyboard overlay, so "der grüne Finger" is a thing you can say. */
hue: number;
}
export const FINGERS: Record<FingerId, Finger> = {
"left-pinky": { id: "left-pinky", hand: "left", label: "kleiner Finger links", home: "a", hue: 25 },
"left-ring": { id: "left-ring", hand: "left", label: "Ringfinger links", home: "s", hue: 70 },
"left-middle": { id: "left-middle", hand: "left", label: "Mittelfinger links", home: "d", hue: 140 },
"left-index": { id: "left-index", hand: "left", label: "Zeigefinger links", home: "f", hue: 195 },
"right-index": { id: "right-index", hand: "right", label: "Zeigefinger rechts", home: "j", hue: 250 },
"right-middle": { id: "right-middle", hand: "right", label: "Mittelfinger rechts", home: "k", hue: 290 },
"right-ring": { id: "right-ring", hand: "right", label: "Ringfinger rechts", home: "l", hue: 330 },
"right-pinky": { id: "right-pinky", hand: "right", label: "kleiner Finger rechts", home: "ö", hue: 10 },
thumb: { id: "thumb", hand: "right", label: "Daumen", home: " ", hue: 220 },
};
/** Which keys each finger owns, in the standard German assignment. The index fingers
* carry two columns each (their home column plus the stretch inward), which is why
* `left-index` has r/t and `right-index` has z/u. */
const OWNED: Record<FingerId, string> = {
"left-pinky": "^1qay<",
"left-ring": "2wsx",
"left-middle": "3edc",
"left-index": "45rtfgvb",
"right-index": "67zuhjnm",
"right-middle": "8ik,",
"right-ring": "9ol.",
"right-pinky": "0ßpüöä-+#",
thumb: " ",
};
/** Which row a key sits in, for the on-screen keyboard's layout and for the lesson
* titles ("nach oben", "nach unten"). */
export type RowId = "numbers" | "top" | "home" | "bottom" | "space";
const ROWS: Record<RowId, string> = {
numbers: "^1234567890ß",
top: "qwertzuiopü+",
home: "asdfghjklöä#",
bottom: "<yxcvbnm,.-",
space: " ",
};
const KEY_TO_FINGER = new Map<string, FingerId>();
for (const [finger, keys] of Object.entries(OWNED) as [FingerId, string][]) {
for (const key of keys) KEY_TO_FINGER.set(key, finger);
}
const KEY_TO_ROW = new Map<string, RowId>();
for (const [row, keys] of Object.entries(ROWS) as [RowId, string][]) {
for (const key of keys) KEY_TO_ROW.set(key, row);
}
/** The three letter rows as the on-screen keyboard draws them, top to bottom. */
export const KEYBOARD_ROWS: readonly RowId[] = ["top", "home", "bottom"];
export function keysInRow(row: RowId): readonly string[] {
return [...(ROWS[row] ?? "")];
}
/** Characters that need Shift on a German layout, mapped to the physical key that
* carries them. Only the ones this course teaches; the rest of the number row can be
* added when world 6 exists. */
const SHIFTED: Record<string, string> = {
"!": "1",
'"': "2",
"§": "3",
$: "4",
"%": "5",
"&": "6",
"/": "7",
"(": "8",
")": "9",
"=": "0",
"?": "ß",
"*": "+",
";": ",",
":": ".",
_: "-",
"'": "#",
">": "<",
};
/** The physical key that produces `char`.
*
* A capital is its own lowercase key plus Shift, and "?" is the ß key plus Shift. The
* distinction matters in three places: which key the on-screen keyboard lights up,
* which finger the hint names, and whether a lesson can actually type a word. Without
* it, "Wo ist der Delfin?" looks untypable and lights up nothing. */
export function keyForChar(char: string): string {
const lower = char.toLowerCase();
if (lower !== char) return lower;
return SHIFTED[char] ?? char;
}
/** Whether reaching `char` needs a Shift held. */
export function needsShift(char: string): boolean {
return char.toLowerCase() !== char || char in SHIFTED;
}
/** The finger that types `key`, or `null` for anything off the layout (Enter, F1, …).
* Takes either a physical key or a character it produces - "A", "a" and "?" all
* resolve. */
export function fingerOf(key: string): Finger | null {
const id = KEY_TO_FINGER.get(keyForChar(key));
return id ? FINGERS[id] : null;
}
export function handOf(key: string): Hand | null {
return fingerOf(key)?.hand ?? null;
}
export function rowOf(key: string): RowId | null {
return KEY_TO_ROW.get(keyForChar(key)) ?? null;
}
/** The home key the typing finger came from - what the hint shows as "zurück nach …". */
export function homeKeyOf(key: string): string | null {
return fingerOf(key)?.home ?? null;
}
/** The home row itself, left to right. `SPACE_KEY` is separate because the thumb
* is the one finger that does not rest on a letter. */
export const HOME_ROW = ["a", "s", "d", "f", "j", "k", "l", "ö"] as const;
export const SPACE_KEY = " ";
/** A shifted character is typed with the Shift on the *opposite* hand - the single rule
* that separates real touch typing from hunt-and-peck with a pinky cramp. */
export function shiftHandFor(key: string): Hand | null {
const hand = handOf(key);
if (hand === null) return null;
return hand === "left" ? "right" : "left";
}

View File

@@ -0,0 +1,238 @@
/** What the child actually types: drill lines built from a lesson's active keys.
*
* Seeded throughout (`mulberry32`), so a line is reproducible - which is what makes it
* testable, and what lets the race mode replay a ghost against the identical text.
*
* Two ideas borrowed from keybr, simplified to what a six-year-old needs:
*
* - a *focus key* gets roughly double its natural share of the line, so the letter she
* is slowest on is the letter she sees most;
* - real words beat pseudo-words for motivation, so as soon as a lesson's active keys
* can spell something real, the curated `words` list is preferred and `fjfj dkdk`
* stops appearing.
*
* Chunks, not one long string: the line is returned as short groups, because four
* letters with a gap after them is something a six-year-old can find her place in and
* twenty-four letters in a row is not. The gap is a real space from lesson 1 on, even
* before the space-bar lesson formally teaches the thumb - a gap she can see but is
* never asked to type would be more confusing, not less. */
/** A small, fast, seedable PRNG. Identical seed, identical line. */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export type Rng = () => number;
export interface LineOptions {
/** How many chunks the line has. */
chunks?: number;
/** Letters per chunk. */
chunkSize?: number;
/** The key to over-represent, if any. */
focusKey?: string | null;
/** The keys this lesson spotlights. They take the majority of the line; everything
* learned earlier keeps appearing as review. */
newKeys?: readonly string[];
/** How much of the line the spotlighted keys should take, in place of the default
* (see `NEW_KEY_SHARE`). An isolated round wants this high; a mixed round - the same
* keys again, but blended with everything else - wants it lower. */
newKeyShare?: number;
}
/** The most of a line one key may ever occupy. Above this it stops being practice and
* starts being a stutter - and on a small key set it starves the other fingers. */
const MAX_FOCUS_SHARE = 0.3;
/** How much of a line the lesson's spotlighted keys should take by default. The rest is
* review of everything learned so far, which is what stops the early lessons rotting
* while the late ones are learned.
*
* Without this, lesson 2 ("the right hand") drew evenly from all eight home keys and
* spent half the line on the left hand it had already taught - which is not what a
* lesson called "the right hand" should drill. */
const NEW_KEY_SHARE = 0.6;
/** Copies of each spotlighted key needed to reach `share` of the pool, clamped so a
* lesson with one spotlighted key and many old ones does not bury the review entirely. */
function newKeyCopies(newCount: number, oldCount: number, share: number): number {
if (newCount === 0 || oldCount === 0) return 1;
const exact = (share * oldCount) / (newCount * (1 - share));
return Math.max(1, Math.min(6, Math.round(exact)));
}
/** How many extra copies of the focus key to add to a pool of `n` letters without its
* share passing `MAX_FOCUS_SHARE`. Small sets get no boost at all: with four active
* keys every one of them is already drilled constantly. */
function focusKeyCopies(n: number): number {
let copies = 0;
while (copies < 2 && (1 + copies + 1) / (n + copies + 1) <= MAX_FOCUS_SHARE) copies++;
return copies;
}
/** Build a weighted alphabet: every active key at least once, the lesson's new keys
* several times, plus a few extra copies of the focus key. Drawing from this is the
* whole weighting mechanic - no rejection sampling, and no chance of an endless loop
* when the focus key is the only active key. */
function weighted(
activeKeys: readonly string[],
focusKey: string | null | undefined,
newKeys: readonly string[] = [],
newKeyShare: number = NEW_KEY_SHARE,
): string[] {
const letters = activeKeys.filter((key) => key !== " ");
if (letters.length === 0) return [];
// Shift and the space bar are taught by the target text, not by the letter pool.
const newActive = newKeys.filter((key) => letters.includes(key));
const oldActive = letters.filter((key) => !newActive.includes(key));
const pool = [...letters];
if (newActive.length > 0 && oldActive.length > 0) {
const copies = newKeyCopies(newActive.length, oldActive.length, newKeyShare);
for (const key of newActive) for (let i = 1; i < copies; i++) pool.push(key);
}
if (focusKey && letters.includes(focusKey)) {
for (let i = 0; i < focusKeyCopies(letters.length); i++) pool.push(focusKey);
}
return pool;
}
/** Draw from a shuffled bag rather than sampling independently.
*
* Independent sampling is lumpy over the length of one line, and lumpy is not a
* cosmetic problem here: a real generated lesson-1 line came out as
* `saadaaafasaassfssffsafsf` - `d` once in twenty-four characters, so the middle finger
* got one repetition while the little finger got nine. Averaged over a hundred lines
* that is fine; the child types one line.
*
* A bag fixes it by construction. Every key is drawn once before any key is drawn
* twice, so every finger gets its turn within each pass, and the order inside a pass is
* still random. Refilling on empty keeps it going for as long as the line needs. */
function bagDraw(pool: readonly string[], count: number, rng: Rng): string[] {
const out: string[] = [];
let bag: string[] = [];
for (let i = 0; i < count; i++) {
if (bag.length === 0) {
bag = [...pool];
// Fisher-Yates, so every ordering of the bag is equally likely.
for (let j = bag.length - 1; j > 0; j--) {
const k = Math.floor(rng() * (j + 1));
[bag[j], bag[k]] = [bag[k]!, bag[j]!];
}
}
out.push(bag.pop()!);
}
return out;
}
/** A line of pseudo-word chunks over the lesson's active keys. Used by every lesson,
* and the only option for world 1 where nothing real can be spelled yet. */
export function drillChunks(
activeKeys: readonly string[],
rng: Rng,
options: LineOptions = {},
): string[] {
const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [], newKeyShare } = options;
const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
if (pool.length === 0) return [];
const letters = bagDraw(pool, chunks * chunkSize, rng);
const out: string[] = [];
for (let i = 0; i < chunks; i++) {
out.push(letters.slice(i * chunkSize, (i + 1) * chunkSize).join(""));
}
return out;
}
/** A line of real German words (or sentences), or `null` when the lesson has none yet.
*
* Drawn from the same shuffled bag as the letters. Once a round became twenty-five
* words long, independent picks from an eight-word list started clumping: a real
* world-5 round came out with "Das Meer ist blau, tief und kalt." four times in ten
* sentences. The bag uses every word once before any word twice, so repeats are as far
* apart as the list allows. */
export function wordChunks(
words: readonly string[],
rng: Rng,
options: LineOptions = {},
): string[] | null {
if (words.length === 0) return null;
const { chunks = 5, focusKey = null } = options;
// Words containing the focus key go in twice, same trick as the letter pool.
const pool = [...words];
if (focusKey) {
for (const word of words) {
if (word.toLowerCase().includes(focusKey.toLowerCase())) pool.push(word);
}
}
const out = bagDraw(pool, chunks, rng);
// A bag refill can put the last word of one pass straight after itself. Swap it with a
// later word when there is one - but never loop, since a one-word list must still work.
for (let i = 1; i < out.length; i++) {
if (out[i] !== out[i - 1]) continue;
const swapIndex = out.findIndex((word, j) => j > i && word !== out[i]);
if (swapIndex > 0) [out[i], out[swapIndex]] = [out[swapIndex]!, out[i]!];
}
return out;
}
/** The line a lesson should show, given what it can spell. Word lessons alternate:
* `preferWords` lets a mode ask for letters even in a late lesson (jellyfish mode is
* always single letters) or for words wherever they exist (feed mode). */
export function lineFor(
lesson: { activeKeys: readonly string[]; words: readonly string[]; newKeys?: readonly string[] },
rng: Rng,
options: LineOptions & { preferWords?: boolean } = {},
): string[] {
const { preferWords = true, ...rest } = options;
const withNew = { newKeys: lesson.newKeys ?? [], ...rest };
if (preferWords) {
const words = wordChunks(lesson.words, rng, withNew);
if (words) return words;
}
return drillChunks(lesson.activeKeys, rng, withNew);
}
/** Join chunks into the string the engine types against. */
export function lineText(chunks: readonly string[], spaceActive: boolean): string {
return chunks.join(spaceActive ? " " : "");
}
/** Where each chunk starts in `lineText(chunks, spaceActive)` - what the Target
* component needs to draw the gaps in the right places. */
export function chunkOffsets(chunks: readonly string[], spaceActive: boolean): number[] {
const offsets: number[] = [];
let at = 0;
for (const chunk of chunks) {
offsets.push(at);
at += chunk.length + (spaceActive ? 1 : 0);
}
return offsets;
}
/** Single letters for the bubbles and jellyfish modes: one key per bubble, drawn from
* the same bag, so the arcade modes drill the same spread as the dive mode. */
export function letterStream(
activeKeys: readonly string[],
rng: Rng,
count: number,
focusKey?: string | null,
newKeys: readonly string[] = [],
newKeyShare?: number,
): string[] {
const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
if (pool.length === 0) return [];
return bagDraw(pool, count, rng);
}

View File

@@ -0,0 +1,210 @@
/** Turning a run into a number, a star count and an animal.
*
* Two decisions here are the whole pedagogy of the game, so they are worth stating:
*
* *Characters per minute, not words per minute.* German words are long and a
* six-year-old types around four words a minute honestly measured. "4" on a results
* screen reads as failure; "38 Zeichen pro Minute" reads as a number that visibly
* grows. Same data, different message.
*
* *points = speed × accuracy³, not the textbook net WPM.* The standard formula is
* `net = gross errors/minute`, which goes negative for a beginner - the one result
* that must never appear. The multiplicative form cannot: 95% accuracy keeps 86% of the
* speed, 90% keeps 73%, 80% keeps 51%. Careful-and-slow beats fast-and-sloppy, which is
* the habit worth building at this age.
*
* Crucially, speed only ever buys the *animal*. The unlock gate below is accuracy-only
* (see `isPassed`), so a slow, careful child still reaches the end of the curriculum. */
import type { RunState, Stroke } from "./engine";
export interface RunResult {
/** Correct keystrokes. */
characters: number;
/** Wrong keystrokes, counted once per position (see engine.ts rule 2). */
errors: number;
/** ms from the first keystroke to the last. */
duration: number;
/** Characters per minute. */
speed: number;
/** 0..1 */
accuracy: number;
/** `speed × accuracy³`, the number the animal is read off. */
points: number;
stars: 0 | 1 | 2 | 3;
animal: AnimalId;
/** Whether this run unlocks the next lesson on its own. */
passed: boolean;
/** Pearls earned - the aquarium currency. */
pearls: number;
/** Kept for the race-mode ghost and the per-key stats. */
strokes: readonly Stroke[];
}
export type AnimalId =
| "snail"
| "crab"
| "turtle"
| "jellyfish"
| "fish"
| "penguin"
| "seal"
| "dolphin"
| "shark"
| "orca";
export interface Animal {
id: AnimalId;
name: string;
emoji: string;
/** Lower bound in points (characters per minute, accuracy-weighted). */
from: number;
/** What the speech synthesis says when this animal is reached. */
praise: string;
}
/** Nine sea animals, slowest first. Thresholds are calibrated against the "5 WPM pro
* Klassenstufe" school rule - roughly 25 characters/min at the end of first grade -
* with plenty of headroom above it.
*
* The dolphin sits deliberately high, at about 24 WPM, one below the top. It is a
* realistic second- or third-grade target, which means it stays out of reach and
* therefore worth chasing for a long time. Reaching it should feel like an event.
*
* Every emoji here is checked to render as the animal it names - the first draft used
* 🗡️ for a sailfish (it is a dagger) and 🎐 for a jellyfish (it is a wind chime), and
* ⭐ for a starfish, which collided with the star rating three lines below it. */
export const ANIMALS: readonly Animal[] = [
{ id: "snail", name: "Meeresschnecke", emoji: "🐌", from: 0, praise: "Die Schnecke ist losgekrochen!" },
{ id: "crab", name: "Krabbe", emoji: "🦀", from: 15, praise: "Eine Krabbe! Die krabbelt schon los." },
{ id: "turtle", name: "Schildkröte", emoji: "🐢", from: 25, praise: "Die Schildkröte ist stetig und sicher." },
{ id: "jellyfish", name: "Qualle", emoji: "🪼", from: 40, praise: "Die Qualle gleitet dahin!" },
{ id: "fish", name: "Fisch", emoji: "🐟", from: 55, praise: "Ein Fisch! Der schwimmt richtig flott." },
{ id: "penguin", name: "Pinguin", emoji: "🐧", from: 75, praise: "Ein Pinguin! Der flitzt durchs Wasser." },
{ id: "seal", name: "Robbe", emoji: "🦭", from: 95, praise: "Die Robbe ist schnell und geschickt!" },
{ id: "dolphin", name: "Delfin", emoji: "🐬", from: 120, praise: "Ein Delfin! Das ist richtig, richtig schnell." },
{ id: "shark", name: "Hai", emoji: "🦈", from: 150, praise: "Ein Hai! Unglaublich schnell." },
{ id: "orca", name: "Schwertwal", emoji: "🐋", from: 190, praise: "Ein Schwertwal! Schneller wird es im Meer nicht." },
];
/** The last animal shown on the ladder before a run has been fast enough to earn it.
* Everything above the dolphin stays hidden until it is actually reached - see
* `visibleAnimals`. */
export const SURPRISE_FROM: AnimalId = "dolphin";
export function animalIndex(id: AnimalId): number {
return ANIMALS.findIndex((animal) => animal.id === id);
}
/** Which slice of the ladder a result screen may show.
*
* Everything up to the dolphin is always visible, earned or not: seeing the animals you
* have not reached yet is the whole reason to try again, and a six-year-old needs the
* next rung to be visible to aim at it. Above the dolphin the ladder goes dark - those
* are a surprise, revealed only once they have actually been reached, and then they stay
* revealed. `bestEver` is the fastest animal earned on any lesson so far. */
export function visibleAnimals(earned: AnimalId, bestEver: AnimalId | null): {
animals: readonly Animal[];
/** True when faster animals exist that have not been revealed yet. */
moreHidden: boolean;
} {
const boundary = Math.max(
animalIndex(SURPRISE_FROM),
animalIndex(earned),
bestEver ? animalIndex(bestEver) : -1,
);
return {
animals: ANIMALS.slice(0, boundary + 1),
moreHidden: boundary < ANIMALS.length - 1,
};
}
const ANIMAL_BY_ID = new Map(ANIMALS.map((animal) => [animal.id, animal]));
export function animalById(id: AnimalId): Animal {
// Every AnimalId comes from ANIMALS itself, so this cannot miss - but the map lookup
// is typed as possibly-undefined and `noUncheckedIndexedAccess` is on.
return ANIMAL_BY_ID.get(id) ?? ANIMALS[0]!;
}
/** The animal for a score. Walks from the fastest down, so the first match wins. */
export function animalFor(points: number): Animal {
for (let i = ANIMALS.length - 1; i >= 0; i--) {
const animal = ANIMALS[i]!;
if (points >= animal.from) return animal;
}
return ANIMALS[0]!;
}
/** How far along the current animal this score is, 0..1 - drives the progress bar that
* shows how close the next animal is. The top animal is always full. */
export function animalProgress(points: number): number {
const index = ANIMALS.findIndex((animal) => animal.id === animalFor(points).id);
const next = ANIMALS[index + 1];
if (!next) return 1;
const floor = ANIMALS[index]!.from;
return Math.min(1, Math.max(0, (points - floor) / (next.from - floor)));
}
/** Star thresholds, on accuracy alone. The familiar 1/2/3 pattern from every other
* game she will ever play, so it needs no explaining. */
export const STAR_THRESHOLDS = { one: 0.85, two: 0.93, three: 0.97 } as const;
export function starsFor(accuracy: number): 0 | 1 | 2 | 3 {
if (accuracy >= STAR_THRESHOLDS.three) return 3;
if (accuracy >= STAR_THRESHOLDS.two) return 2;
if (accuracy >= STAR_THRESHOLDS.one) return 1;
return 0;
}
/** Two stars unlocks the next lesson. No speed condition anywhere - that is the point. */
export function isPassed(accuracy: number): boolean {
return accuracy >= STAR_THRESHOLDS.two;
}
/** One pearl per five correct keys, plus a bonus per star. Small numbers that go up
* every single run, including a bad one - the aquarium should never stall. */
function pearlsFor(characters: number, stars: number): number {
return Math.floor(characters / 5) + stars * 2;
}
export function grade(state: RunState): RunResult {
const characters = state.strokes.filter((stroke) => stroke.correct).length;
const errors = state.missed.size;
const start = state.startedAt;
const end = state.finishedAt ?? state.strokes.at(-1)?.at ?? start;
// A run of one keystroke has no elapsed time between first and last. Treating that as
// "infinitely fast" would hand out a top-tier animal for a single letter, so anything
// under a second of real typing is scored as if it took a second.
const duration = start !== null && end !== null ? Math.max(0, end - start) : 0;
const minutes = Math.max(duration, 1000) / 60000;
const speed = characters / minutes;
const attempts = characters + errors;
const accuracy = attempts === 0 ? 0 : characters / attempts;
const points = speed * accuracy ** 3;
const stars = starsFor(accuracy);
return {
characters,
errors,
duration,
speed,
accuracy,
points,
stars,
animal: animalFor(points).id,
passed: isPassed(accuracy),
pearls: pearlsFor(characters, stars),
strokes: state.strokes,
};
}
/** Which of two results is the better one, for the per-lesson personal best. Stars come
* first, points break the tie - so a careful run is never displaced by a sloppy fast
* one, and the best animal on a lesson card can never go down. */
export function isBetter(candidate: RunResult, best: { stars: number; points: number }): boolean {
if (candidate.stars !== best.stars) return candidate.stars > best.stars;
return candidate.points > best.points;
}

View File

@@ -0,0 +1,45 @@
/** The geometry of the zigzag lesson path - kept pure and out of the component so it can
* be tested the way `aquarium.ts`'s swim physics are. */
/** How many nodes make one full left-to-right-to-left swing. */
export const PATH_PERIOD = 6;
/** The furthest a node strays from the centre line, in px. */
export const PATH_AMPLITUDE = 110;
/** Vertical spacing between two nodes, in px. */
export const NODE_SPACING = 108;
/** Horizontal offset for the nth node of a world's path, centred on 0. A sine wave
* rather than a zigzag of straight segments, so the path reads as one smooth ribbon
* instead of a jagged staircase. */
export function xOffsetFor(indexInWorld: number): number {
return Math.sin((indexInWorld / PATH_PERIOD) * 2 * Math.PI) * PATH_AMPLITUDE;
}
export interface PathPoint {
x: number;
y: number;
}
/** The centre of the nth node, for both its own placement and the connector line. */
export function pointFor(indexInWorld: number): PathPoint {
return { x: xOffsetFor(indexInWorld), y: indexInWorld * NODE_SPACING + NODE_SPACING / 2 };
}
/** An SVG path string threading every node centre with a smooth curve - a straight
* polyline through a sine wave looks faceted; a vertical Bezier through each segment
* does not. Empty/one-point paths draw nothing, which is fine: a one-lesson world needs
* no connector. */
export function pathD(points: readonly PathPoint[]): string {
if (points.length < 2) return "";
const [first, ...rest] = points;
let d = `M ${first!.x} ${first!.y}`;
for (let i = 0; i < rest.length; i++) {
const from = points[i]!;
const to = rest[i]!;
const midY = (from.y + to.y) / 2;
d += ` C ${from.x} ${midY}, ${to.x} ${midY}, ${to.x} ${to.y}`;
}
return d;
}

View File

@@ -0,0 +1,12 @@
/** Labels and emoji for each game, shared by the result sheet's bonus buttons and the
* lesson map's mode badge. */
import type { ModeId } from "./curriculum";
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
dive: { emoji: "🤿", name: "Tauchgang" },
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
feed: { emoji: "🐟", name: "Fütterungszeit" },
race: { emoji: "🐬", name: "Delfinrennen" },
};

56
web/src/lib/tippen/pop.ts Normal file
View File

@@ -0,0 +1,56 @@
/** Sound feedback, ported verbatim in spirit from ../../web/src/lib/pop.ts - the same
* short rising blip the music player uses, so the two apps sound like one family.
*
* The error sound is the one addition, and it is deliberately not a buzzer: a low, soft,
* *falling* blip at a fraction of the volume. A six-year-old who is told "wrong!" twenty
* times a minute stops playing. One that hears a quiet "bloop" just tries the next key. */
let context: AudioContext | null = null;
function blip(from: number, to: number, gainStart: number, duration: number): void {
try {
context ??= new AudioContext();
const now = context.currentTime;
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(from, now);
oscillator.frequency.exponentialRampToValueAtTime(to, now + duration * 0.55);
gain.gain.setValueAtTime(gainStart, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
oscillator.connect(gain).connect(context.destination);
oscillator.start();
oscillator.stop(now + duration + 0.01);
} catch {
// No audio context before the first user gesture, and on some browsers never.
}
}
/** The music player's interaction blip: rising, bright. */
export function playPop(frequency: number): void {
blip(frequency, frequency * 1.8, 0.15, 0.15);
}
/** A correct key. The pitch climbs with the streak, so a good run audibly builds - it
* caps at an octave up, past which it just sounds shrill. */
export function playCorrect(streak: number): void {
const semitones = Math.min(streak, 12);
blip(440 * 2 ** (semitones / 12), 660 * 2 ** (semitones / 12), 0.1, 0.09);
}
/** A wrong key: low, falling, quiet. */
export function playWrong(): void {
blip(200, 150, 0.06, 0.12);
}
/** Finishing a line. */
export function playDone(): void {
blip(520, 900, 0.16, 0.4);
}
/** A new lesson, a new animal, a new creature in the aquarium. */
export function playFanfare(): void {
[523, 659, 784, 1047].forEach((frequency, i) => {
window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110);
});
}

View File

@@ -0,0 +1,185 @@
/** What stays client-side now that the backend owns `recordRun`/`freshProgress` (see
* `musicmouse/tippen/progress.py`): the pure, per-keystroke derivations - which key to
* drill next, how worn-in a key looks on the on-screen keyboard, and the fastest animal
* earned anywhere, for the aquarium's headline stat. Progress itself is fetched once
* per session and updated from each run-recording response; there is no local save. */
import type {
TippenProgress as ApiProgress,
TippenRunInput,
TippenRunResult as ApiRunResult,
TippenStroke,
} from "../../api/types";
import type { CreatureId } from "./aquarium";
import type { AnimalId, RunResult } from "./grading";
export interface LessonProgress {
unlocked: boolean;
runs: number;
bestStars: 0 | 1 | 2 | 3;
bestAnimal: AnimalId | null;
bestPoints: number;
/** Best-run keystrokes, replayed as the opponent in race mode. */
ghost: { key: string; at: number }[] | null;
}
export interface KeyStat {
ema: number;
attempts: number;
errors: number;
}
export interface Settings {
sound: boolean;
keyboardHint: "auto" | "on" | "off";
}
export interface Progress {
lessons: Record<string, LessonProgress>;
keyStats: Record<string, KeyStat>;
pearls: number;
/** Pets that have moved into the aquarium, in the order they arrived. */
aquarium: CreatureId[];
streak: { days: number; lastPlayed: string | null };
settings: Settings;
}
export function progressFromApi(progress: ApiProgress): Progress {
const lessons: Record<string, LessonProgress> = {};
for (const [id, entry] of Object.entries(progress.lessons)) {
lessons[id] = {
unlocked: entry.unlocked,
runs: entry.runs,
bestStars: entry.best_stars,
bestAnimal: entry.best_animal as AnimalId | null,
bestPoints: entry.best_points,
ghost: entry.ghost,
};
}
const keyStats: Record<string, KeyStat> = {};
for (const [key, stat] of Object.entries(progress.key_stats)) {
keyStats[key] = { ema: stat.ema, attempts: stat.attempts, errors: stat.errors };
}
return {
lessons,
keyStats,
pearls: progress.pearls,
aquarium: progress.aquarium as CreatureId[],
streak: { days: progress.streak.days, lastPlayed: progress.streak.last_played },
settings: { sound: progress.settings.sound, keyboardHint: progress.settings.keyboard_hint },
};
}
export function toRunInput(lessonId: string, result: RunResult): TippenRunInput {
const strokes: TippenStroke[] = result.strokes.map((stroke) => ({
key: stroke.key,
expected: stroke.expected,
correct: stroke.correct,
at: stroke.at,
}));
return {
lesson_id: lessonId,
stars: result.stars,
animal: result.animal,
points: result.points,
passed: result.passed,
pearls: result.pearls,
strokes,
};
}
export interface UnlockedReward {
albumId: string;
title: string;
hasCover: boolean;
kind: "album" | "book" | "podcast_episode";
}
export interface RunOutcome {
progress: Progress;
unlockedLessonId: string | null;
unlockedLessonTitle: string | null;
newCreature: CreatureId | null;
isNewBest: boolean;
unlockedReward: UnlockedReward | null;
}
export function runOutcomeFromApi(result: ApiRunResult): RunOutcome {
return {
progress: progressFromApi(result.progress),
unlockedLessonId: result.unlocked_lesson_id,
unlockedLessonTitle: result.unlocked_lesson_title,
newCreature: result.new_creature as CreatureId | null,
isNewBest: result.is_new_best,
unlockedReward: result.unlocked_reward
? {
albumId: result.unlocked_reward.album_id,
title: result.unlocked_reward.title,
hasCover: result.unlocked_reward.has_cover,
kind: result.unlocked_reward.kind,
}
: null,
};
}
/** How many times a key must be typed before its stats mean anything. */
const ENOUGH_ATTEMPTS = 3;
/** The key a lesson should drill hardest - the generator's focus key, or `null` for an
* even spread.
*
* `null` on a brand-new lesson is the important case. Every key starts unpractised, so
* "pick an unpractised key" would pick whichever sorted first and drill it half the
* line - which on lesson 1 means typing `a` thirteen times out of twenty-four while
* three other fingers go untrained. A lesson she has never played gets an even spread;
* a focus key only emerges once there is evidence of what she is actually slow at. */
export function focusKeyFor(progress: Progress, activeKeys: readonly string[]): string | null {
const keys = activeKeys.filter((key) => key !== " ");
if (keys.length === 0) return null;
const practiced = keys.filter((key) => (progress.keyStats[key]?.attempts ?? 0) >= ENOUGH_ATTEMPTS);
if (practiced.length === 0) return null;
// Some keys practised and some not: the gap is the most useful thing to close.
const unpracticed = keys.find((key) => (progress.keyStats[key]?.attempts ?? 0) < ENOUGH_ATTEMPTS);
if (unpracticed) return unpracticed;
let worst: string | null = null;
let worstScore = -Infinity;
for (const key of keys) {
const stat = progress.keyStats[key]!;
// Errors weigh heavily: a key she gets wrong matters more than one she is merely
// slow on, and 3000ms is well past the point where slow becomes a real hesitation.
const score = stat.ema + (stat.errors / stat.attempts) * 3000;
if (score > worstScore) {
worstScore = score;
worst = key;
}
}
return worst;
}
/** The fastest animal earned on any lesson so far. Drives the aquarium's headline stat
* and, on the result screen, how far up the ladder is allowed to be revealed. */
export function overallBestAnimal(progress: Progress): AnimalId | null {
let best: AnimalId | null = null;
let bestPoints = -1;
for (const lesson of Object.values(progress.lessons)) {
if (lesson.bestAnimal && lesson.bestPoints > bestPoints) {
best = lesson.bestAnimal;
bestPoints = lesson.bestPoints;
}
}
return best;
}
/** How well a key is known, 0..1 - the on-screen keyboard's opacity, so the hint fades
* away exactly where she no longer needs it. */
export function mastery(progress: Progress, key: string): number {
const stat = progress.keyStats[key.toLowerCase()];
if (!stat || stat.attempts < 5) return 0;
const accuracy = 1 - stat.errors / stat.attempts;
// 600ms is about where a six-year-old's key press stops being a search.
const speed = Math.max(0, Math.min(1, (1200 - stat.ema) / 600));
return Math.max(0, Math.min(1, accuracy * speed));
}

View File

@@ -0,0 +1,39 @@
/** Feature toggles and the one hue this app is built on.
*
* Same role as ../../web/src/lib/theme.ts: flip a switch here rather than hunting
* through components. The hue lives in styles/app.css because CSS is where it is used;
* it is repeated here only for the canvas, which cannot read a custom property. */
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
export const HUE = 175;
/** Frost the glass panels with a real backdrop blur. Expensive on weak GPUs. */
export const SHOW_GLASS_BLUR = true;
/** The decorative rising bubbles behind everything. */
export const SHOW_BUBBLES = true;
/** The earned pets swimming behind every screen. The reward that is always in view - off
* only to rule it out when chasing a performance problem. */
export const SHOW_AQUARIUM_CREATURES = true;
/** Fade screens in on entry. */
export const ANIMATE_VIEW_TRANSITIONS = true;
/** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key
* as each one is mastered - the scaffold that removes itself, which is the whole point
* of teaching touch typing rather than hunt-and-peck. */
export const KEYBOARD_HINT_DEFAULT: "auto" | "on" | "off" = "auto";
/** Read the target letters and words aloud. She is six and still learning to read; a
* missing reading skill must never block the typing skill. */
export const SPEECH_DEFAULT = true;
export const SOUND_DEFAULT = true;
/** How many letters one bubbles or jellyfish round sends up - matched to the dive
* mode's length so a mode swap is not also a difficulty swap. Dive-mode line length
* lives on the lesson itself (`lengthFor` in lib/curriculum.ts). */
export function bubbleCountFor(world: number): number {
return world === 1 ? 50 : 100;
}