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

@@ -7,7 +7,17 @@ from pathlib import Path
import pytest import pytest
from ruamel.yaml import YAML from ruamel.yaml import YAML
from musicmouse.tippen.curriculum import CurriculumError, load_curriculum from musicmouse.tippen.curriculum import (
ELIGIBLE_MODES,
CurriculumError,
first_lesson_id,
lesson_by_id,
load_curriculum,
next_lesson,
)
#: The real, shipped curriculum - see python-backend/tippen-curriculum.yml.example.
_EXAMPLE = Path(__file__).parent.parent / "tippen-curriculum.yml.example"
VALID: dict = { VALID: dict = {
"worlds": [ "worlds": [
@@ -150,3 +160,168 @@ def test_unlocks_is_carried_through_unresolved(tmp_path: Path) -> None:
curriculum = load_curriculum(_write(tmp_path, {"worlds": worlds})) curriculum = load_curriculum(_write(tmp_path, {"worlds": worlds}))
assert curriculum.lessons[0].unlocks == "~/Music/Musik/Album/05Track.mp3" assert curriculum.lessons[0].unlocks == "~/Music/Musik/Album/05Track.mp3"
assert curriculum.lessons[1].unlocks is None assert curriculum.lessons[1].unlocks is None
# ------------------------------------------------------------- the real curriculum
#
# Content-shape invariants the real, shipped curriculum must hold - ported from the
# frontend's old curriculum.test.ts, which checked these against the same YAML back
# when it was parsed client-side. The pedagogical content itself did not change in the
# move to the backend; what moved is the parsing/deriving logic these tests actually
# exercise (buildLessons's Python port, `_build_lessons`), so the coverage still earns
# its keep here. A few of the original checks needed real-keyboard finger/hand mapping
# (`fingers.ts`, never ported to Python, since nothing backend-side needs it) and are
# not repeated.
@pytest.fixture(scope="module")
def curriculum():
return load_curriculum(_EXAMPLE)
def test_has_unique_ids_and_consecutive_numbers(curriculum) -> None:
ids = [lesson.id for lesson in curriculum.lessons]
assert len(set(ids)) == len(ids)
for i, lesson in enumerate(curriculum.lessons):
assert lesson.number == i + 1
def test_starts_on_the_two_keys_with_the_tactile_bumps(curriculum) -> None:
assert curriculum.lessons[0].new_keys == ("f", "j")
assert first_lesson_id(curriculum) == curriculum.lessons[0].id
def test_eases_in_at_most_two_new_keys_per_lesson(curriculum) -> None:
for lesson in curriculum.lessons:
assert len(lesson.new_keys) <= 2, f"{lesson.number}: {lesson.title}"
def test_makes_every_round_a_real_block_of_practice(curriculum) -> None:
for lesson in curriculum.lessons:
where = f"{lesson.number}: {lesson.title}"
if lesson.kind == "sentences":
assert lesson.chunks >= 10, where
elif lesson.kind == "words":
assert lesson.chunks >= 25, where
else:
assert lesson.chunks * lesson.chunk_size >= 60, where
def test_has_more_sentences_to_draw_from_than_a_round_uses(curriculum) -> None:
for lesson in curriculum.lessons:
if lesson.kind != "sentences":
continue
assert len(lesson.words) >= lesson.chunks, f"{lesson.number}: {lesson.title}"
def test_active_keys_grow_monotonically(curriculum) -> None:
previous: set[str] = set()
for lesson in curriculum.lessons:
active = set(lesson.active_keys)
assert previous <= active
previous = active
def test_every_new_key_enters_its_own_active_keys(curriculum) -> None:
for lesson in curriculum.lessons:
for key in lesson.new_keys:
if key == "": # Shift is not a character the generator can emit.
continue
assert key in lesson.active_keys, f"{lesson.number}: {lesson.title}"
def test_covers_the_whole_alphabet_plus_umlauts_by_the_end(curriculum) -> None:
final = set(curriculum.lessons[-1].active_keys)
for key in "abcdefghijklmnopqrstuvwxyzäöü":
assert key in final
def test_plays_a_mode_that_fits_its_kind(curriculum) -> None:
for lesson in curriculum.lessons:
where = f"{lesson.number}: {lesson.title}"
assert lesson.primary_mode in ELIGIBLE_MODES[lesson.kind], where
for mode in lesson.bonus_modes:
assert mode in ELIGIBLE_MODES[lesson.kind], where
def test_never_plays_the_same_arcade_game_twice_in_a_row(curriculum) -> None:
arcade = [lesson for lesson in curriculum.lessons if lesson.kind == "letters"]
for i in range(1, len(arcade)):
where = f"{arcade[i].number}: {arcade[i].title}"
assert arcade[i].primary_mode != arcade[i - 1].primary_mode, where
def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None:
for lesson in curriculum.lessons:
expected = sorted(
mode
for mode in ELIGIBLE_MODES[lesson.kind]
if mode in ("feed", "race") and mode != lesson.primary_mode
)
assert sorted(lesson.bonus_modes) == expected
def test_mixes_games_instead_of_always_diving(curriculum) -> None:
words = [lesson for lesson in curriculum.lessons if lesson.kind == "words"]
sentences = [lesson for lesson in curriculum.lessons if lesson.kind == "sentences"]
assert any(lesson.primary_mode == "feed" for lesson in words)
assert any(lesson.primary_mode == "race" for lesson in words)
assert any(lesson.primary_mode == "race" for lesson in sentences)
def test_distinguishes_a_mixed_round_from_a_drill(curriculum) -> None:
mixed = [lesson for lesson in curriculum.lessons if lesson.emphasis == "mixed"]
assert len(mixed) > 0
for lesson in mixed:
assert lesson.new_keys == ()
assert lesson.is_drill is False
def test_every_isolated_round_has_something_new_to_drill(curriculum) -> None:
for lesson in curriculum.lessons:
if lesson.emphasis == "isolated":
assert len(lesson.new_keys) > 0, f"{lesson.number}: {lesson.title}"
def test_teaches_capitals_only_once_shift_exists(curriculum) -> None:
for lesson in curriculum.lessons:
has_uppercase = any(word != word.lower() for word in lesson.words)
if has_uppercase:
assert lesson.world >= 4, f"{lesson.number}: {lesson.title}"
def test_follows_every_pair_of_new_keys_with_a_drill(curriculum) -> None:
drills = [lesson for lesson in curriculum.lessons if lesson.is_drill]
assert len(drills) >= 15
for lesson in drills:
assert lesson.new_keys == ()
assert len(lesson.active_keys) > 0
def test_is_a_real_duolingo_length_course(curriculum) -> None:
assert len(curriculum.lessons) >= 80
assert len(curriculum.worlds) == 5
def test_every_world_has_at_least_three_lessons(curriculum) -> None:
for world in curriculum.worlds:
count = sum(1 for lesson in curriculum.lessons if lesson.world == world.number)
assert count >= 3, world.title
def test_every_world_has_its_own_creature(curriculum) -> None:
rewards = [world.reward for world in curriculum.worlds]
assert len(set(rewards)) == len(rewards)
def test_navigation_chains_every_lesson_to_the_next(curriculum) -> None:
lessons = curriculum.lessons
for i in range(len(lessons) - 1):
assert next_lesson(curriculum, lessons[i].id).id == lessons[i + 1].id
assert next_lesson(curriculum, lessons[-1].id) is None
assert next_lesson(curriculum, "gibt-es-nicht") is None
def test_navigation_looks_lessons_up_by_id(curriculum) -> None:
assert lesson_by_id(curriculum, first_lesson_id(curriculum)).number == 1
assert lesson_by_id(curriculum, "gibt-es-nicht") is None

2
tippen/.gitignore vendored
View File

@@ -1,2 +0,0 @@
node_modules/
dist/

View File

@@ -1,107 +0,0 @@
# Delfin Tippen
Ein Lernspiel fürs Zehnfingerschreiben auf einer deutschen QWERTZ-Tastatur, gebaut für
eine Sechsjährige, die Delfine mag. Alles auf Deutsch.
Technisch ein Geschwister des Musik-Players in `../web` — React 19, TypeScript, Vite,
keine Laufzeit-Abhängigkeiten außer React, dieselbe oklch-Glasoptik und dieselbe
Nunito — aber **vollständig entkoppelt**: eigenes Vite-Projekt, eigener Dev-Server,
kein Backend, kein gemeinsamer Build. Was geteilt werden sollte, wurde kopiert.
## Loslegen
```sh
npm install
npm run dev # http://localhost:5174
npm run test # vitest über src/lib
npx tsc --noEmit # der eigentliche Check
npm run build # -> dist/
```
Es braucht eine echte QWERTZ-Tastatur. Der Fortschritt liegt im `localStorage`
(`delfin-tippen:v1`); zum Zurücksetzen den Key löschen.
## Wie es aufgebaut ist
Die Logik ist DOM-frei und getestet, die Komponenten sind Layout. Das ist dieselbe
Aufteilung wie in `../web` und der Grund, warum sechs Spielmodi dieselbe Mechanik teilen
können, ohne sie sechsmal zu implementieren.
| Datei | Wofür |
|---|---|
| `lib/fingers.ts` | Die QWERTZ-Belegung: welcher Finger welche Taste, welche Farbe, welche Grundstellungstaste. |
| `lib/curriculum.ts` | 48 Lektionen in 5 Welten. Höchstens zwei neue Tasten pro Lektion, mit Übungslektionen dazwischen. |
| `lib/generator.ts` | Was getippt wird — Übungszeilen aus den aktiven Tasten, echte Wörter sobald möglich. |
| `lib/engine.ts` | Die Tippmechanik: ein Tastendruck rein, ein neuer Zustand und Ereignisse raus. Pur. |
| `lib/grading.ts` | Zeichen pro Minute, Genauigkeit, Sterne, Tier. |
| `lib/progress.ts` | Was das Spiel sich merkt, und wann die nächste Lektion aufgeht. |
| `hooks/useRun.ts` | Der dünne Adapter zwischen echten Tastendrücken und `engine.ts`. |
## Die Entscheidungen, die das Spiel ausmachen
Vier davon sind bewusst anders, als man es zuerst bauen würde. Sie stehen ausführlicher
in den Dateikommentaren:
**Tempo schaltet nichts frei.** Die nächste Lektion geht bei 93 % Treffern auf — ohne
jede Geschwindigkeitsbedingung. Wer langsam und sorgfältig tippt, kommt durch den ganzen
Kurs. Tempo bringt nur das Tier. Zusätzlich geht die nächste Lektion nach fünf Versuchen
ohnehin auf, damit niemand an einer Taste hängen bleibt.
**`punkte = tempo × genauigkeit³`.** Die Lehrbuchformel `netto = brutto fehler/minute`
wird bei einem Anfänger negativ, und ein negatives Ergebnis darf hier nie erscheinen.
Multiplikativ kann das nicht passieren: 95 % Genauigkeit behalten 86 % des Tempos, 90 %
behalten 73 %.
**Eine falsche Taste rückt nicht vor und zählt pro Stelle nur einmal.** Es gibt kein
Backspace und keine zerschossene Zeile zum Anstarren, und zehnmal panisch dieselbe
falsche Taste ist ein Fehler, nicht zehn.
**Es gibt keinen Verloren-Bildschirm.** Eine schwache Runde zeigt weniger Sterne und ein
langsameres Tier — nie ein rotes X, nie einen Summer, und die Perlen steigen immer.
## Die sechs Spielmodi
Alle sechs laufen über dieselbe Mechanik (`lib/engine.ts`) und landen in derselben
Bewertung. Sie unterscheiden sich nur darin, *wie* das Ziel gezeigt wird — deshalb ist
ein siebter Modus eine Datei und eine Zeile, keine zweite Spiellogik.
| Modus | Was man sieht | Wofür |
|---|---|---|
| 🤿 Tauchgang | Eine Zeile mit Cursor | Die Kernübung. Sie zählt fürs Freischalten. |
| 🫧 Blasenplatzen | Blasen steigen auf, eine pro Buchstabe | Tastenlage, weiche Uhr |
| 🦑 Quallenalarm | Sechs Quallen, eine leuchtet | Reine Tastenlage — erst suchen, dann tippen |
| 🐟 Fütterungszeit | Fische mit Wörtern schwimmen zum Delfin | Ganze Wörter statt Buchstaben |
| 🐬 Delfinrennen | Zwei Bahnen: du gegen deinen Rekord | Tempo, gegen den einzigen fairen Gegner |
| 🦪 Perlentaucher | Eine Muschel, ein Wort, eine Perlenkette | Genauigkeit — **ohne jede Uhr** |
Beim Delfinrennen ist der Gegner der Geist des eigenen besten Laufs: die Tastenzeiten
liegen in `progress.lessons[id].ghost` und werden gegen die Uhr abgespielt. Gegen sich
selbst zu rennen ist das einzige Wettkampfformat, das nicht entmutigen kann — der Gegner
ist per Definition genau so gut, wie man selbst war. Vor dem ersten Rekord schwimmt eine
absichtlich schlagbare Krabbe.
## Das Aquarium
Wer eine Welt schafft, bekommt ein Haustier — Clownfisch, Krake, Seepferdchen,
Schildkröte, Perlmuschel. Es zieht für immer ein und schwimmt ab dann frei hinter jedem
Bildschirm herum; während einer Runde nur blass, damit es nicht mit der Zeile
konkurriert. Das Schwimmen ist ein purer, getesteter Schritt in `lib/aquarium.ts`,
`components/AquariumTiere.tsx` zeichnet nur.
Ein neues Tier: die Zeichnung (auf schwarzem Hintergrund) nach `art/aquarium/` legen,
```sh
scripts/aquarium-bild.sh art/aquarium/qualle.png public/aquarium/qualle.webp
```
und in `KREATUREN` eintragen. Das Skript entfernt den Hintergrund als zusammenhängende
Fläche statt als Farbe — schwarze Augen und Wimpern bleiben stehen — und rechnet die
Kanten aus dem Schwarz heraus, damit kein dunkler Rand bleibt.
## Stand
Fertig und spielbar: alle 48 Lektionen, alle sechs Modi, Freischalten, Bewertung,
Tierleiter, Aquarium, Sprachausgabe und die Bildschirmtastatur.
Noch offen: die Geschichten am Ende jeder Welt, der Eltern-Bildschirm (`?eltern=1`) und
eine Welt 6 mit der Zahlenreihe.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 867 KiB

View File

@@ -1,19 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Delfin Tippen</title>
<meta name="theme-color" content="#0b2f2c" />
<meta name="description" content="Zehnfingerschreiben lernen mit Delfinen" />
<link rel="icon" href="/icon-192.png" type="image/png" />
<link rel="stylesheet" href="/fonts/nunito.css" />
<link rel="preload" href="/fonts/nunito-latin.woff2" as="font" type="font/woff2" crossorigin />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2349
tippen/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
{
"name": "delfin-tippen",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"yaml": "^2.6.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.9.0",
"vite": "^7.1.0",
"vitest": "^3.2.0"
},
"allowScripts": {
"esbuild@0.28.2": true
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 697 KiB

View File

@@ -1,22 +0,0 @@
/* Nunito, self-hosted: one variable file per subset covers 500-900.
Google Fonts would be a network round trip the mouse may not have, and a
cached app shell rendered in the fallback face looks broken. latin-ext is
what carries "Hörbücher" and "Käpt'n"; the Cyrillic and Vietnamese subsets
Google also offers are dropped, since nothing in this UI can produce them. */
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 500 900;
font-display: swap;
src: url("/fonts/nunito-latin-ext.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 500 900;
font-display: swap;
src: url("/fonts/nunito-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,57 +0,0 @@
#!/usr/bin/env bash
# Turns a creature illustration on solid black into a transparent aquarium sprite.
#
# scripts/aquarium-bild.sh bild.png public/aquarium/krake.webp
#
# The illustrations come out of the image generator as RGB on pure black, with no alpha
# channel. "Black becomes transparent" is not enough: the eyes and eyelashes are black
# too, and a creature with see-through eyes looks haunted on a turquoise sea. So the
# background is found as a *region*, not a colour - every dark area big enough to not be
# a facial feature - and only that region is removed.
#
# The edge is the other half. The art was antialiased onto black, so its outline pixels
# are the creature's colour already mixed with black. Cutting them hard leaves a dark
# halo; instead, near the background the opacity follows the brightness and the colour
# is divided back out of the black (un-premultiplied), which is what the mix actually was.
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "usage: $0 input.png output.webp [size]" >&2
exit 1
fi
src=$1
out=$2
size=${3:-512}
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
# How far each pixel is from black: its brightest channel.
magick "$src" -alpha off -channel RGB -separate -evaluate-sequence max "$tmp/hell.png"
# Dark pixels, then drop every dark island under 5000 px back into the body. Measured on
# the first five creatures: the background is one region of ~1M px, the largest facial
# feature is ~2000 px, and nothing sits in between.
magick "$tmp/hell.png" -threshold 8% -negate \
-define connected-components:area-threshold=5000 \
-define connected-components:mean-color=true \
-connected-components 8 "$tmp/grund.png"
# Opacity: solid inside the body, zero out in the background, and in a thin band along
# the outline a ramp on brightness - the band is what catches the antialiased edge. The
# ramp starts a little above black, or the generator's faint noise in the background
# would come back as a faint haze around every creature.
magick "$tmp/grund.png" -morphology Dilate Disk:3 -negate "$tmp/koerper.png"
magick "$tmp/grund.png" -negate -morphology Dilate Disk:5 "$tmp/umriss.png"
magick "$tmp/hell.png" -evaluate Subtract 4% -evaluate Multiply 3.8 "$tmp/rampe.png"
magick "$tmp/rampe.png" "$tmp/koerper.png" -compose Lighten -composite \
"$tmp/umriss.png" -compose Multiply -composite "$tmp/alpha.png"
# Divide the black back out of the edge colours, attach the opacity, cut to the creature
# and scale down - the sprites are drawn at a few hundred px at most. `-alpha background`
# blanks the colour of fully transparent pixels, which is what lets `-trim` see them as
# border at all.
magick "$src" -alpha off "$tmp/alpha.png" -compose DivideSrc -composite \
"$tmp/alpha.png" -alpha off -compose CopyOpacity -composite \
-background none -alpha background -trim +repage -resize "${size}x${size}>" \
-quality 90 -define webp:alpha-quality=100 "$out"

View File

@@ -1,422 +0,0 @@
# The lesson plan, as data rather than code, so wording, word lists and pacing can be
# tweaked here without touching curriculum.ts.
#
# Every lesson: title, subtitle (read aloud). Then either:
# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were
# already active: the first time a key appears its lesson is "isolated" (heavy
# weight, alone); the second (identical) appearance is "mixed" (lighter weight,
# blended with everything learned so far). Two lessons with the same `keys` back to
# back is exactly how you write "isolated, then mixed" - no separate flag needed.
# - drill: true - a pure review round, no new content, whatever is active so far.
# - kind + words - a fragments/words/sentences consolidation round (dive mode by
# default; `mode:` overrides it, see curriculum.ts for which modes fit which kind).
#
# Letter order follows German letter frequency, adapted to a home-row-first, mirrored
# pace for a six-year-old - unchanged from the original course.
worlds:
- number: 1
title: "Die Grundstellung"
emoji: "🏝️"
reward: clownfish
lessons:
- title: "F und J"
subtitle: "Die Zeigefinger - die Tasten mit den Punkten"
keys: [f, j]
- title: "F und J üben"
subtitle: "Die neuen Tasten festigen"
keys: [f, j]
- title: "D und K"
subtitle: "Die Mittelfinger"
keys: [d, k]
- title: "D und K üben"
subtitle: "Die neuen Tasten festigen"
keys: [d, k]
- title: "Übung: F J D K"
subtitle: "Die vier Tasten zusammen"
drill: true
- title: "S und L"
subtitle: "Die Ringfinger"
keys: [s, l]
- title: "S und L üben"
subtitle: "Die neuen Tasten festigen"
keys: [s, l]
- title: "Übung: sechs Tasten"
subtitle: "Alles bisher zusammen"
drill: true
- title: "A und Ö"
subtitle: "Die kleinen Finger"
keys: [a, ö]
- title: "A und Ö üben"
subtitle: "Die neuen Tasten festigen"
keys: [a, ö]
- title: "Übung: die Grundstellung"
subtitle: "Alle acht Finger"
drill: true
- title: "Erste kleine Wörter"
subtitle: "Echte Wörter mit acht Tasten"
kind: fragments
words: [da, ja, das, dass, als, all, fall, falls, lass, saal, kalk, salsa, jass]
- title: "Die Leertaste"
subtitle: "Der Daumen kommt dazu"
keys: [" "]
- title: "Übung: Grundstellung mit Leertaste"
subtitle: "Jetzt mit dem Daumen"
drill: true
- title: "Wörter mit Leertaste"
subtitle: "Kleine Wörter, kleine Sätze"
kind: fragments
words: ["lass das", "das da", "da ja", "fall da", "kalk da", "saal da", "ja lass das", "als da"]
- number: 2
title: "Nach oben"
emoji: "🌊"
reward: octopus
lessons:
- title: "Das E"
subtitle: "Mittelfinger links nach oben"
keys: [e]
- title: "E üben"
subtitle: "Die neue Taste festigen"
keys: [e]
- title: "Das I"
subtitle: "Mittelfinger rechts nach oben"
keys: [i]
- title: "I üben"
subtitle: "Die neue Taste festigen"
keys: [i]
- title: "Übung: E und I"
subtitle: "Beide Mittelfinger nach oben"
drill: true
- title: "Das R"
subtitle: "Zeigefinger links nach oben"
keys: [r]
- title: "R üben"
subtitle: "Die neue Taste festigen"
keys: [r]
- title: "Das U"
subtitle: "Zeigefinger rechts nach oben"
keys: [u]
- title: "U üben"
subtitle: "Die neue Taste festigen"
keys: [u]
- title: "Übung: R und U"
subtitle: "Beide Zeigefinger nach oben"
drill: true
- title: "Kleine Wörter: E I R U"
subtitle: "Erste echte Wörter mit der oberen Reihe"
kind: fragments
words: [die, sie, elf, eis, esel, see, keks, fiel, lied, rad, reis, eier, rufe, feuer, sauer, lauf]
- title: "Das T"
subtitle: "Zeigefinger links weit nach oben"
keys: [t]
- title: "T üben"
subtitle: "Die neue Taste festigen"
keys: [t]
- title: "Das Z"
subtitle: "Zeigefinger rechts weit nach oben"
keys: [z]
- title: "Z üben"
subtitle: "Die neue Taste festigen"
keys: [z]
- title: "Übung: T und Z"
subtitle: "Weit nach oben greifen"
drill: true
- title: "Das O"
subtitle: "Ringfinger rechts nach oben"
keys: [o]
- title: "O üben"
subtitle: "Die neue Taste festigen"
keys: [o]
- title: "Das W"
subtitle: "Ringfinger links nach oben"
keys: [w]
- title: "W üben"
subtitle: "Die neue Taste festigen"
keys: [w]
- title: "Übung: O und W"
subtitle: "Beide Ringfinger nach oben"
drill: true
- title: "Kleine Wörter: T Z O W"
subtitle: "Noch mehr echte Wörter"
kind: fragments
mode: feed
words: [tier, tafel, kette, leiter, zeit, salz, zelt, katze, rot, tor, los, foto, wo, wald, zwei, wolke]
- title: "Das P"
subtitle: "Kleiner Finger rechts nach oben"
keys: [p]
- title: "P üben"
subtitle: "Die neue Taste festigen"
keys: [p]
- title: "Das Q"
subtitle: "Kleiner Finger links nach oben"
keys: [q]
- title: "Q üben"
subtitle: "Die neue Taste festigen"
keys: [q]
- title: "Das Ü"
subtitle: "Kleiner Finger rechts, ganz außen"
keys: [ü]
- title: "Ü üben"
subtitle: "Die neue Taste festigen"
keys: [ü]
- title: "Übung: P Q Ü"
subtitle: "Die kleinen Finger nach oben"
drill: true
- title: "Übung: die obere Reihe"
subtitle: "Die ganze Reihe zusammen"
kind: words
mode: feed
words: [wolke, zeit, pause, prüfe, qualle, torte, reiter, würfel]
- title: "Übung: Welt 2 komplett"
subtitle: "Alles aus der oberen Reihe"
drill: true
- number: 3
title: "Nach unten"
emoji: "🪸"
reward: seahorse
lessons:
- title: "Das N"
subtitle: "Zeigefinger rechts nach unten"
keys: [n]
- title: "N üben"
subtitle: "Die neue Taste festigen"
keys: [n]
- title: "Das M"
subtitle: "Zeigefinger rechts, neben dem N"
keys: [m]
- title: "M üben"
subtitle: "Die neue Taste festigen"
keys: [m]
- title: "Übung: N und M"
subtitle: "Die neuen Tasten festigen"
drill: true
- title: "Das G"
subtitle: "Zeigefinger links, in der Mitte"
keys: [g]
- title: "G üben"
subtitle: "Die neue Taste festigen"
keys: [g]
- title: "Das H"
subtitle: "Zeigefinger rechts, in der Mitte"
keys: [h]
- title: "H üben"
subtitle: "Die neue Taste festigen"
keys: [h]
- title: "Übung: G und H"
subtitle: "Die Mitte der Grundreihe"
drill: true
- title: "Kleine Wörter: N M G H"
subtitle: "Erste echte Wörter nach unten"
kind: fragments
words: [nase, nein, kind, wind, sonne, mama, mond, meer, maus, gut, gans, regen, hase, haus, hund, hupe]
- title: "Das C"
subtitle: "Mittelfinger links nach unten"
keys: [c]
- title: "C üben"
subtitle: "Die neue Taste festigen"
keys: [c]
- title: "Das V"
subtitle: "Zeigefinger links nach unten"
keys: [v]
- title: "V üben"
subtitle: "Die neue Taste festigen"
keys: [v]
- title: "Übung: C und V"
subtitle: "Nach unten greifen"
drill: true
- title: "Das B"
subtitle: "Zeigefinger links, neben dem V"
keys: [b]
- title: "B üben"
subtitle: "Die neue Taste festigen"
keys: [b]
- title: "Das Y"
subtitle: "Kleiner Finger links nach unten"
keys: [y]
- title: "Y üben"
subtitle: "Die neue Taste festigen"
keys: [y]
- title: "Übung: B und Y"
subtitle: "Ganz unten links"
drill: true
- title: "Kleine Wörter: C V B Y"
subtitle: "Noch mehr echte Wörter"
kind: fragments
words: [koch, milch, schule, chaos, vier, vase, voll, vater, baum, boot, bunt, brot, yoga, baby, typ, pony]
- title: "Das X"
subtitle: "Ringfinger links nach unten"
keys: [x]
- title: "X üben"
subtitle: "Die neue Taste festigen"
keys: [x]
- title: "Das Ä"
subtitle: "Kleiner Finger rechts, ganz außen"
keys: [ä]
- title: "Ä üben"
subtitle: "Die neue Taste festigen"
keys: [ä]
- title: "Übung: X und Ä"
subtitle: "Die letzten beiden Tasten"
drill: true
- title: "Übung: alle Buchstaben"
subtitle: "Das ganze Alphabet"
kind: words
mode: race
words: [delfin, wasser, xylofon, bäume, vogel, qualle, muschel, tauchen]
- title: "Übung: Welt 3 komplett"
subtitle: "Alles aus der unteren Reihe"
drill: true
- number: 4
title: "Große Buchstaben"
emoji: "👑"
reward: turtle
lessons:
- title: "Umschalttaste rechts, Teil 1"
subtitle: "Große Buchstaben der linken Hand"
keys: ["⇧"]
kind: words
words: [Delfin, Wal, Fisch, Baum]
- title: "Umschalttaste rechts, Teil 2"
subtitle: "Noch mehr große Buchstaben"
kind: words
words: [Garten, Ente, Vogel, Riff]
- title: "Umschalttaste links, Teil 1"
subtitle: "Große Buchstaben der rechten Hand"
kind: words
words: [Haus, Kind, Mond, Nase]
- title: "Umschalttaste links, Teil 2"
subtitle: "Noch mehr große Buchstaben"
kind: words
words: [Lampe, Onkel, Uhr, Puppe]
- title: "Übung: Namen, Teil 1"
subtitle: "Namen fangen groß an"
kind: words
words: [Anna, Lena, Paul, Mia, Emil, Jonas, Tom, Lisa]
- title: "Übung: Namen, Teil 2"
subtitle: "Noch mehr Namen"
kind: words
mode: feed
words: [Ben, Nora, Finn, Ida, Max, Ella, Oskar, Greta]
- title: "Übung: große und kleine"
subtitle: "Beides gemischt"
kind: words
words: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel", "Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"]
- title: "Übung: Welt 4 komplett"
subtitle: "Groß und klein zusammen"
kind: words
drill: true
words: [Delfin, Haus, Anna, Ben, "Das Meer", "Der Wal", Mond, Riff]
- number: 5
title: "Ganze Sätze"
emoji: "📖"
reward: pearlmussel
lessons:
- title: "Der Punkt"
subtitle: "Ringfinger rechts nach unten"
keys: ["."]
- title: "Punkt üben"
subtitle: "Die neue Taste festigen"
keys: ["."]
- title: "Erste Sätze mit Punkt"
subtitle: "Ein Satz, ein Punkt"
kind: sentences
words:
- "Das Meer ist tief."
- "Der Hund bellt."
- "Ich mag Kekse."
- "Die Sonne scheint."
- "Der Wal ist riesig."
- "Wir gehen baden."
- "Mama liest ein Buch."
- "Der Fisch schwimmt."
- "Heute ist es warm."
- "Ich habe einen Ball."
- "Die Welle ist hoch."
- "Papa kocht Suppe."
- title: "Das Komma"
subtitle: "Mittelfinger rechts nach unten"
keys: [","]
- title: "Komma üben"
subtitle: "Die neue Taste festigen"
keys: [","]
- title: "Sätze mit Komma"
subtitle: "Zwei Gedanken, ein Satz"
kind: sentences
mode: race
words:
- "Ich mag Wale, Delfine und Fische."
- "Erst lesen, dann tippen."
- "Es ist warm, also baden wir."
- "Rot, gelb und blau sind Farben."
- "Wenn es regnet, bleiben wir drinnen."
- "Der Delfin springt, taucht und spielt."
- "Morgen, sagt Papa, fahren wir los."
- "Eins, zwei, drei, vier."
- "Oma, Opa und ich gehen schwimmen."
- "Die Sonne scheint, das Meer glitzert."
- "Muscheln, Steine und Sand liegen am Strand."
- title: "Der Bindestrich"
subtitle: "Kleiner Finger rechts, ganz außen"
keys: ["-"]
- title: "Bindestrich üben"
subtitle: "Die neue Taste festigen"
keys: ["-"]
- title: "Sätze mit Bindestrich"
subtitle: "Zwei Wörter, ein Strich"
kind: sentences
words:
- "Wir spielen mit dem Wasser-Ball."
- "Das ist ein Delfin-Baby."
- "Meine Ur-Oma kommt heute."
- "Wir bauen eine Sand-Burg."
- "Der Fisch-Schwarm ist riesig."
- "Ich trage mein T-Shirt."
- "Das Schwimm-Bad ist offen."
- "Die Bade-Hose ist nass."
- "Wir essen ein Eis-Hörnchen."
- "Das Segel-Boot ist blau."
- "Mein Lieblings-Tier ist der Delfin."
- title: "Fragezeichen und Ausrufezeichen"
subtitle: "Mit der Umschalttaste"
keys: ["ß", "1"]
- title: "Fragezeichen und Ausrufezeichen üben"
subtitle: "Die neuen Tasten festigen"
keys: ["ß", "1"]
- title: "Fragen und Rufe"
subtitle: "Wie klingt ein Satz?"
kind: sentences
words:
- "Wo ist der Delfin?"
- "Das war toll!"
- "Wie geht es dir?"
- "Pass auf!"
- "Kommst du mit?"
- "Der Wal ist so groß!"
- "Hast du Hunger?"
- "Hurra, Ferien!"
- "Was schwimmt da?"
- "Schau mal, ein Hai!"
- "Wie tief ist das Meer?"
- "Wir haben es geschafft!"
- title: "Übung: ganze Sätze"
subtitle: "Alles zusammen"
kind: sentences
drill: true
mode: race
words:
- "Der Delfin schwimmt sehr schnell."
- "Wo ist mein Boot?"
- "Ich tippe jetzt mit zehn Fingern!"
- "Das Meer ist blau, tief und kalt."
- "Kannst du das auch?"
- "Wir bauen eine Sand-Burg am Strand."
- "Die Möwe fliegt über das Wasser."
- "Oma, Opa und ich gehen schwimmen."
- "Das ist ja super!"
- "Wie heißt der große Wal?"
- "Im Riff wohnen bunte Fische."
- "Der Krake hat acht Arme."

View File

@@ -1,236 +0,0 @@
import { describe, expect, it } from "vitest";
import { ELIGIBLE_MODES, FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
import { fingerOf, keyForChar, needsShift } from "../fingers";
describe("LESSONS", () => {
it("has unique ids and consecutive numbers", () => {
const ids = LESSONS.map((lesson) => lesson.id);
expect(new Set(ids).size).toBe(ids.length);
LESSONS.forEach((lesson, i) => expect(lesson.number).toBe(i + 1));
});
it("starts on the two keys with the tactile bumps", () => {
expect(LESSONS[0]!.newKeys).toEqual(["f", "j"]);
expect(FIRST_LESSON_ID).toBe(LESSONS[0]!.id);
});
it("eases in: at most two new keys per lesson, anywhere in the course", () => {
// The first draft opened with all four left-hand keys at once, which is a steep
// first five minutes for a six-year-old. Two at a time, always.
for (const lesson of LESSONS) {
expect(lesson.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
}
});
it("teaches world 1 as mirrored finger pairs, one finger per hand", () => {
const pairs = lessonsOfWorld(1).filter((lesson) => lesson.newKeys.length === 2);
expect(pairs.length).toBe(4);
for (const lesson of pairs) {
const [left, right] = lesson.newKeys.map((key) => fingerOf(key)!);
expect(left!.hand).toBe("left");
expect(right!.hand).toBe("right");
// Same finger on each hand - "die Zeigefinger", "die Mittelfinger", …
expect(left!.id.replace("left-", "")).toBe(right!.id.replace("right-", ""));
}
});
it("has the whole home row active by the end of world 1", () => {
const last = lessonsOfWorld(1).at(-1)!;
for (const key of "asdfjklö") expect(last.activeKeys).toContain(key);
expect(last.activeKeys).toContain(" ");
});
it("makes every round a real block of practice, not a handful of keys", () => {
// The first version ran 12 characters in world 1 - long enough to finish, too short
// for a rhythm to form or for the speed score to mean anything.
for (const lesson of LESSONS) {
if (lesson.kind === "sentences") {
expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(10);
} else if (lesson.kind === "words") {
expect(lesson.chunks, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(25);
} else {
expect(lesson.chunks * lesson.chunkSize, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(60);
}
}
});
it("still starts gentler than it ends", () => {
const length = (lesson: (typeof LESSONS)[number]) => lesson.chunks * lesson.chunkSize;
expect(length(lessonsOfWorld(3)[0]!)).toBeGreaterThan(length(lessonsOfWorld(1)[0]!));
});
it("has more sentences to draw from than a round uses, so a round never has to repeat", () => {
for (const lesson of LESSONS.filter((l) => l.kind === "sentences")) {
expect(lesson.words.length, `${lesson.number}: ${lesson.title}`).toBeGreaterThanOrEqual(lesson.chunks);
}
});
it("grows activeKeys monotonically - no lesson ever loses a key", () => {
let previous: string[] = [];
for (const lesson of LESSONS) {
for (const key of previous) expect(lesson.activeKeys).toContain(key);
previous = [...lesson.activeKeys];
}
});
it("introduces every new key into its own activeKeys", () => {
for (const lesson of LESSONS) {
for (const key of lesson.newKeys) {
if (key === "⇧") continue; // Shift is not a character the generator can emit.
expect(lesson.activeKeys).toContain(key);
}
}
});
it("covers the whole German alphabet plus the umlauts by the end", () => {
const final = new Set(LESSONS.at(-1)!.activeKeys);
for (const key of "abcdefghijklmnopqrstuvwxyzäöü") expect(final.has(key)).toBe(true);
});
it("assigns every active key to a real finger", () => {
for (const lesson of LESSONS) {
for (const key of lesson.activeKeys) expect(fingerOf(key)).not.toBeNull();
}
});
it("only lists words its own activeKeys can type", () => {
// Compared by physical key, not by character: "?" is Shift+ß and "A" is Shift+a,
// so a lesson can type them as soon as it has the ß or a key.
for (const lesson of LESSONS) {
const active = new Set(lesson.activeKeys);
for (const word of lesson.words) {
for (const char of word) {
expect(
active.has(keyForChar(char)),
`Lektion ${lesson.number} (${lesson.title}): "${word}" braucht "${char}" (Taste ${keyForChar(char)})`,
).toBe(true);
}
}
}
});
it("only uses shifted characters once shift is taught", () => {
const shiftFrom = LESSONS.find((lesson) => lesson.newKeys.includes("⇧"))!.number;
for (const lesson of LESSONS) {
for (const word of lesson.words) {
for (const char of word) {
if (!needsShift(char)) continue;
expect(
lesson.number,
`Lektion ${lesson.number}: "${word}" braucht Umschalt für "${char}"`,
).toBeGreaterThanOrEqual(shiftFrom);
}
}
}
});
it("plays a mode that actually fits its kind", () => {
for (const lesson of LESSONS) {
expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(lesson.primaryMode);
for (const mode of lesson.bonusModes) {
expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(mode);
}
}
});
it("never plays the same arcade game twice in a row", () => {
const arcade = LESSONS.filter((lesson) => lesson.kind === "letters");
for (let i = 1; i < arcade.length; i++) {
expect(arcade[i]!.primaryMode, `${arcade[i]!.number}: ${arcade[i]!.title}`).not.toBe(arcade[i - 1]!.primaryMode);
}
});
it("offers every eligible mode this lesson isn't gated on, as a bonus", () => {
for (const lesson of LESSONS) {
const expected = ELIGIBLE_MODES[lesson.kind].filter(
(mode) => (mode === "feed" || mode === "race") && mode !== lesson.primaryMode,
);
expect([...lesson.bonusModes].sort()).toEqual([...expected].sort());
}
});
it("mixes the games instead of always diving - feed and race take a turn as the required mode too", () => {
const words = LESSONS.filter((l) => l.kind === "words");
const sentences = LESSONS.filter((l) => l.kind === "sentences");
expect(words.some((l) => l.primaryMode === "feed")).toBe(true);
expect(words.some((l) => l.primaryMode === "race")).toBe(true);
expect(sentences.some((l) => l.primaryMode === "race")).toBe(true);
});
it("distinguishes a mixed round from a drill - both have no new keys, only one is a review", () => {
const mixed = LESSONS.filter((lesson) => lesson.emphasis === "mixed");
expect(mixed.length).toBeGreaterThan(0);
for (const lesson of mixed) {
expect(lesson.newKeys).toEqual([]);
expect(lesson.isDrill).toBe(false);
}
});
it("gives every isolated round something new to drill", () => {
for (const lesson of LESSONS) {
if (lesson.emphasis === "isolated") expect(lesson.newKeys.length).toBeGreaterThan(0);
}
});
it("teaches capitals only once shift exists", () => {
for (const lesson of LESSONS) {
const hasUppercase = lesson.words.some((word) => word !== word.toLowerCase());
if (hasUppercase) expect(lesson.world).toBeGreaterThanOrEqual(4);
}
});
it("gives every new key a lesson of its own after world 1", () => {
// One key at a time is the pacing decision: world 1 pairs a finger across both
// hands, everything after introduces exactly one key or none.
for (const lesson of LESSONS) {
if (lesson.world === 1) continue;
expect(lesson.newKeys.length, `${lesson.number}: ${lesson.title}`).toBeLessThanOrEqual(2);
}
});
it("follows every pair of new keys with a drill", () => {
const drills = LESSONS.filter((lesson) => lesson.isDrill);
expect(drills.length).toBeGreaterThanOrEqual(15);
// A drill never introduces anything, and always has something to practise.
for (const lesson of drills) {
expect(lesson.newKeys).toEqual([]);
expect(lesson.activeKeys.length).toBeGreaterThan(0);
}
});
it("is long enough to be a real, Duolingo-length course", () => {
expect(LESSONS.length).toBeGreaterThanOrEqual(80);
expect(WORLDS.length).toBe(5);
});
it("never leaves a world without a run of at least three lessons", () => {
for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThanOrEqual(3);
});
});
describe("Worlds", () => {
it("has lessons in every world", () => {
for (const world of WORLDS) expect(lessonsOfWorld(world.number).length).toBeGreaterThan(0);
});
it("gives every world its own aquarium creature", () => {
const rewards = WORLDS.map((world) => world.reward);
expect(new Set(rewards).size).toBe(rewards.length);
});
});
describe("navigation", () => {
it("chains every lesson to the next and stops at the end", () => {
for (let i = 0; i < LESSONS.length - 1; i++) {
expect(nextLesson(LESSONS[i]!.id)?.id).toBe(LESSONS[i + 1]!.id);
}
expect(nextLesson(LESSONS.at(-1)!.id)).toBeNull();
expect(nextLesson("gibt-es-nicht")).toBeNull();
});
it("looks lessons up by id", () => {
expect(lessonById(FIRST_LESSON_ID)?.number).toBe(1);
expect(lessonById("gibt-es-nicht")).toBeNull();
});
});

View File

@@ -1,265 +0,0 @@
import { describe, expect, it } from "vitest";
import { FIRST_LESSON_ID, LESSONS } from "../curriculum";
import { press, startRun } from "../engine";
import { grade } from "../grading";
import type { RunResult } from "../grading";
import {
DILIGENCE_ATTEMPTS,
focusKeyFor,
freshProgress,
mastery,
migrate,
recordRun,
today,
} from "../progress";
import type { Progress } from "../progress";
const L1 = LESSONS[0]!.id;
const L2 = LESSONS[1]!.id;
/** A graded run of `target` with a wrong key at each of `wrongAt`, paced at `tickMs`. */
function runResult(target: string, wrongAt: number[] = [], tickMs = 1000): RunResult {
let state = startRun(target);
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 += tickMs;
}
[state] = press(state, target[i]!, t);
t += tickMs;
}
return grade(state);
}
const perfect = () => runResult("asdfasdfasdfasdf");
const bad = () => runResult("asdfasdfasdfasdf", [0, 1, 2, 3, 4, 5]);
describe("freshProgress", () => {
it("unlocks the first lesson and nothing else", () => {
const progress = freshProgress();
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
const unlocked = LESSONS.filter((lesson) => progress.lessons[lesson.id]!.unlocked);
expect(unlocked).toHaveLength(1);
});
it("knows every lesson in the curriculum", () => {
const progress = freshProgress();
for (const lesson of LESSONS) expect(progress.lessons[lesson.id]).toBeDefined();
});
});
describe("recordRun", () => {
it("unlocks the next lesson on two stars", () => {
const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, perfect());
expect(unlockedLessonId).toBe(L2);
expect(progress.lessons[L2]!.unlocked).toBe(true);
});
it("does not unlock below two stars", () => {
const result = bad();
expect(result.stars).toBeLessThan(2);
const { progress, unlockedLessonId } = recordRun(freshProgress(), L1, result);
expect(unlockedLessonId).toBeNull();
expect(progress.lessons[L2]!.unlocked).toBe(false);
});
it("unlocks after enough tries however bad the score - the diligence rule", () => {
let progress = freshProgress();
for (let i = 0; i < DILIGENCE_ATTEMPTS - 1; i++) {
progress = recordRun(progress, L1, bad()).progress;
expect(progress.lessons[L2]!.unlocked).toBe(false);
}
const { progress: last, unlockedLessonId } = recordRun(progress, L1, bad());
expect(unlockedLessonId).toBe(L2);
expect(last.lessons[L2]!.unlocked).toBe(true);
});
it("never lets the best animal or star count go down", () => {
let progress = recordRun(freshProgress(), L1, perfect()).progress;
const best = progress.lessons[L1]!;
progress = recordRun(progress, L1, bad()).progress;
expect(progress.lessons[L1]!.bestStars).toBe(best.bestStars);
expect(progress.lessons[L1]!.bestPoints).toBe(best.bestPoints);
expect(progress.lessons[L1]!.bestAnimal).toBe(best.bestAnimal);
});
it("counts every run, good or bad", () => {
let progress = freshProgress();
for (let i = 0; i < 3; i++) progress = recordRun(progress, L1, bad()).progress;
expect(progress.lessons[L1]!.runs).toBe(3);
});
it("adds pearls on every run", () => {
const first = recordRun(freshProgress(), L1, bad());
expect(first.progress.pearls).toBeGreaterThan(0);
const second = recordRun(first.progress, L1, bad());
expect(second.progress.pearls).toBeGreaterThan(first.progress.pearls);
});
it("releases a creature only when a world is finished, and only once", () => {
let progress = freshProgress();
// World 1 is lessons 1-3, so finishing lesson 3 is what crosses into world 2.
const world1 = LESSONS.filter((lesson) => lesson.world === 1);
let released: string[] = [];
for (const lesson of world1) {
const outcome = recordRun(progress, lesson.id, perfect());
progress = outcome.progress;
if (outcome.newCreature) released.push(outcome.newCreature);
}
expect(released).toHaveLength(1);
expect(progress.aquarium).toEqual(released);
// Replaying the same lesson must not hand out a second copy.
const again = recordRun(progress, world1.at(-1)!.id, perfect());
expect(again.newCreature).toBeNull();
expect(again.progress.aquarium).toEqual(released);
});
it("records a ghost of the best run for the race", () => {
const { progress } = recordRun(freshProgress(), L1, perfect());
expect(progress.lessons[L1]!.ghost?.length).toBe(perfect().characters);
});
it("builds the daily streak and restarts it after a gap", () => {
let progress = recordRun(freshProgress(), L1, perfect(), "2026-09-11").progress;
expect(progress.streak).toEqual({ days: 1, lastPlayed: "2026-09-11" });
// A second run the same day does not double-count.
progress = recordRun(progress, L1, perfect(), "2026-09-11").progress;
expect(progress.streak.days).toBe(1);
progress = recordRun(progress, L1, perfect(), "2026-09-12").progress;
expect(progress.streak.days).toBe(2);
// A missed day restarts at 1, never at 0.
progress = recordRun(progress, L1, perfect(), "2026-09-20").progress;
expect(progress.streak.days).toBe(1);
});
it("folds keystrokes into the per-key stats", () => {
const { progress } = recordRun(freshProgress(), L1, runResult("asdf", [1]));
expect(progress.keyStats["a"]?.attempts).toBe(1);
expect(progress.keyStats["s"]?.errors).toBe(1);
expect(progress.keyStats["s"]?.attempts).toBe(2);
});
});
describe("migrate", () => {
it("returns a fresh profile for anything unusable", () => {
for (const raw of [null, undefined, 42, "nope", {}, { version: 99 }, []]) {
const progress = migrate(raw);
expect(progress.version).toBe(2);
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
}
});
it("keeps a valid save", () => {
const stored = recordRun(freshProgress(), L1, perfect()).progress;
const restored = migrate(JSON.parse(JSON.stringify(stored)));
expect(restored.lessons[L2]!.unlocked).toBe(true);
expect(restored.pearls).toBe(stored.pearls);
});
it("drops lessons that no longer exist", () => {
const stored = { ...freshProgress(), lessons: { ...freshProgress().lessons, oldLesson: {} } };
expect(migrate(JSON.parse(JSON.stringify(stored))).lessons["oldLesson"]).toBeUndefined();
});
it("moves the emoji pets of old saves into today's aquarium", () => {
const old = { ...freshProgress(), aquarium: ["🐠", "🐙", "🐠", "🦈", 7] };
expect(migrate(JSON.parse(JSON.stringify(old))).aquarium).toEqual(["clownfish", "octopus"]);
});
it("re-unlocks the first lesson even if the save says otherwise", () => {
const broken = freshProgress();
broken.lessons[FIRST_LESSON_ID] = { ...broken.lessons[FIRST_LESSON_ID]!, unlocked: false };
expect(migrate(broken).lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
});
it("restarts the lesson path from an old version, but keeps the aquarium, pearls and streak", () => {
// Version 1's lesson ids don't correspond to today's much finer-grained curriculum,
// so there is nothing sensible to remap them onto - she starts the path over, but
// does not lose what she already earned.
const old = {
version: 1,
lessons: { l09: { unlocked: true, runs: 3, bestStars: 3, bestAnimal: "orca", bestPoints: 999, ghost: null } },
keyStats: { a: { ema: 400, attempts: 10, errors: 1 } },
pearls: 42,
aquarium: ["clownfish", "octopus"],
streak: { days: 5, lastPlayed: "2024-01-01" },
settings: { sound: false, keyboardHint: "off" },
};
const restored = migrate(JSON.parse(JSON.stringify(old)));
expect(restored.version).toBe(2);
// "l09" exists in the new curriculum too, just as a different lesson - its old
// three-star, level-99 progress must not carry over onto whatever l09 means now.
expect(restored.lessons["l09"]).toEqual({ unlocked: false, runs: 0, bestStars: 0, bestAnimal: null, bestPoints: 0, ghost: null });
expect(restored.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
expect(Object.values(restored.lessons).filter((l) => l.unlocked)).toHaveLength(1);
expect(restored.pearls).toBe(42);
expect(restored.aquarium).toEqual(["clownfish", "octopus"]);
expect(restored.keyStats["a"]?.attempts).toBe(10);
expect(restored.streak).toEqual({ days: 5, lastPlayed: "2024-01-01" });
expect(restored.settings).toEqual({ sound: false, keyboardHint: "off" });
});
});
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(freshProgress(), ["a", "s", "d", "f"])).toBeNull();
});
it("picks an unpractised key before a merely slow one", () => {
const progress: Progress = {
...freshProgress(),
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: Progress = {
...freshProgress(),
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(freshProgress(), [" "])).toBeNull();
expect(focusKeyFor(freshProgress(), [])).toBeNull();
});
});
describe("mastery", () => {
it("is zero until a key has been seen enough times", () => {
const progress: Progress = { ...freshProgress(), 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) => ({
...freshProgress(),
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);
});
});
describe("today", () => {
it("formats local time, not UTC", () => {
expect(today(new Date(2026, 8, 11, 23, 30))).toBe("2026-09-11");
expect(today(new Date(2026, 0, 1, 0, 5))).toBe("2026-01-01");
});
});

View File

@@ -1,263 +0,0 @@
/** The lesson plan: content lives in data/curriculum.yaml, this file only derives and
* validates it.
*
* The shape follows what every serious ten-finger course does - start on the home row
* `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S, R, A, T, D, H,
* U, L …), and drill everything learned so far each time. TIPP10's German course is the
* same idea in 18 lessons.
*
* Where this deviates, it deviates for the age and for the Duolingo-style path this app
* wants: every new key gets an **isolated** round (heavy weight, alone) before a
* **mixed** round (lighter weight, blended with everything learned so far) - the same
* new-key / mixed-key / real-content progression typing.com and typingstudy.com use,
* just written as short rounds a six-year-old can climb one at a time. A drill lesson
* follows every couple of keys, and periodically a **fragments** round turns the
* newly-active keys into real short German words - the bridge between drilling letters
* and typing whole words or sentences.
*
* `activeKeys` is cumulative on purpose: lesson 80 still drills `f`, or the first
* lessons rot while the last ones are learned. */
import { parse } from "yaml";
import curriculumYaml from "../data/curriculum.yaml?raw";
import type { CreatureId } from "./aquarium";
import { HOME_ROW, SPACE_KEY } from "./fingers";
/** What a lesson's targets are made of. `fragments` is short real syllables/mini-words -
* the bridge between drilling isolated letters and typing whole words or sentences. */
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
/** Which game a lesson can be played as. `pearls` is gone - every other mode measures
* something, and that one deliberately didn't. */
export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
export interface Lesson {
id: string;
world: number;
number: number;
title: string;
/** What this lesson is about, in words a six-year-old hears read aloud. */
subtitle: string;
kind: LessonKind;
/** The keys that became active *this* lesson - empty for a mixed round or a drill.
* Used to size how gently a round starts (see World 1's mirrored pairs) and to check
* the course never introduces more than it should. */
newKeys: readonly string[];
/** The keys this round is about, whether or not they are new - what the generator
* over-represents. Equal to `newKeys` for an isolated round, the same keys again
* (already active) for the mixed round that follows it, empty for a drill. */
spotlightKeys: readonly string[];
/** Isolated: newly-active keys, drilled alone. Mixed: the same keys, blended with
* everything else. `null` for a drill or for anything that isn't a letters round. */
emphasis: "isolated" | "mixed" | null;
/** Everything typable in this lesson, cumulative. */
activeKeys: readonly string[];
/** The one mode that gates progress this round - passing a run in this mode is what
* can unlock the next lesson. Mostly `dive` for non-letters kinds, but `feed`/`race`
* take a periodic turn so the games stay mixed, not just `dive` end to end. */
primaryMode: ModeId;
/** Extra replays, offered on the result sheet once this lesson is passed. Never gate
* anything - they exist so a favourite game can be played again. */
bonusModes: readonly ModeId[];
/** Real German words, fragments or sentences for the non-letters kinds. */
words: readonly string[];
/** True for a pure review round - no fresh content, just practice. Independent of
* `newKeys`: a mixed round also has no new keys but is not a drill. */
isDrill: boolean;
/** How long one run is. Grows with the curriculum - see `lengthFor`. */
chunks: number;
chunkSize: number;
}
export interface World {
number: number;
title: string;
emoji: string;
/** The creature that moves into the aquarium when this world is finished. */
reward: CreatureId;
}
/** A world's creature is a pet that stays - a drawing that swims behind every screen
* from then on. An animal in grading.ts is a speed trophy that changes, and is an
* emoji. lib/aquarium.ts has why the two are kept in different visual languages. */
const CREATURE_IDS: readonly CreatureId[] = ["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"];
/** Which modes make sense for a kind. Letters rounds are single keys, so only the
* arcade modes fit; fragments/words can be fed to a fish; only words and sentences are
* long enough for a race. */
export const ELIGIBLE_MODES: Record<LessonKind, readonly ModeId[]> = {
letters: ["bubbles", "jellyfish"],
fragments: ["dive", "feed"],
words: ["dive", "feed", "race"],
sentences: ["dive", "race"],
};
/** Line length by world and kind - a full block of text per round. Letters rounds are
* never rendered through `dive` any more (they are bubbles/jellyfish), but the length is
* still computed for type uniformity and so the "still starts gentler than it ends"
* shape is preserved if a letters round is ever asked for one. */
function lengthFor(world: number, kind: LessonKind): { chunks: number; chunkSize: number } {
if (kind === "fragments") return { chunks: 16, chunkSize: 4 }; // 64
if (kind === "sentences") return { chunks: 10, chunkSize: 4 }; // ten whole sentences
if (kind === "words") return { chunks: 25, chunkSize: 4 };
// kind === "letters"
if (world === 1) return { chunks: 24, chunkSize: 3 }; // 72 characters
if (world === 2) return { chunks: 25, chunkSize: 4 }; // 100
return { chunks: 30, chunkSize: 4 }; // 120, world 3 onward
}
interface YamlLesson {
title: string;
subtitle: string;
kind?: LessonKind;
keys?: readonly string[];
drill?: boolean;
mode?: ModeId;
words?: readonly string[];
}
interface YamlWorld {
number: number;
title: string;
emoji: string;
reward: CreatureId;
lessons: readonly YamlLesson[];
}
interface YamlRoot {
worlds: readonly YamlWorld[];
}
/** Turns a YAML typo into a clear startup error instead of a silently wrong lesson. */
function validatePlan(worlds: readonly YamlWorld[]): void {
if (worlds.length !== CREATURE_IDS.length) {
throw new Error(`curriculum.yaml: expected ${CREATURE_IDS.length} worlds, found ${worlds.length}`);
}
const rewards = new Set<CreatureId>();
for (const world of worlds) {
if (rewards.has(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} reuses reward "${world.reward}"`);
if (!CREATURE_IDS.includes(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} has an unknown reward "${world.reward}"`);
rewards.add(world.reward);
world.lessons.forEach((entry, i) => {
const where = `world ${world.number}, lesson ${i + 1} (${entry.title})`;
const words = entry.words ?? [];
const kind: LessonKind = entry.kind ?? "letters";
if (words.length > 0 && entry.kind === undefined) {
throw new Error(`${where}: has words but no explicit kind`);
}
if (kind === "letters" && words.length > 0) throw new Error(`${where}: kind "letters" cannot have words`);
if (kind !== "letters" && words.length === 0) throw new Error(`${where}: kind "${kind}" needs a non-empty words list`);
if (entry.drill && entry.keys?.length) throw new Error(`${where}: a drill cannot also introduce keys`);
if ((entry.keys?.length ?? 0) > 2) throw new Error(`${where}: at most two keys per lesson`);
if (entry.mode && !ELIGIBLE_MODES[kind].includes(entry.mode)) {
throw new Error(`${where}: mode "${entry.mode}" does not fit kind "${kind}"`);
}
});
}
}
function loadPlan(): readonly YamlWorld[] {
const root = parse(curriculumYaml) as YamlRoot;
validatePlan(root.worlds);
return root.worlds;
}
function buildLessons(worlds: readonly YamlWorld[]): Lesson[] {
const lessons: Lesson[] = [];
const active = new Set<string>();
const seenBefore = new Set<string>();
// Letters rounds alternate bubbles/jellyfish across the *whole* course, not per world
// or per pair - a fragments/words lesson in between does not reset the count, so the
// arcade game never repeats twice in a row even across a consolidation gap.
let lastArcade: "bubbles" | "jellyfish" = "jellyfish"; // so lesson 1 opens on bubbles
for (const world of worlds) {
for (const entry of world.lessons) {
const keys = entry.keys ?? [];
const newKeys = keys.filter((key) => !seenBefore.has(key));
for (const key of keys) {
seenBefore.add(key);
active.add(key);
}
// The space-bar lesson is where the whole home row comes together, so it
// activates every home key - belt-and-braces confirmation that the four
// finger-pair lessons before it really did cover all eight.
if (keys.includes(SPACE_KEY)) for (const key of HOME_ROW) active.add(key);
// Shift is not a character the generator can emit - the capitals in the word
// list are what teaches it - so it never enters activeKeys.
const activeKeys = [...active].filter((key) => key !== "⇧").sort();
const words = entry.words ?? [];
const kind: LessonKind = entry.kind ?? "letters";
const isDrill = entry.drill === true;
const emphasis: Lesson["emphasis"] =
kind !== "letters" || isDrill ? null : newKeys.length > 0 ? "isolated" : "mixed";
let primaryMode: ModeId;
if (kind === "letters") {
primaryMode = entry.mode ?? (lastArcade === "bubbles" ? "jellyfish" : "bubbles");
lastArcade = primaryMode as "bubbles" | "jellyfish";
} else {
primaryMode = entry.mode ?? "dive";
}
// Bonus replays are only ever feed/race - dive is the plain default, not a treat
// worth offering separately, and bubbles/jellyfish already alternate on their own.
const bonusModes = ELIGIBLE_MODES[kind].filter(
(mode) => (mode === "feed" || mode === "race") && mode !== primaryMode,
);
lessons.push({
id: `l${String(lessons.length + 1).padStart(2, "0")}`,
world: world.number,
number: lessons.length + 1,
title: entry.title,
subtitle: entry.subtitle,
kind,
newKeys,
spotlightKeys: kind === "letters" && !isDrill ? keys : [],
emphasis,
activeKeys,
primaryMode,
bonusModes,
words,
isDrill,
...lengthFor(world.number, kind),
});
}
}
return lessons;
}
const PLAN = loadPlan();
export const WORLDS: readonly World[] = PLAN.map((world) => ({
number: world.number,
title: world.title,
emoji: world.emoji,
reward: world.reward,
}));
export const LESSONS: readonly Lesson[] = buildLessons(PLAN);
const BY_ID = new Map(LESSONS.map((lesson) => [lesson.id, lesson]));
export function lessonById(id: string): Lesson | null {
return BY_ID.get(id) ?? null;
}
export function lessonsOfWorld(world: number): readonly Lesson[] {
return LESSONS.filter((lesson) => lesson.world === world);
}
/** The lesson after this one, or null at the end of the curriculum. */
export function nextLesson(id: string): Lesson | null {
const index = LESSONS.findIndex((lesson) => lesson.id === id);
if (index < 0) return null;
return LESSONS[index + 1] ?? null;
}
export const FIRST_LESSON_ID = LESSONS[0]!.id;

View File

@@ -1,335 +0,0 @@
/** Everything the game remembers, in one localStorage blob.
*
* The music player deliberately keeps no client state - what is playing lives on the
* backend. This app has no backend at all, so localStorage is the whole store. One key,
* one versioned object, and a `migrate` on read: when the shape changes later, old
* saves upgrade instead of a six-year-old losing her aquarium.
*
* Every read and write is wrapped. A private window, a browser with site data blocked,
* a quota that is full - none of those may stop the game from being playable. They just
* make it forgetful. */
import { creatureFromRaw } from "./aquarium";
import type { CreatureId } from "./aquarium";
import { FIRST_LESSON_ID, LESSONS, WORLDS, nextLesson } from "./curriculum";
import type { RunResult, AnimalId } from "./grading";
import { isBetter } from "./grading";
const STORAGE_KEY = "delfin-tippen:v1";
/** How many attempts at one lesson unlock the next regardless of score. The safety
* valve against getting stuck on a single stubborn key - which at six is the difference
* between a game she returns to and one she does not. */
export const DILIGENCE_ATTEMPTS = 5;
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 {
/** Smoothed reaction time in ms - keybr's exponential moving average. Drives the
* focus key and how fast the on-screen keyboard hint fades out. */
ema: number;
attempts: number;
errors: number;
}
export interface Settings {
sound: boolean;
keyboardHint: "auto" | "on" | "off";
}
export interface Progress {
version: 2;
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;
}
function emptyLesson(unlocked: boolean): LessonProgress {
return { unlocked, runs: 0, bestStars: 0, bestAnimal: null, bestPoints: 0, ghost: null };
}
export function freshProgress(): Progress {
const lessons: Record<string, LessonProgress> = {};
for (const lesson of LESSONS) {
lessons[lesson.id] = emptyLesson(lesson.id === FIRST_LESSON_ID);
}
return {
version: 2,
lessons,
keyStats: {},
pearls: 0,
aquarium: [],
streak: { days: 0, lastPlayed: null },
settings: { sound: true, keyboardHint: "auto" },
};
}
/** Everything that survives a curriculum rebuild - the pets, the currency, the per-key
* stats and the streak - pulled defensively off whatever shape was stored, old or new. */
function carryForward(stored: Record<string, unknown>, fresh: Progress): Omit<Progress, "version" | "lessons"> {
return {
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? (stored.keyStats as Progress["keyStats"]) : {},
pearls: typeof stored.pearls === "number" ? stored.pearls : 0,
aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
streak:
typeof stored.streak === "object" && stored.streak !== null
? {
days: (stored.streak as Progress["streak"]).days ?? 0,
lastPlayed: (stored.streak as Progress["streak"]).lastPlayed ?? null,
}
: fresh.streak,
settings: { ...fresh.settings, ...((stored.settings as Partial<Settings>) ?? {}) },
};
}
/** Bring any stored value up to the current shape. Anything unrecognisable is thrown
* away rather than trusted - a half-valid Progress would crash the lesson map, and a
* fresh one merely means starting over. */
export function migrate(raw: unknown): Progress {
const fresh = freshProgress();
if (typeof raw !== "object" || raw === null) return fresh;
const stored = raw as Record<string, unknown>;
// Version 1's lesson ids don't correspond to today's much finer-grained curriculum -
// "l09" used to be "Das E" and might be anything now - so there is nothing sensible to
// remap. She keeps her aquarium, pearls, key stats and streak, and starts the (longer,
// gentler) path over from the first lesson.
if (stored.version !== 2) {
return { version: 2, lessons: fresh.lessons, ...carryForward(stored, fresh) };
}
const lessons = { ...fresh.lessons };
if (typeof stored.lessons === "object" && stored.lessons !== null) {
for (const [id, value] of Object.entries(stored.lessons as Record<string, unknown>)) {
// Lessons that no longer exist in the curriculum are dropped silently.
if (!(id in lessons) || typeof value !== "object" || value === null) continue;
lessons[id] = { ...emptyLesson(false), ...value };
}
}
// The first lesson is unlocked by definition; a save that says otherwise is wrong.
lessons[FIRST_LESSON_ID] = { ...lessons[FIRST_LESSON_ID]!, unlocked: true };
return { version: 2, lessons, ...carryForward(stored, fresh) };
}
/** Saves from before the pets were drawings hold emoji here. Those map onto the creature
* that fills the same world today; duplicates and anything unrecognisable are dropped,
* so one stray entry cannot put a broken image in the tank. */
function migrateAquarium(raw: readonly unknown[]): CreatureId[] {
const creatures: CreatureId[] = [];
for (const entry of raw) {
const id = creatureFromRaw(entry);
if (id && !creatures.includes(id)) creatures.push(id);
}
return creatures;
}
export function loadProgress(): Progress {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw === null) return freshProgress();
return migrate(JSON.parse(raw));
} catch {
// Unparseable, unreadable, or no storage at all. Play on without memory.
return freshProgress();
}
}
export function saveProgress(progress: Progress): void {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
} catch {
// Quota or a blocked store. Nothing to do and nothing worth telling a child.
}
}
/** Today as YYYY-MM-DD in local time - `toISOString` would roll over at the wrong hour
* and break the streak for anyone playing in the evening. */
export function today(now: Date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
function bumpStreak(streak: Progress["streak"], day: string): Progress["streak"] {
if (streak.lastPlayed === day) return streak;
const yesterday = new Date(`${day}T12:00:00`);
yesterday.setDate(yesterday.getDate() - 1);
const consecutive = streak.lastPlayed === today(yesterday);
// A missed day restarts at 1, never at 0 - playing today always counts for something.
return { days: consecutive ? streak.days + 1 : 1, lastPlayed: day };
}
/** Fold the per-key reaction times of a run into the stored averages. The EMA weight of
* 0.3 is slow enough that one distracted run does not rewrite what she knows. */
function foldKeyStats(stats: Record<string, KeyStat>, result: RunResult): Record<string, KeyStat> {
const next = { ...stats };
let previousAt: number | null = null;
for (const stroke of result.strokes) {
const key = stroke.expected.toLowerCase();
const current = next[key] ?? { ema: 0, attempts: 0, errors: 0 };
const gap = previousAt === null ? null : stroke.at - previousAt;
previousAt = stroke.at;
next[key] = {
// Reaction times over two seconds are a pause for thought or an interruption,
// not a measure of the key, so they are ignored rather than averaged in.
ema: gap !== null && gap < 2000 ? (current.ema === 0 ? gap : current.ema * 0.7 + gap * 0.3) : current.ema,
attempts: current.attempts + 1,
errors: current.errors + (stroke.correct ? 0 : 1),
};
}
return next;
}
/** World number to the creature it releases, read straight off the worlds table so it
* cannot drift from curriculum.ts. */
const WORLD_REWARD = new Map(WORLDS.map((world) => [world.number, world.reward]));
export interface RecordOutcome {
progress: Progress;
/** Set when this run unlocked the following lesson, for the celebration. */
unlockedLessonId: string | null;
/** Set when this run finished a world, for the creature that moved in. */
newCreature: CreatureId | null;
isNewBest: boolean;
}
/** Record a finished run: stars, animal, pearls, key stats, streak, and the unlock.
*
* The unlock rule, in one place: two stars unlocks the next lesson, and so does the
* fifth attempt whatever the score. Speed is nowhere in it. */
export function recordRun(
progress: Progress,
lessonId: string,
result: RunResult,
day: string = today(),
): RecordOutcome {
const before = progress.lessons[lessonId] ?? emptyLesson(true);
const runs = before.runs + 1;
const improved = isBetter(result, { stars: before.bestStars, points: before.bestPoints });
const lessons = { ...progress.lessons };
lessons[lessonId] = {
...before,
runs,
bestStars: improved ? result.stars : before.bestStars,
bestAnimal: improved ? result.animal : before.bestAnimal,
bestPoints: improved ? result.points : before.bestPoints,
ghost: improved
? result.strokes.filter((s) => s.correct).map((s) => ({ key: s.key, at: s.at }))
: before.ghost,
};
const earned = result.passed || runs >= DILIGENCE_ATTEMPTS;
const next = nextLesson(lessonId);
let unlockedLessonId: string | null = null;
if (earned && next && !lessons[next.id]?.unlocked) {
lessons[next.id] = { ...(lessons[next.id] ?? emptyLesson(false)), unlocked: true };
unlockedLessonId = next.id;
}
// Finishing the last lesson of a world releases that world's creature. Checked
// against the aquarium so it is only ever awarded once.
const aquarium = [...progress.aquarium];
let newCreature: CreatureId | null = null;
if (unlockedLessonId && next) {
const finished = LESSONS.find((l) => l.id === lessonId);
if (finished && next.world !== finished.world) {
const reward = WORLD_REWARD.get(finished.world);
if (reward && !aquarium.includes(reward)) {
aquarium.push(reward);
newCreature = reward;
}
}
}
return {
progress: {
...progress,
lessons,
aquarium,
keyStats: foldKeyStats(progress.keyStats, result),
pearls: progress.pearls + result.pearls,
streak: bumpStreak(progress.streak, day),
},
unlockedLessonId,
newCreature,
isNewBest: improved,
};
}
/** 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

@@ -1,11 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/app.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@@ -1 +0,0 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/AppHeader.tsx","./src/components/Aquarium.tsx","./src/components/AquariumCreatures.tsx","./src/components/Bubbles.tsx","./src/components/HelpOverlay.tsx","./src/components/Keyboard.tsx","./src/components/LessonMap.tsx","./src/components/ResultSheet.tsx","./src/components/Stage.tsx","./src/components/Target.tsx","./src/components/modes/BubblesRun.tsx","./src/components/modes/DiveRun.tsx","./src/components/modes/FeedRun.tsx","./src/components/modes/JellyfishRun.tsx","./src/components/modes/RaceRun.tsx","./src/hooks/useRun.ts","./src/lib/aquarium.ts","./src/lib/curriculum.ts","./src/lib/engine.ts","./src/lib/fingers.ts","./src/lib/generator.ts","./src/lib/grading.ts","./src/lib/lessonPath.ts","./src/lib/modeInfo.ts","./src/lib/pop.ts","./src/lib/progress.ts","./src/lib/theme.ts","./src/lib/__tests__/aquarium.test.ts","./src/lib/__tests__/curriculum.test.ts","./src/lib/__tests__/engine.test.ts","./src/lib/__tests__/fingers.test.ts","./src/lib/__tests__/generator.test.ts","./src/lib/__tests__/grading.test.ts","./src/lib/__tests__/lessonPath.test.ts","./src/lib/__tests__/progress.test.ts"],"version":"5.9.3"}

View File

@@ -1,11 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Deliberately standalone: no proxy, no backend, no shared build with ../web. The game
// is pure client state (localStorage), so `vite dev` on its own is the whole runtime.
// Port 5174 so it can run next to the music player's 5173 without a clash.
export default defineConfig({
plugins: [react()],
server: { port: 5174 },
build: { outDir: "dist", emptyOutDir: true },
});

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -22,6 +22,8 @@ import { PlayerBar } from "./components/PlayerBar";
import { PlayView } from "./components/PlayView"; import { PlayView } from "./components/PlayView";
import { RemoteAssignPopup } from "./components/RemoteAssignPopup"; import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
import { RoomView } from "./components/RoomView"; import { RoomView } from "./components/RoomView";
import { TabRail } from "./components/TabRail";
import { TippenApp } from "./components/TippenApp";
import { useGridColumns } from "./hooks/useGridColumns"; import { useGridColumns } from "./hooks/useGridColumns";
import { useLibrary } from "./hooks/useLibrary"; import { useLibrary } from "./hooks/useLibrary";
import { usePlayerState } from "./hooks/usePlayerState"; import { usePlayerState } from "./hooks/usePlayerState";
@@ -401,8 +403,8 @@ export function App() {
} }
run(browseBackActions(ui)); run(browseBackActions(ui));
}; };
const onToggleRoom = () => const onSelectPage = (page: UiState["page"]) =>
setUi((previous) => ({ ...previous, page: previous.page === "room" ? "music" : "room" })); setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
return ( return (
<div className="stage" data-blur={SHOW_GLASS_BLUR ? undefined : "off"}> <div className="stage" data-blur={SHOW_GLASS_BLUR ? undefined : "off"}>
@@ -497,6 +499,10 @@ export function App() {
{ui.page === "room" && haConfig && <RoomView config={haConfig} />} {ui.page === "room" && haConfig && <RoomView config={haConfig} />}
{ui.page === "typing" && (
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
)}
{openAlbum && state && ( {openAlbum && state && (
<AlbumModal <AlbumModal
album={openAlbum} album={openAlbum}
@@ -522,15 +528,7 @@ export function App() {
</CornerButton> </CornerButton>
)} )}
{haConfig && ( <TabRail page={ui.page} onSelect={onSelectPage} showRoom={haConfig != null} />
<CornerButton
side="right"
onClick={onToggleRoom}
label={ui.page === "room" ? "Musik" : "Mein Zimmer"}
>
{ui.page === "room" ? "🎵" : "💡"}
</CornerButton>
)}
{ui.showHelp && ( {ui.showHelp && (
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} /> <HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />

View File

@@ -9,6 +9,11 @@ import type {
RemoteMapping, RemoteMapping,
RemoteSlotInput, RemoteSlotInput,
Settings, Settings,
TippenCurriculum,
TippenProgress,
TippenRunInput,
TippenRunResult,
TippenSettings,
TrackDetail, TrackDetail,
} from "./types"; } from "./types";
@@ -53,6 +58,11 @@ const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>
/** `null` means the IR remote isn't configured, not an error. */ /** `null` means the IR remote isn't configured, not an error. */
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc"); const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc");
/** `null` means the typing game isn't configured, not an error - the tab still shows,
* just with a "not set up" placeholder instead of a lesson map. */
const fetchTippenCurriculum = (): Promise<TippenCurriculum | null> =>
fetchOrNullOn404<TippenCurriculum>("/tippen/curriculum");
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable /** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
* right now" (the backend answers the latter with a 502) - the room page treats a * right now" (the backend answers the latter with a 502) - the room page treats a
* device with no state the same way either way, rather than crashing on a poll. */ * device with no state the same way either way, rather than crashing on a poll. */
@@ -103,6 +113,13 @@ export const api = {
remoteMapping: () => request<RemoteMapping>("/remote/mapping"), remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) => saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }), request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
tippenCurriculum: fetchTippenCurriculum,
tippenProgress: () => request<TippenProgress>("/tippen/progress"),
saveTippenSettings: (settings: TippenSettings) =>
request<TippenSettings>("/tippen/settings", { method: "PUT", body: JSON.stringify(settings) }),
recordTippenRun: (body: TippenRunInput) =>
request<TippenRunResult>("/tippen/runs", { method: "POST", body: JSON.stringify(body) }),
}; };
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`; export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;

View File

@@ -34,11 +34,24 @@ export interface TrackDetail {
curve: TrackCurves | null; curve: TrackCurves | null;
} }
/** Which typing lesson unlocks a still-locked track - the browse view's "unlocks
* when..." hint. Mirrors `UnlockHintOut`. */
export interface UnlockHint {
lesson_id: string;
lesson_title: string;
world_number: number;
world_title: string;
}
export interface Track { export interface Track {
title: string; title: string;
/** Seconds, read from the file's tags at scan time. */ /** Seconds, read from the file's tags at scan time. */
duration: number; duration: number;
analysis: TrackAnalysis | null; analysis: TrackAnalysis | null;
/** A typing-reward track not yet earned. Only ever `true` when the typing game is
* configured on the backend. */
locked: boolean;
unlock_hint: UnlockHint | null;
} }
export interface Album { export interface Album {
@@ -56,6 +69,8 @@ export interface Album {
has_cover: boolean; has_cover: boolean;
duration: number; duration: number;
tracks: Track[]; tracks: Track[];
/** Every track is still locked - show a question mark instead of cover art. */
locked: boolean;
} }
export interface PlayerState { export interface PlayerState {
@@ -135,3 +150,131 @@ export interface RemoteSlotInput {
export interface LircConfig { export interface LircConfig {
connected: boolean; connected: boolean;
} }
// --------------------------------------------------------------------------- tippen
//
// The typing game. `null` from `GET /tippen/curriculum` means the whole feature isn't
// configured, like `HaConfig` above - the tab still shows, just with nothing behind it.
// Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`.
export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences";
export type TippenModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
/** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the
* configured path matches nothing in the current library. */
export interface TippenReward {
resolved: boolean;
album_id: string | null;
has_cover: boolean;
kind: "tracks" | "episode" | null;
}
export interface TippenLesson {
id: string;
world: number;
number: number;
title: string;
subtitle: string;
kind: TippenLessonKind;
new_keys: string[];
spotlight_keys: string[];
emphasis: "isolated" | "mixed" | null;
active_keys: string[];
primary_mode: TippenModeId;
bonus_modes: TippenModeId[];
words: string[];
is_drill: boolean;
chunks: number;
chunk_size: number;
reward: TippenReward;
}
export interface TippenWorld {
number: number;
title: string;
emoji: string;
reward: string;
}
export interface TippenCurriculum {
worlds: TippenWorld[];
lessons: TippenLesson[];
}
export interface TippenGhostStroke {
key: string;
at: number;
}
export interface TippenLessonProgress {
unlocked: boolean;
runs: number;
best_stars: 0 | 1 | 2 | 3;
best_animal: string | null;
best_points: number;
/** Derived server-side, not stored - two stars, or five attempts regardless. */
earned: boolean;
ghost: TippenGhostStroke[] | null;
}
export interface TippenKeyStat {
ema: number;
attempts: number;
errors: number;
}
export interface TippenStreak {
days: number;
last_played: string | null;
}
export interface TippenSettings {
sound: boolean;
keyboard_hint: "auto" | "on" | "off";
}
export interface TippenProgress {
lessons: Record<string, TippenLessonProgress>;
key_stats: Record<string, TippenKeyStat>;
pearls: number;
aquarium: string[];
streak: TippenStreak;
settings: TippenSettings;
}
export interface TippenStroke {
key: string;
expected: string;
correct: boolean;
at: number;
}
/** A run the client already graded - see `lib/tippen/grading.ts`. Grading stays
* client-side; the backend only owns progress bookkeeping. */
export interface TippenRunInput {
lesson_id: string;
stars: 0 | 1 | 2 | 3;
animal: string;
points: number;
passed: boolean;
pearls: number;
strokes: TippenStroke[];
}
/** The literal track/episode this run's lesson names in its own `unlocks:` - what the
* unlock animation shows, via the existing `/api/albums/{id}/cover`. */
export interface TippenUnlockedReward {
album_id: string;
title: string;
has_cover: boolean;
kind: "album" | "book" | "podcast_episode";
}
export interface TippenRunResult {
progress: TippenProgress;
unlocked_lesson_id: string | null;
unlocked_lesson_title: string | null;
new_creature: string | null;
is_new_best: boolean;
unlocked_reward: TippenUnlockedReward | null;
}

View File

@@ -67,7 +67,13 @@ export function AlbumModal({
> >
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}> <div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
<div style={{ width: 150, flex: "none" }}> <div style={{ width: 150, flex: "none" }}>
<Cover album={album} size={150} radius={book ? "10px 18px 18px 10px" : "16px"} label /> <Cover
album={album}
size={150}
radius={book ? "10px 18px 18px 10px" : "16px"}
label
locked={album.locked}
/>
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div <div
@@ -103,13 +109,14 @@ export function AlbumModal({
{album.figure && ` · 🧸 ${album.figure}`} {album.figure && ` · 🧸 ${album.figure}`}
</div> </div>
<button <button
onClick={() => onPlay(0)} onClick={() => !album.locked && onPlay(0)}
disabled={album.locked}
style={{ style={{
marginTop: 14, marginTop: 14,
border: "none", border: "none",
cursor: "pointer", cursor: album.locked ? "default" : "pointer",
background: "var(--accent)", background: album.locked ? "oklch(80% 0.02 210)" : "var(--accent)",
color: "#fff", color: album.locked ? "oklch(40% 0.03 210)" : "#fff",
fontSize: 16, fontSize: 16,
fontWeight: 800, fontWeight: 800,
padding: "12px 22px", padding: "12px 22px",
@@ -117,9 +124,13 @@ export function AlbumModal({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
boxShadow: "0 4px 14px oklch(70% 0.16 340 / .45)", boxShadow: album.locked ? "none" : "0 4px 14px oklch(70% 0.16 340 / .45)",
}} }}
> >
{album.locked ? (
"❓ Noch nicht freigeschaltet"
) : (
<>
<span <span
style={{ style={{
width: 14, width: 14,
@@ -129,6 +140,8 @@ export function AlbumModal({
}} }}
/> />
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"} {book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
</>
)}
</button> </button>
</div> </div>
<button <button
@@ -158,17 +171,19 @@ export function AlbumModal({
<button <button
key={index} key={index}
data-nav-index={index} data-nav-index={index}
onClick={() => onPlay(index)} onClick={() => !track.locked && onPlay(index)}
disabled={track.locked}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 14, gap: 14,
padding: "10px 14px", padding: "10px 14px",
borderRadius: 14, borderRadius: 14,
cursor: "pointer", cursor: track.locked ? "default" : "pointer",
border: "none", border: "none",
font: "inherit", font: "inherit",
textAlign: "left", textAlign: "left",
opacity: track.locked ? 0.55 : 1,
background: current background: current
? "oklch(70% 0.16 340 / .16)" ? "oklch(70% 0.16 340 / .16)"
: "oklch(30% 0.03 210 / .05)", : "oklch(30% 0.03 210 / .05)",
@@ -190,19 +205,33 @@ export function AlbumModal({
color: current ? "#fff" : "oklch(35% 0.03 210)", color: current ? "#fff" : "oklch(35% 0.03 210)",
}} }}
> >
{index + 1} {track.locked ? "❓" : index + 1}
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}>
<div <div
style={{ style={{
flex: 1,
minWidth: 0,
fontSize: 16, fontSize: 16,
fontWeight: 800, fontWeight: 800,
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)", color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
}} }}
> >
{track.title} {track.locked ? "❓ Noch gesperrt" : track.title}
</div> </div>
{track.locked && track.unlock_hint && (
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "oklch(45% 0.03 210 / .8)",
marginTop: 2,
}}
>
Freigeschaltet nach {track.unlock_hint.lesson_title}" (Welt{" "}
{track.unlock_hint.world_number})
</div>
)}
</div>
{!track.locked && (
<div <div
style={{ style={{
font: "700 13px ui-monospace, Menlo, monospace", font: "700 13px ui-monospace, Menlo, monospace",
@@ -211,6 +240,7 @@ export function AlbumModal({
> >
{clock(track.duration)} {clock(track.duration)}
</div> </div>
)}
</button> </button>
); );
})} })}

View File

@@ -425,6 +425,7 @@ export function BrowseView({
// Fixed regardless of how many lines the title actually needs, so // Fixed regardless of how many lines the title actually needs, so
// every card - and thus every row of covers - is the same height. // every card - and thus every row of covers - is the same height.
const titleLines = showArtist ? 2 : 3; const titleLines = showArtist ? 2 : 3;
const hint = album.locked ? (album.tracks[0]?.unlock_hint ?? null) : null;
return ( return (
<div <div
key={album.id} key={album.id}
@@ -432,6 +433,7 @@ export function BrowseView({
data-nav-index={navIndex} data-nav-index={navIndex}
data-selected={selected === navIndex} data-selected={selected === navIndex}
data-current={album.id === currentAlbumId} data-current={album.id === currentAlbumId}
data-locked={album.locked}
style={{ style={{
background: cardBackground(album), background: cardBackground(album),
borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px", borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px",
@@ -439,26 +441,34 @@ export function BrowseView({
}} }}
> >
<button <button
onClick={() => onOpenAlbum(album, navIndex)} onClick={() => !album.locked && onOpenAlbum(album, navIndex)}
aria-label={podcast ? `${album.title} abspielen` : `${album.title}: Titel wählen`} disabled={album.locked}
aria-label={
album.locked
? `${album.title}: noch nicht freigeschaltet`
: podcast
? `${album.title} abspielen`
: `${album.title}: Titel wählen`
}
style={{ style={{
display: "flex", display: "flex",
width: "100%", width: "100%",
border: "none", border: "none",
padding: 0, padding: 0,
cursor: "pointer", cursor: album.locked ? "default" : "pointer",
}} }}
> >
<Cover album={album} size={180} radius="0" label /> <Cover album={album} size={180} radius="0" label locked={album.locked} />
</button> </button>
<button <button
onClick={() => onPlayAlbum(album, navIndex)} onClick={() => !album.locked && onPlayAlbum(album, navIndex)}
aria-label={`${album.title} abspielen`} disabled={album.locked}
aria-label={album.locked ? `${album.title}: noch nicht freigeschaltet` : `${album.title} abspielen`}
style={{ style={{
display: "block", display: "block",
width: "100%", width: "100%",
border: "none", border: "none",
cursor: "pointer", cursor: album.locked ? "default" : "pointer",
textAlign: "left", textAlign: "left",
font: "inherit", font: "inherit",
background: "none", background: "none",
@@ -466,6 +476,34 @@ export function BrowseView({
paddingRight: isBook(album) ? 22 : 12, paddingRight: isBook(album) ? 22 : 12,
}} }}
> >
{album.locked ? (
<>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "oklch(22% 0.03 210)",
lineHeight: 1.2,
}}
>
Geheimnis
</div>
{hint && (
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "oklch(40% 0.03 210)",
marginTop: 4,
lineHeight: 1.3,
}}
>
Freigeschaltet nach {hint.lesson_title}" (Welt {hint.world_number})
</div>
)}
</>
) : (
<>
<div <div
style={{ style={{
fontSize: 16, fontSize: 16,
@@ -507,6 +545,8 @@ export function BrowseView({
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)} {podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
{album.figure && " · 🧸"} {album.figure && " · 🧸"}
</div> </div>
</>
)}
</button> </button>
</div> </div>
); );

View File

@@ -26,9 +26,12 @@ interface Props {
style?: CSSProperties; style?: CSSProperties;
/** Print the title over generated art. Off for thumbnails, where it would not fit. */ /** Print the title over generated art. Off for thumbnails, where it would not fit. */
label?: boolean; label?: boolean;
/** A reward-gated album/track not yet earned - shows a question mark instead of the
* real art or title, regardless of `has_cover`. */
locked?: boolean;
} }
export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) { export function Cover({ album, size, fit = "width", radius, className, style, label, locked }: Props) {
const book = isBook(album); const book = isBook(album);
const defaultRadius = book ? "6px 18px 18px 6px" : "16px"; const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
const box: CSSProperties = const box: CSSProperties =
@@ -49,6 +52,23 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
...style, ...style,
}} }}
> >
{locked ? (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: Math.max(22, Math.round(size / 2.2)),
color: "oklch(99% 0 0 / .85)",
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
}}
>
</div>
) : (
<>
{!album.has_cover && label && ( {!album.has_cover && label && (
<div <div
style={{ style={{
@@ -81,12 +101,14 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
width: "100%", width: "100%",
height: "100%", height: "100%",
objectFit: "cover", objectFit: "cover",
// Books keep a sliver of spine showing on the right, so the shelf metaphor // Books keep a sliver of spine showing on the right, so the shelf
// survives contact with real square artwork. // metaphor survives contact with real square artwork.
clipPath: book ? "inset(0 5% 0 0)" : undefined, clipPath: book ? "inset(0 5% 0 0)" : undefined,
}} }}
/> />
)} )}
</>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,65 @@
/** The tab rail: icon-only, vertically centered on the right edge - audio player and
* typing always shown, the smarthome tab only when Home Assistant is configured, same
* gate the old single toggle button used. Replaces that button now that there are
* three destinations instead of two. */
import { PAGE_ICON, PAGE_LABEL } from "../lib/theme";
import type { UiState } from "../lib/keyboard";
interface Props {
page: UiState["page"];
onSelect: (page: UiState["page"]) => void;
showRoom: boolean;
}
const PAGES: readonly UiState["page"][] = ["music", "typing", "room"];
export function TabRail({ page, onSelect, showRoom }: Props) {
const pages = PAGES.filter((candidate) => candidate !== "room" || showRoom);
return (
<div
style={{
position: "absolute",
top: "50%",
right: 24,
transform: "translateY(-50%)",
zIndex: 3,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{pages.map((candidate) => {
const active = candidate === page;
return (
<button
key={candidate}
onClick={() => onSelect(candidate)}
aria-label={PAGE_LABEL[candidate]}
aria-current={active}
title={PAGE_LABEL[candidate]}
style={{
width: 44,
height: 44,
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "none",
borderRadius: 999,
background: active ? "var(--accent)" : "oklch(97% 0.01 210 / .95)",
boxShadow: "0 6px 18px oklch(15% 0.05 210 / .4)",
fontSize: 20,
fontWeight: 900,
color: active ? "#fff" : "var(--ink)",
cursor: "pointer",
transition: "background 0.15s ease, color 0.15s ease",
}}
>
{PAGE_ICON[candidate]}
</button>
);
})}
</div>
);
}

View File

@@ -1,55 +1,54 @@
/** The whole app: one state object, one keydown listener for navigation, four screens. /** The typing game, as a tab of the music player rather than its own app.
* *
* The same shape as ../../web/src/App.tsx - state lives here, components are layout, and * Ported from the standalone tippen app's App.tsx - same shape (one state object, one
* the keyboard is handled in one place rather than scattered through the tree. * keydown listener for navigation, four screens) - with two changes: curriculum and
* progress are now fetched from the backend instead of a build-time YAML import and
* localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and
* `onExit` is the new base case for "back" - Escape/the map's own navigation peel one
* layer at a time, same as before, but the aquarium screen is no longer the floor: one
* more Escape leaves the tab entirely, back to the music player.
* *
* One rule is worth stating because it is easy to break later: **while a run is going, * The rule that still matters most: **while a run is going, every key belongs to the
* every key belongs to the run**. Only Escape and F1 are intercepted here. Otherwise a * run**. This component's own keydown listener only ever intercepts Escape and F1 - the
* lesson that happens to teach `m` would mute the game every time she typed it, and the * run's own listener lives in hooks/useTippenRun.ts. The music player's own global
* mute would look to her like the game breaking. The run's own listener lives in * keydown listener (lib/keyboard.ts) is kept out of this entirely: it bails out
* hooks/useRun.ts and does the typing; this one only handles the screens around it. */ * immediately whenever the typing tab is active, so the two never fight over a key. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Aquarium } from "./components/Aquarium"; import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
import { AppHeader } from "./components/AppHeader"; import { useTippenProgress } from "../hooks/useTippenProgress";
import { HelpOverlay } from "./components/HelpOverlay"; import { Aquarium } from "./tippen/Aquarium";
import { LessonMap } from "./components/LessonMap"; import { AppHeader } from "./tippen/AppHeader";
import { ResultSheet } from "./components/ResultSheet"; import { HelpOverlay } from "./tippen/HelpOverlay";
import { Stage } from "./components/Stage"; import { LessonMap } from "./tippen/LessonMap";
import { BubblesRun } from "./components/modes/BubblesRun"; import { ResultSheet } from "./tippen/ResultSheet";
import { FeedRun } from "./components/modes/FeedRun"; import { Stage } from "./tippen/Stage";
import { JellyfishRun } from "./components/modes/JellyfishRun"; import { BubblesRun } from "./tippen/modes/BubblesRun";
import { RaceRun } from "./components/modes/RaceRun"; import { DiveRun } from "./tippen/modes/DiveRun";
import { DiveRun } from "./components/modes/DiveRun"; import { FeedRun } from "./tippen/modes/FeedRun";
import type { CreatureId } from "./lib/aquarium"; import { JellyfishRun } from "./tippen/modes/JellyfishRun";
import { LESSONS, lessonById, nextLesson } from "./lib/curriculum"; import { RaceRun } from "./tippen/modes/RaceRun";
import type { Lesson, ModeId } from "./lib/curriculum"; import type { CreatureId } from "../lib/tippen/aquarium";
import { lineFor, lineText, letterStream, mulberry32 } from "./lib/generator"; import type { Lesson, ModeId } from "../lib/tippen/curriculum";
import type { RunResult } from "./lib/grading"; import { lessonById, nextLesson } from "../lib/tippen/curriculum";
import { playFanfare, playPop } from "./lib/pop"; import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
import { overallBestAnimal, focusKeyFor, loadProgress, recordRun, saveProgress } from "./lib/progress"; import type { RunResult } from "../lib/tippen/grading";
import type { Progress } from "./lib/progress"; import { playFanfare, playPop } from "../lib/tippen/pop";
import { bubbleCountFor } from "./lib/theme"; import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress";
import type { UnlockedReward } from "../lib/tippen/progress";
import { bubbleCountFor } from "../lib/tippen/theme";
type Screen = "aquarium" | "map" | "run"; type Screen = "aquarium" | "map" | "run";
/** What a mode is handed to draw. Tagged rather than optional-fielded so the render /** What a mode is handed to draw - see the original App.tsx for why there are only two
* below narrows on `kind` instead of guessing from which keys are present. * shapes for five modes. */
*
* Only two shapes, for five modes: the arcade modes want a stream of single letters, and
* everything else wants a line of chunks. That is the whole reason the modes are cheap
* to add - they are presentations of one of two targets, all driven by the same engine. */
type RunTarget = type RunTarget =
| { kind: "letters"; letters: readonly string[] } | { kind: "letters"; letters: readonly string[] }
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean }; | { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
/** Modes that drill one key at a time rather than a line. */
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"]; const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
/** How much of a letters round the spotlighted key(s) should take. An isolated round -
* the key's very first lesson - drills it hard; the mixed round right after blends it
* back in with everything else, which is the whole point of "isolated then mixed". */
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 }; const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
interface Outcome { interface Outcome {
@@ -57,10 +56,18 @@ interface Outcome {
unlockedTitle: string | null; unlockedTitle: string | null;
newCreature: CreatureId | null; newCreature: CreatureId | null;
isNewBest: boolean; isNewBest: boolean;
unlockedReward: UnlockedReward | null;
} }
export function App() { interface Props {
const [progress, setProgress] = useState<Progress>(loadProgress); onExit: () => void;
}
export function TippenApp({ onExit }: Props) {
const { curriculum, loading: curriculumLoading } = useTippenCurriculum();
const configured = curriculum !== null;
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
const [screen, setScreen] = useState<Screen>("aquarium"); const [screen, setScreen] = useState<Screen>("aquarium");
const [lessonId, setLessonId] = useState<string | null>(null); const [lessonId, setLessonId] = useState<string | null>(null);
const [mode, setMode] = useState<ModeId>("dive"); const [mode, setMode] = useState<ModeId>("dive");
@@ -70,20 +77,19 @@ export function App() {
/** Bumped to generate a fresh line - a new seed for the same lesson. */ /** Bumped to generate a fresh line - a new seed for the same lesson. */
const [round, setRound] = useState(0); const [round, setRound] = useState(0);
useEffect(() => saveProgress(progress), [progress]); const lesson = lessonId !== null && curriculum ? lessonById(curriculum, lessonId) : null;
const lesson = lessonId === null ? null : lessonById(lessonId);
/** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */ /** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */
const nextUp = useMemo(() => { const nextUp = useMemo(() => {
const unlocked = LESSONS.filter((l) => progress.lessons[l.id]?.unlocked); if (!curriculum || !progress) return null;
const unlocked = curriculum.lessons.filter((l) => progress.lessons[l.id]?.unlocked);
return unlocked.find((l) => (progress.lessons[l.id]?.bestStars ?? 0) < 2) ?? unlocked.at(-1) ?? null; return unlocked.find((l) => (progress.lessons[l.id]?.bestStars ?? 0) < 2) ?? unlocked.at(-1) ?? null;
}, [progress]); }, [curriculum, progress]);
/** The line for this run. Reproducible from the lesson, the mode and the round /** The line for this run. Reproducible from the lesson, the mode and the round
* counter, so a re-render never reshuffles the text mid-run. */ * counter, so a re-render never reshuffles the text mid-run. */
const run = useMemo((): RunTarget | null => { const run = useMemo((): RunTarget | null => {
if (!lesson) return null; if (!lesson || !progress) return null;
const seed = lesson.number * 1000 + round * 7 + (mode === "bubbles" ? 3 : 0); const seed = lesson.number * 1000 + round * 7 + (mode === "bubbles" ? 3 : 0);
const rng = mulberry32(seed); const rng = mulberry32(seed);
const focusKey = focusKeyFor(progress, lesson.activeKeys); const focusKey = focusKeyFor(progress, lesson.activeKeys);
@@ -105,15 +111,9 @@ export function App() {
), ),
}; };
} }
const chunks = lineFor(lesson, rng, { const chunks = lineFor(lesson, rng, { chunks: lesson.chunks, chunkSize: lesson.chunkSize, focusKey });
chunks: lesson.chunks,
chunkSize: lesson.chunkSize,
focusKey,
});
return { kind: "text", chunks, text: lineText(chunks, spaceActive), spaceActive }; return { kind: "text", chunks, text: lineText(chunks, spaceActive), spaceActive };
// `progress` is deliberately not a dependency: the focus key is read once when the // `progress` is deliberately not a dependency - see the original App.tsx.
// line is built, and re-reading it after every keystroke would rebuild the line
// underneath her fingers.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [lesson, mode, round]); }, [lesson, mode, round]);
@@ -127,22 +127,24 @@ export function App() {
const onFinished = useCallback( const onFinished = useCallback(
(result: RunResult) => { (result: RunResult) => {
if (!lessonId) return; if (!lessonId || !progress) return;
const recorded = recordRun(progress, lessonId, result); void recordRun(lessonId, result).then((recorded) => {
setProgress(recorded.progress);
setOutcome({ setOutcome({
result, result,
unlockedTitle: recorded.unlockedLessonId unlockedTitle: recorded.unlockedLessonTitle,
? (lessonById(recorded.unlockedLessonId)?.title ?? null)
: null,
newCreature: recorded.newCreature, newCreature: recorded.newCreature,
isNewBest: recorded.isNewBest, isNewBest: recorded.isNewBest,
unlockedReward: recorded.unlockedReward,
}); });
if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) { if (
progress.settings.sound &&
(recorded.unlockedLessonId || recorded.newCreature || recorded.unlockedReward)
) {
playFanfare(); playFanfare();
} }
});
}, },
[lessonId, progress], [lessonId, progress, recordRun],
); );
const retry = useCallback(() => { const retry = useCallback(() => {
@@ -150,10 +152,9 @@ export function App() {
setRound((r) => r + 1); setRound((r) => r + 1);
}, []); }, []);
/** A bonus replay in a mode this lesson didn't gate progress on - feed or race, /** A bonus replay in a mode this lesson didn't gate progress on - never touches the
* offered on the result sheet once the lesson is passed. Never touches the unlock: * unlock: `onFinished` still runs underneath, so a great bonus run can only improve
* `onFinished` still runs underneath, so a great bonus run can only improve the best * the best score, not change what is unlocked. */
* score, not change what is unlocked. */
const playBonus = useCallback((bonusMode: ModeId) => { const playBonus = useCallback((bonusMode: ModeId) => {
setMode(bonusMode); setMode(bonusMode);
setOutcome(null); setOutcome(null);
@@ -161,22 +162,23 @@ export function App() {
}, []); }, []);
const continueAfterResult = useCallback(() => { const continueAfterResult = useCallback(() => {
const next = lessonId ? nextLesson(lessonId) : null; const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
setOutcome(null); setOutcome(null);
if (next && progress.lessons[next.id]?.unlocked) start(next); if (next && progress?.lessons[next.id]?.unlocked) start(next);
else setScreen("map"); else setScreen("map");
}, [lessonId, progress, start]); }, [lessonId, curriculum, progress, start]);
const goBack = useCallback(() => { const goBack = useCallback(() => {
if (outcome) return setOutcome(null); if (outcome) return setOutcome(null);
if (screen === "run") return setScreen("map"); if (screen === "run") return setScreen("map");
if (screen === "map") return setScreen("aquarium"); if (screen === "map") return setScreen("aquarium");
}, [outcome, screen]); if (screen === "aquarium") return onExit();
}, [outcome, screen, onExit]);
// --- navigation keys ----------------------------------------------------- // --- navigation keys -----------------------------------------------------
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start }); const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start, curriculum });
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start }; latest.current = { screen, outcome, selected, goBack, retry, nextUp, start, curriculum };
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -206,25 +208,26 @@ export function App() {
// Everything below is navigation, and must not fire while typing. // Everything below is navigation, and must not fire while typing.
if (current.screen === "run") return; if (current.screen === "run") return;
if (!current.curriculum) return;
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
if (current.screen === "aquarium") { if (current.screen === "aquarium") {
if (current.nextUp) current.start(current.nextUp); if (current.nextUp) current.start(current.nextUp);
} else { } else {
const lesson = LESSONS[current.selected]; const lesson = current.curriculum.lessons[current.selected];
if (lesson) current.start(lesson); if (lesson) current.start(lesson);
} }
return; return;
} }
if (current.screen !== "map") return; if (current.screen !== "map") return;
// The map is a vertical path now, so "next" is down rather than to the right. // The map is a vertical path, so "next" is down rather than to the right.
const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
if (step !== 0) { if (step !== 0) {
event.preventDefault(); event.preventDefault();
playPop(340); playPop(340);
setSelected((index) => Math.min(LESSONS.length - 1, Math.max(0, index + step))); setSelected((index) => Math.min(current.curriculum!.lessons.length - 1, Math.max(0, index + step)));
} }
}; };
@@ -236,34 +239,44 @@ export function App() {
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (screen === "run" && !outcome) return; if (screen === "run" && !outcome) return;
if (!progress) return;
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (key === "m") setProgress((p) => ({ ...p, settings: { ...p.settings, sound: !p.settings.sound } })); if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
if (key === "h") { if (key === "h") {
setProgress((p) => ({ void saveSettings({
...p, ...progress.settings,
settings: { keyboardHint: progress.settings.keyboardHint === "off" ? "auto" : "off",
...p.settings, });
keyboardHint: p.settings.keyboardHint === "off" ? "auto" : "off",
},
}));
} }
}; };
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, [screen, outcome]); }, [screen, outcome, progress, saveSettings]);
// --- render -------------------------------------------------------------- // --- render --------------------------------------------------------------
const next = lessonId ? nextLesson(lessonId) : null; if (curriculumLoading || (configured && progressLoading) || !progress) {
return (
<Stage creatures={[]} dimmed={false}>
<AppHeader />
<LoadingScreen text="Einen Moment …" />
</Stage>
);
}
// Every mode takes the same bundle; only the drawing differs. Built here so adding a if (!curriculum) {
// seventh mode is one line in the switch below rather than eight repeated props. return (
const shared = { <Stage creatures={[]} dimmed={false}>
activeKeys: lesson?.activeKeys ?? [], <AppHeader />
progress, <LoadingScreen text="Das Tippen-Spiel ist noch nicht eingerichtet." />
paused: outcome !== null, </Stage>
onFinished, );
}; }
const next = lessonId ? nextLesson(curriculum, lessonId) : null;
// Every mode takes the same bundle; only the drawing differs.
const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished };
const letterProps = (letters: readonly string[]) => ({ letters, ...shared }); const letterProps = (letters: readonly string[]) => ({ letters, ...shared });
const textProps = (r: Extract<RunTarget, { kind: "text" }>) => ({ const textProps = (r: Extract<RunTarget, { kind: "text" }>) => ({
chunks: r.chunks, chunks: r.chunks,
@@ -287,6 +300,7 @@ export function App() {
{screen === "aquarium" && ( {screen === "aquarium" && (
<Aquarium <Aquarium
worlds={curriculum.worlds}
progress={progress} progress={progress}
nextLesson={nextUp} nextLesson={nextUp}
onContinue={() => nextUp && start(nextUp)} onContinue={() => nextUp && start(nextUp)}
@@ -295,7 +309,7 @@ export function App() {
)} )}
{screen === "map" && ( {screen === "map" && (
<LessonMap progress={progress} selected={selected} onPick={start} /> <LessonMap worlds={curriculum.worlds} lessons={curriculum.lessons} progress={progress} selected={selected} onPick={start} />
)} )}
{screen === "run" && lesson && run && ( {screen === "run" && lesson && run && (
@@ -321,15 +335,14 @@ export function App() {
result={outcome.result} result={outcome.result}
unlockedTitle={outcome.unlockedTitle} unlockedTitle={outcome.unlockedTitle}
newCreature={outcome.newCreature} newCreature={outcome.newCreature}
unlockedReward={outcome.unlockedReward}
isNewBest={outcome.isNewBest} isNewBest={outcome.isNewBest}
bestEver={overallBestAnimal(progress)} bestEver={overallBestAnimal(progress)}
bonusModes={outcome.result.passed ? lesson.bonusModes : []} bonusModes={outcome.result.passed ? lesson.bonusModes : []}
onPlayBonus={playBonus} onPlayBonus={playBonus}
onRetry={retry} onRetry={retry}
onContinue={continueAfterResult} onContinue={continueAfterResult}
continueLabel={ continueLabel={next && progress.lessons[next.id]?.unlocked ? `${next.title}` : "Zur Karte"}
next && progress.lessons[next.id]?.unlocked ? `${next.title}` : "Zur Karte"
}
/> />
)} )}
@@ -337,3 +350,23 @@ export function App() {
</Stage> </Stage>
); );
} }
function LoadingScreen({ text }: { text: string }) {
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--paper)",
fontSize: 18,
fontWeight: 800,
textAlign: "center",
padding: "0 32px",
}}
>
{text}
</div>
);
}

View File

@@ -9,14 +9,14 @@
* pale outlines. The same idea as the animal ladder - the next thing has to be visible to * pale outlines. The same idea as the animal ladder - the next thing has to be visible to
* be worth aiming at - while the outline alone keeps a little surprise for the arrival. */ * be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
import { creatureById } from "../lib/aquarium"; import { creatureById } from "../../lib/tippen/aquarium";
import type { Lesson } from "../lib/curriculum"; import type { Lesson, World } from "../../lib/tippen/curriculum";
import { WORLDS } from "../lib/curriculum"; import { animalById } from "../../lib/tippen/grading";
import { animalById } from "../lib/grading"; import { overallBestAnimal } from "../../lib/tippen/progress";
import { overallBestAnimal } from "../lib/progress"; import type { Progress } from "../../lib/tippen/progress";
import type { Progress } from "../lib/progress";
interface Props { interface Props {
worlds: readonly World[];
progress: Progress; progress: Progress;
/** The lesson the "Weiter üben" button jumps to - the first unfinished one. */ /** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
nextLesson: Lesson | null; nextLesson: Lesson | null;
@@ -24,7 +24,7 @@ interface Props {
onOpenMap: () => void; onOpenMap: () => void;
} }
export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props) { export function Aquarium({ worlds, progress, nextLesson, onContinue, onOpenMap }: Props) {
const bestAnimal = overallBestAnimal(progress); const bestAnimal = overallBestAnimal(progress);
return ( return (
@@ -42,7 +42,7 @@ export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props)
}} }}
> >
<div <div
className="glass-panel" className="tp-glass-panel"
style={{ padding: "26px 34px", width: "min(680px, 100%)", textAlign: "center" }} style={{ padding: "26px 34px", width: "min(680px, 100%)", textAlign: "center" }}
> >
<div style={{ fontSize: 15, fontWeight: 900, color: "var(--paper)", opacity: 0.8 }}> <div style={{ fontSize: 15, fontWeight: 900, color: "var(--paper)", opacity: 0.8 }}>
@@ -60,7 +60,7 @@ export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props)
alignItems: "center", alignItems: "center",
}} }}
> >
{WORLDS.map((world) => { {worlds.map((world) => {
const creature = creatureById(world.reward); const creature = creatureById(world.reward);
const owned = progress.aquarium.includes(creature.id); const owned = progress.aquarium.includes(creature.id);
return ( return (
@@ -118,7 +118,7 @@ export function Aquarium({ progress, nextLesson, onContinue, onOpenMap }: Props)
{nextLesson.title} {nextLesson.title}
</button> </button>
)} )}
<button className="pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}> <button className="tp-pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}>
🗺 Alle Lektionen 🗺 Alle Lektionen
</button> </button>
</div> </div>

View File

@@ -14,8 +14,8 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { creatureById, createSwimmer, pose, stepSwimmer } from "../lib/aquarium"; import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
import type { CreatureId, Swimmer } from "../lib/aquarium"; import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
interface Props { interface Props {
creatures: readonly CreatureId[]; creatures: readonly CreatureId[];

View File

@@ -16,8 +16,8 @@ const KEYS: readonly [string, string][] = [
export function HelpOverlay({ onClose }: Props) { export function HelpOverlay({ onClose }: Props) {
return ( return (
<div className="overlay backdrop-enter" onClick={onClose}> <div className="tp-overlay backdrop-enter" onClick={onClose}>
<div className="sheet sheet-enter" style={{ padding: "26px 32px", width: "min(460px, 100%)" }}> <div className="tp-sheet tp-sheet-enter" style={{ padding: "26px 32px", width: "min(460px, 100%)" }}>
<div style={{ fontSize: 24, fontWeight: 900, color: "var(--ink)", marginBottom: 16 }}> <div style={{ fontSize: 24, fontWeight: 900, color: "var(--ink)", marginBottom: 16 }}>
Zaubertasten Zaubertasten
</div> </div>

View File

@@ -10,9 +10,9 @@
* and a scaffold: looking at the screen has to stop being worth it before looking at the * and a scaffold: looking at the screen has to stop being worth it before looking at the
* hands does. `H` forces it back on. */ * hands does. `H` forces it back on. */
import { KEYBOARD_ROWS, fingerOf, keyForChar, keysInRow, needsShift, shiftHandFor } from "../lib/fingers"; import { KEYBOARD_ROWS, fingerOf, keyForChar, keysInRow, needsShift, shiftHandFor } from "../../lib/tippen/fingers";
import type { Progress } from "../lib/progress"; import type { Progress } from "../../lib/tippen/progress";
import { mastery } from "../lib/progress"; import { mastery } from "../../lib/tippen/progress";
interface Props { interface Props {
/** Keys this lesson uses - everything else is dimmed. */ /** Keys this lesson uses - everything else is dimmed. */
@@ -39,9 +39,9 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
const shiftHand = shift && nextKey !== null ? shiftHandFor(nextKey) : null; const shiftHand = shift && nextKey !== null ? shiftHandFor(nextKey) : null;
return ( return (
<div className="kb" style={{ ["--kb-size" as string]: `${size}px` }} aria-hidden="true"> <div className="tp-kb" style={{ ["--kb-size" as string]: `${size}px` }} aria-hidden="true">
{KEYBOARD_ROWS.map((row) => ( {KEYBOARD_ROWS.map((row) => (
<div className="kb-row" key={row} style={{ marginLeft: ROW_INDENT[row] ?? 0 }}> <div className="tp-kb-row" key={row} style={{ marginLeft: ROW_INDENT[row] ?? 0 }}>
{keysInRow(row).map((key) => { {keysInRow(row).map((key) => {
const finger = fingerOf(key); const finger = fingerOf(key);
const isActive = active.has(key); const isActive = active.has(key);
@@ -52,7 +52,7 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
return ( return (
<div <div
key={key} key={key}
className="kb-key" className="tp-kb-key"
data-active={isActive} data-active={isActive}
data-next={key === next} data-next={key === next}
data-finger={finger?.id} data-finger={finger?.id}
@@ -71,20 +71,20 @@ export function Keyboard({ activeKeys, nextKey, progress, mode, size = 42 }: Pro
</div> </div>
))} ))}
<div className="kb-row" style={{ marginTop: 2, alignItems: "center", gap: 10 }}> <div className="tp-kb-row" style={{ marginTop: 2, alignItems: "center", gap: 10 }}>
{/* Both Shifts are drawn, and the one to use lights up - the opposite hand from {/* Both Shifts are drawn, and the one to use lights up - the opposite hand from
the letter, which is the rule world 4 exists to teach. */} the letter, which is the rule world 4 exists to teach. */}
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "left"}> <div className="tp-kb-key tp-kb-shift" data-active={shift} data-next={shift && shiftHand === "left"}>
</div> </div>
<div <div
className="kb-key kb-space" className="tp-kb-key tp-kb-space"
data-active={spaceActive} data-active={spaceActive}
data-next={next === " "} data-next={next === " "}
data-finger="thumb" data-finger="thumb"
style={{ ["--finger-hue" as string]: 220 }} style={{ ["--finger-hue" as string]: 220 }}
/> />
<div className="kb-key kb-shift" data-active={shift} data-next={shift && shiftHand === "right"}> <div className="tp-kb-key tp-kb-shift" data-active={shift} data-next={shift && shiftHand === "right"}>
</div> </div>
</div> </div>

View File

@@ -5,14 +5,15 @@
* best animal, star count and a badge for which game it plays, so the map doubles as * best animal, star count and a badge for which game it plays, so the map doubles as
* both a path forward and a trophy cabinet. */ * both a path forward and a trophy cabinet. */
import { LESSONS, WORLDS } from "../lib/curriculum"; import type { Lesson, World } from "../../lib/tippen/curriculum";
import type { Lesson } from "../lib/curriculum"; import { animalById } from "../../lib/tippen/grading";
import { animalById } from "../lib/grading"; import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
import { NODE_SPACING, pathD, pointFor } from "../lib/lessonPath"; import { MODE_INFO } from "../../lib/tippen/modeInfo";
import { MODE_INFO } from "../lib/modeInfo"; import type { Progress } from "../../lib/tippen/progress";
import type { Progress } from "../lib/progress";
interface Props { interface Props {
worlds: readonly World[];
lessons: readonly Lesson[];
progress: Progress; progress: Progress;
/** Which card the keyboard selection is on. */ /** Which card the keyboard selection is on. */
selected: number; selected: number;
@@ -31,23 +32,23 @@ const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> =
sentences: "Sätze", sentences: "Sätze",
}; };
export function LessonMap({ progress, selected, onPick }: Props) { export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props) {
return ( return (
<div <div
className="view-enter" className="view-enter"
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 40px", minHeight: 0 }} style={{ flex: 1, overflowY: "auto", padding: "6px 32px 40px", minHeight: 0 }}
> >
<div style={{ maxWidth: 480, margin: "0 auto", display: "flex", flexDirection: "column", gap: 14 }}> <div style={{ maxWidth: 480, margin: "0 auto", display: "flex", flexDirection: "column", gap: 14 }}>
{WORLDS.map((world) => { {worlds.map((world) => {
const lessons = LESSONS.filter((lesson) => lesson.world === world.number); const worldLessons = lessons.filter((lesson) => lesson.world === world.number);
const done = lessons.filter((l) => (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length; const done = worldLessons.filter((l) => (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length;
const height = lessons.length * NODE_SPACING; const height = worldLessons.length * NODE_SPACING;
const points = lessons.map((_, i) => pointFor(i)); const points = worldLessons.map((_, i) => pointFor(i));
return ( return (
<section key={world.number}> <section key={world.number}>
<div <div
className="glass-panel" className="tp-glass-panel"
style={{ style={{
position: "sticky", position: "sticky",
top: 0, top: 0,
@@ -64,7 +65,7 @@ export function LessonMap({ progress, selected, onPick }: Props) {
Welt {world.number} {world.title} Welt {world.number} {world.title}
</span> </span>
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6, marginLeft: "auto" }}> <span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6, marginLeft: "auto" }}>
{done}/{lessons.length} {done}/{worldLessons.length}
</span> </span>
</div> </div>
@@ -78,18 +79,18 @@ export function LessonMap({ progress, selected, onPick }: Props) {
<path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" /> <path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
</svg> </svg>
{lessons.map((lesson, i) => { {worldLessons.map((lesson, i) => {
const entry = progress.lessons[lesson.id]; const entry = progress.lessons[lesson.id];
const locked = !entry?.unlocked; const locked = !entry?.unlocked;
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null; const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
const index = LESSONS.indexOf(lesson); const index = lessons.indexOf(lesson);
const point = points[i]!; const point = points[i]!;
const modeInfo = MODE_INFO[lesson.primaryMode]; const modeInfo = MODE_INFO[lesson.primaryMode];
return ( return (
<button <button
key={lesson.id} key={lesson.id}
className="card glass-panel" className="card tp-glass-panel"
data-selected={index === selected} data-selected={index === selected}
data-locked={locked} data-locked={locked}
disabled={locked} disabled={locked}

View File

@@ -7,13 +7,15 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { creatureById } from "../lib/aquarium"; import { coverUrl } from "../../api/client";
import type { CreatureId } from "../lib/aquarium"; import { creatureById } from "../../lib/tippen/aquarium";
import type { ModeId } from "../lib/curriculum"; import type { CreatureId } from "../../lib/tippen/aquarium";
import type { RunResult } from "../lib/grading"; import type { ModeId } from "../../lib/tippen/curriculum";
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../lib/grading"; import type { RunResult } from "../../lib/tippen/grading";
import type { AnimalId } from "../lib/grading"; import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../../lib/tippen/grading";
import { MODE_INFO } from "../lib/modeInfo"; import type { AnimalId } from "../../lib/tippen/grading";
import { MODE_INFO } from "../../lib/tippen/modeInfo";
import type { UnlockedReward } from "../../lib/tippen/progress";
interface Props { interface Props {
result: RunResult; result: RunResult;
@@ -21,6 +23,9 @@ interface Props {
unlockedTitle: string | null; unlockedTitle: string | null;
/** Set when this run released a creature into the aquarium. */ /** Set when this run released a creature into the aquarium. */
newCreature: CreatureId | null; newCreature: CreatureId | null;
/** Set when this run's lesson named an `unlocks:` target that just became reachable -
* the literal track/episode it names, not "one of several". */
unlockedReward: UnlockedReward | null;
isNewBest: boolean; isNewBest: boolean;
/** The fastest animal earned on any lesson so far - decides how much of the ladder /** The fastest animal earned on any lesson so far - decides how much of the ladder
* may be revealed. */ * may be revealed. */
@@ -39,6 +44,7 @@ export function ResultSheet({
result, result,
unlockedTitle, unlockedTitle,
newCreature, newCreature,
unlockedReward,
isNewBest, isNewBest,
bestEver, bestEver,
bonusModes, bonusModes,
@@ -56,9 +62,9 @@ export function ResultSheet({
useEffect(() => retryButton.current?.focus(), []); useEffect(() => retryButton.current?.focus(), []);
return ( return (
<div className="overlay backdrop-enter"> <div className="tp-overlay backdrop-enter">
<div <div
className="sheet sheet-enter" className="tp-sheet tp-sheet-enter"
style={{ padding: "30px 38px 28px", width: "min(520px, 100%)", textAlign: "center" }} style={{ padding: "30px 38px 28px", width: "min(520px, 100%)", textAlign: "center" }}
> >
<div style={{ fontSize: 88, lineHeight: 1, animation: "tierEnter 520ms ease-out" }}> <div style={{ fontSize: 88, lineHeight: 1, animation: "tierEnter 520ms ease-out" }}>
@@ -143,6 +149,36 @@ export function ResultSheet({
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen! {creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
</div> </div>
)} )}
{unlockedReward && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
color: "var(--ink)",
fontWeight: 900,
marginTop: 8,
}}
>
{unlockedReward.hasCover ? (
<img
src={coverUrl(unlockedReward.albumId)}
alt=""
style={{
width: 52,
height: 52,
objectFit: "cover",
borderRadius: 8,
animation: "tierEnter 520ms ease-out",
}}
/>
) : (
<span style={{ fontSize: 40, animation: "tierEnter 520ms ease-out" }}>🎁</span>
)}
🎁 Neu zum Anhören: {unlockedReward.title}
</div>
)}
{!result.passed && !unlockedTitle && ( {!result.passed && !unlockedTitle && (
<div style={{ color: "var(--ink)", opacity: 0.75, fontWeight: 700, marginTop: 10 }}> <div style={{ color: "var(--ink)", opacity: 0.75, fontWeight: 700, marginTop: 10 }}>
Mit {Math.round(STAR_THRESHOLDS.two * 100)}% Treffern geht es weiter. Fast! Mit {Math.round(STAR_THRESHOLDS.two * 100)}% Treffern geht es weiter. Fast!

View File

@@ -6,8 +6,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { CreatureId } from "../lib/aquarium"; import type { CreatureId } from "../../lib/tippen/aquarium";
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../lib/theme"; import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
import { AquariumCreatures } from "./AquariumCreatures"; import { AquariumCreatures } from "./AquariumCreatures";
import { Bubbles } from "./Bubbles"; import { Bubbles } from "./Bubbles";
@@ -21,7 +21,7 @@ interface Props {
export function Stage({ children, creatures, dimmed }: Props) { export function Stage({ children, creatures, dimmed }: Props) {
return ( return (
<div className="stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}> <div className="tp-stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}>
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />} {SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
{SHOW_BUBBLES && <Bubbles />} {SHOW_BUBBLES && <Bubbles />}
{children} {children}

View File

@@ -5,7 +5,7 @@
* not. The gap is a real space to type from the very first lesson, even before the * not. The gap is a real space to type from the very first lesson, even before the
* space-bar lesson formally teaches the thumb. */ * space-bar lesson formally teaches the thumb. */
import { chunkOffsets } from "../lib/generator"; import { chunkOffsets } from "../../lib/tippen/generator";
interface Props { interface Props {
chunks: readonly string[]; chunks: readonly string[];
@@ -43,11 +43,11 @@ export function Target({ chunks, spaceActive, index, wrong, fontSize }: Props) {
const size = fontSize ?? layout.size; const size = fontSize ?? layout.size;
return ( return (
<div className="target" style={{ fontSize: size, maxWidth: `min(${layout.width}px, 90vw)` }}> <div className="tp-target" style={{ fontSize: size, maxWidth: `min(${layout.width}px, 90vw)` }}>
{chunks.map((chunk, chunkIndex) => { {chunks.map((chunk, chunkIndex) => {
const start = offsets[chunkIndex] ?? 0; const start = offsets[chunkIndex] ?? 0;
return ( return (
<div className="target-chunk" key={chunkIndex}> <div className="tp-target-chunk" key={chunkIndex}>
{[...chunk].map((char, i) => { {[...chunk].map((char, i) => {
const at = start + i; const at = start + i;
const state = at < index ? "done" : at === index ? "current" : "open"; const state = at < index ? "done" : at === index ? "current" : "open";
@@ -58,7 +58,7 @@ export function Target({ chunks, spaceActive, index, wrong, fontSize }: Props) {
return ( return (
<span <span
key={i} key={i}
className="target-char" className="tp-target-char"
data-state={state} data-state={state}
data-blank={isBlank} data-blank={isBlank}
data-wrong={state === "current" && wrong} data-wrong={state === "current" && wrong}
@@ -71,7 +71,7 @@ export function Target({ chunks, spaceActive, index, wrong, fontSize }: Props) {
needs to be typeable and to show the cursor. Before that it is a gap. */} needs to be typeable and to show the cursor. Before that it is a gap. */}
{spaceActive && chunkIndex < chunks.length - 1 && ( {spaceActive && chunkIndex < chunks.length - 1 && (
<span <span
className="target-char" className="tp-target-char"
data-blank="true" data-blank="true"
data-state={ data-state={
start + chunk.length < index start + chunk.length < index

View File

@@ -12,12 +12,12 @@
import { useState } from "react"; import { useState } from "react";
import { currentChar } from "../../lib/engine"; import { currentChar } from "../../../lib/tippen/engine";
import type { RunEvent } from "../../lib/engine"; import type { RunEvent } from "../../../lib/tippen/engine";
import { fingerOf } from "../../lib/fingers"; import { fingerOf } from "../../../lib/tippen/fingers";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../../lib/tippen/grading";
import type { Progress } from "../../lib/progress"; import type { Progress } from "../../../lib/tippen/progress";
import { useRun } from "../../hooks/useRun"; import { useRun } from "../../../hooks/useTippenRun";
import { Keyboard } from "../Keyboard"; import { Keyboard } from "../Keyboard";
interface Props { interface Props {

View File

@@ -3,10 +3,10 @@
* and race periodically take a turn as the required mode instead - any of them can * and race periodically take a turn as the required mode instead - any of them can
* unlock the next lesson, since `recordRun` doesn't care which mode produced the run. */ * unlock the next lesson, since `recordRun` doesn't care which mode produced the run. */
import { currentChar } from "../../lib/engine"; import { currentChar } from "../../../lib/tippen/engine";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../../lib/tippen/grading";
import type { Progress } from "../../lib/progress"; import type { Progress } from "../../../lib/tippen/progress";
import { useRun } from "../../hooks/useRun"; import { useRun } from "../../../hooks/useTippenRun";
import { Keyboard } from "../Keyboard"; import { Keyboard } from "../Keyboard";
import { Target } from "../Target"; import { Target } from "../Target";

View File

@@ -11,12 +11,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { currentChar } from "../../lib/engine"; import { currentChar } from "../../../lib/tippen/engine";
import type { RunEvent } from "../../lib/engine"; import type { RunEvent } from "../../../lib/tippen/engine";
import { chunkOffsets } from "../../lib/generator"; import { chunkOffsets } from "../../../lib/tippen/generator";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../../lib/tippen/grading";
import type { Progress } from "../../lib/progress"; import type { Progress } from "../../../lib/tippen/progress";
import { useRun } from "../../hooks/useRun"; import { useRun } from "../../../hooks/useTippenRun";
import { Keyboard } from "../Keyboard"; import { Keyboard } from "../Keyboard";
interface Props { interface Props {

View File

@@ -11,12 +11,12 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { currentChar } from "../../lib/engine"; import { currentChar } from "../../../lib/tippen/engine";
import { fingerOf } from "../../lib/fingers"; import { fingerOf } from "../../../lib/tippen/fingers";
import { mulberry32 } from "../../lib/generator"; import { mulberry32 } from "../../../lib/tippen/generator";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../../lib/tippen/grading";
import type { Progress } from "../../lib/progress"; import type { Progress } from "../../../lib/tippen/progress";
import { useRun } from "../../hooks/useRun"; import { useRun } from "../../../hooks/useTippenRun";
import { Keyboard } from "../Keyboard"; import { Keyboard } from "../Keyboard";
interface Props { interface Props {
@@ -96,7 +96,7 @@ export function JellyfishRun({ letters, activeKeys, progress, paused, onFinished
return ( return (
<div <div
key={i} key={i}
className="jellyfish" className="tp-jellyfish"
style={{ style={{
position: "absolute", position: "absolute",
left: `${spot.left}%`, left: `${spot.left}%`,

View File

@@ -16,10 +16,10 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { currentChar } from "../../lib/engine"; import { currentChar } from "../../../lib/tippen/engine";
import type { RunResult } from "../../lib/grading"; import type { RunResult } from "../../../lib/tippen/grading";
import type { Progress } from "../../lib/progress"; import type { Progress } from "../../../lib/tippen/progress";
import { useRun } from "../../hooks/useRun"; import { useRun } from "../../../hooks/useTippenRun";
import { Keyboard } from "../Keyboard"; import { Keyboard } from "../Keyboard";
import { Target } from "../Target"; import { Target } from "../Target";

View File

@@ -0,0 +1,34 @@
/** The typing game's lesson plan, fetched once - see `lib/tippen/curriculum.ts` for the
* derived Lesson/World shape everything else in the typing game expects. `null` after
* loading means the backend has no `general.tippen` section configured, same as
* `useHomeAssistant`'s `config` for the room page. */
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { Curriculum } from "../lib/tippen/curriculum";
import { fromApi } from "../lib/tippen/curriculum";
export interface TippenCurriculumState {
curriculum: Curriculum | null;
loading: boolean;
}
export function useTippenCurriculum(): TippenCurriculumState {
const [curriculum, setCurriculum] = useState<Curriculum | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
void api.tippenCurriculum().then((loaded) => {
if (cancelled) return;
setCurriculum(loaded ? fromApi(loaded) : null);
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { curriculum, loading };
}

View File

@@ -0,0 +1,57 @@
/** The typing game's progress: fetched once and updated from each run-recording
* response, since the backend is the only place it lives now - there is no local save.
* `enabled` mirrors `useHomeAssistant`'s `config` argument: pass whether the curriculum
* resolved non-null, so this hook stays a no-op until there is something to fetch. */
import { useCallback, useEffect, useState } from "react";
import { api } from "../api/client";
import type { RunResult } from "../lib/tippen/grading";
import type { Progress, RunOutcome, Settings } from "../lib/tippen/progress";
import { progressFromApi, runOutcomeFromApi, toRunInput } from "../lib/tippen/progress";
export interface TippenProgressState {
progress: Progress | null;
loading: boolean;
recordRun: (lessonId: string, result: RunResult) => Promise<RunOutcome>;
saveSettings: (settings: Settings) => Promise<void>;
}
export function useTippenProgress(enabled: boolean): TippenProgressState {
const [progress, setProgress] = useState<Progress | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
void api.tippenProgress().then((loaded) => {
if (cancelled) return;
setProgress(progressFromApi(loaded));
setLoading(false);
});
return () => {
cancelled = true;
};
}, [enabled]);
const recordRun = useCallback(async (lessonId: string, result: RunResult) => {
const response = await api.recordTippenRun(toRunInput(lessonId, result));
const outcome = runOutcomeFromApi(response);
setProgress(outcome.progress);
return outcome;
}, []);
const saveSettings = useCallback(async (settings: Settings) => {
const saved = await api.saveTippenSettings({
sound: settings.sound,
keyboard_hint: settings.keyboardHint,
});
setProgress((previous) =>
previous
? { ...previous, settings: { sound: saved.sound, keyboardHint: saved.keyboard_hint } }
: previous,
);
}, []);
return { progress, loading, recordRun, saveSettings };
}

View File

@@ -11,10 +11,10 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { isTypingKey, press, startRun } from "../lib/engine"; import { isTypingKey, press, startRun } from "../lib/tippen/engine";
import type { RunEvent, RunState } from "../lib/engine"; import type { RunEvent, RunState } from "../lib/tippen/engine";
import type { RunResult } from "../lib/grading"; import type { RunResult } from "../lib/tippen/grading";
import { playWrong, playDone, playCorrect } from "../lib/pop"; import { playWrong, playDone, playCorrect } from "../lib/tippen/pop";
interface Options { interface Options {
/** The text to type. Changing it restarts the run. */ /** The text to type. Changing it restarts the run. */

View File

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

View File

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

View File

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

View File

@@ -25,8 +25,8 @@ export interface UiState {
* that row", the way it means "which flat position" everywhere else. */ * that row", the way it means "which flat position" everywhere else. */
shelfRow: number; shelfRow: number;
/** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below, /** 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. */ * so switching pages and back leaves the music side exactly as it was. */
page: "music" | "room"; page: "music" | "room" | "typing";
view: "browse" | "play"; view: "browse" | "play";
openAlbumId: string | null; openAlbumId: string | null;
/** Which track is highlighted in the open album's track list. Only meaningful while /** Which track is highlighted in the open album's track list. Only meaningful while
@@ -232,6 +232,12 @@ export function handleKey(
): Action[] { ): Action[] {
const { key } = event; 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) { if (event.shiftKey && !event.ctrlKey && !event.metaKey) {
const media = SHIFT_MEDIA[key.toUpperCase()]; const media = SHIFT_MEDIA[key.toUpperCase()];
if (media) return media; if (media) return media;

View File

@@ -114,6 +114,9 @@ export function songMatches(query: BrowseQuery): SongHit[] {
const hits: SongHit[] = []; const hits: SongHit[] = [];
for (const album of pool(query.albums, query.group)) { for (const album of pool(query.albums, query.group)) {
album.tracks.forEach((track, index) => { 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)) { if (!words.length || matchesWords(normalize(track.title), words)) {
hits.push({ album, index, title: track.title, duration: track.duration }); hits.push({ album, index, title: track.title, duration: track.duration });
} }

View File

@@ -12,6 +12,7 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import type { Group } from "./search"; import type { Group } from "./search";
import type { UiState } from "./keyboard";
// ---------------------------------------------------------------- toggles -- // ---------------------------------------------------------------- toggles --
@@ -74,6 +75,21 @@ export const GROUP_LABEL: Record<Group, string> = {
podcasts: "Podcasts", 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, /** 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. */ * with `SHOW_ROW_TINT` off, `{}` so `.glass-panel`'s own neutral CSS shows through. */
export function glassTint(hue: number): CSSProperties { export function glassTint(hue: number): CSSProperties {

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CREATURES, creatureFromRaw, creatureById, createSwimmer, pose, stepSwimmer } from "../aquarium"; import { CREATURES, creatureFromRaw, creatureById, createSwimmer, pose, stepSwimmer } from "../aquarium";
import type { Tank, Swimmer } from "../aquarium"; import type { Tank, Swimmer } from "../aquarium";
import { WORLDS } from "../curriculum";
import { mulberry32 } from "../generator"; import { mulberry32 } from "../generator";
const TANK: Tank = { width: 1280, height: 800 }; const TANK: Tank = { width: 1280, height: 800 };
@@ -15,11 +14,6 @@ function swim(s: Swimmer, seconds: number, tank = TANK, rng = mulberry32(7)): Sw
} }
describe("Creatures", () => { describe("Creatures", () => {
it("gives every pet exactly one world", () => {
const rewards = WORLDS.map((world) => world.reward);
expect([...rewards].sort()).toEqual(CREATURES.map((c) => c.id).sort());
});
it("looks pets up by id", () => { it("looks pets up by id", () => {
for (const creature of CREATURES) expect(creatureById(creature.id)).toBe(creature); for (const creature of CREATURES) expect(creatureById(creature.id)).toBe(creature);
}); });

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

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { LESSONS } from "../curriculum"; import type { Lesson } from "../curriculum";
import { import {
chunkOffsets, chunkOffsets,
drillChunks, drillChunks,
@@ -13,6 +13,44 @@ import {
const keys = ["a", "s", "d", "f"]; 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", () => { describe("mulberry32", () => {
it("is deterministic for a seed and different across seeds", () => { 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))).toEqual(drillChunks(keys, mulberry32(7)));
@@ -31,9 +69,9 @@ describe("mulberry32", () => {
describe("drillChunks", () => { describe("drillChunks", () => {
it("only ever emits active keys", () => { it("only ever emits active keys", () => {
for (const lesson of LESSONS) { for (const fixture of FIXTURE_LESSONS) {
const chunks = drillChunks(lesson.activeKeys, mulberry32(lesson.number), { chunks: 20 }); const chunks = drillChunks(fixture.activeKeys, mulberry32(fixture.number), { chunks: 20 });
const active = new Set(lesson.activeKeys); const active = new Set(fixture.activeKeys);
for (const char of chunks.join("")) expect(active.has(char)).toBe(true); for (const char of chunks.join("")) expect(active.has(char)).toBe(true);
} }
}); });
@@ -144,22 +182,22 @@ describe("wordChunks", () => {
describe("lineFor", () => { describe("lineFor", () => {
it("prefers real words once a lesson has them", () => { it("prefers real words once a lesson has them", () => {
const lesson = LESSONS.find((l) => l.words.length > 0)!; const withWords = FIXTURE_LESSONS.find((l) => l.words.length > 0)!;
const chunks = lineFor(lesson, mulberry32(1)); const chunks = lineFor(withWords, mulberry32(1));
for (const chunk of chunks) expect(lesson.words).toContain(chunk); for (const chunk of chunks) expect(withWords.words).toContain(chunk);
}); });
it("falls back to letters for the home-row lessons", () => { it("falls back to letters for a lesson with no words", () => {
const lesson = LESSONS[0]!; const noWords = FIXTURE_LESSONS[0]!;
const chunks = lineFor(lesson, mulberry32(1)); const chunks = lineFor(noWords, mulberry32(1));
expect(chunks.length).toBeGreaterThan(0); expect(chunks.length).toBeGreaterThan(0);
for (const char of chunks.join("")) expect(lesson.activeKeys).toContain(char); for (const char of chunks.join("")) expect(noWords.activeKeys).toContain(char);
}); });
it("can be asked for letters even in a word lesson", () => { it("can be asked for letters even in a word lesson", () => {
const lesson = LESSONS.at(-1)!; const withWords = FIXTURE_LESSONS.find((l) => l.words.length > 0)!;
const chunks = lineFor(lesson, mulberry32(1), { preferWords: false, chunks: 4 }); const chunks = lineFor(withWords, mulberry32(1), { preferWords: false, chunks: 4 });
for (const chunk of chunks) expect(lesson.words).not.toContain(chunk); for (const chunk of chunks) expect(withWords.words).not.toContain(chunk);
}); });
}); });

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,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,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

@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import "./styles/app.css"; import "./styles/app.css";
import "./styles/room.css"; import "./styles/room.css";
import "./styles/tippen.css";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>

View File

@@ -1,50 +1,34 @@
/* The Dolphin Beats design system, on its own hue. /* The typing game's own corner of the Dolphin Beats design system - ported from the
../../web/src/styles/app.css is the original; the tokens, the glass recipe, the radius standalone tippen app's app.css (see its own header: "the tokens, the glass recipe,
scale and the entrance animations are deliberately identical so the two apps read as the radius scale and the entrance animations are deliberately identical [to
siblings. What changes is the hue: the music player's sea is 210, "Mein Zimmer" is app.css], so the two apps read as siblings. What changes is the hue: the music
300, Hörbücher are 55. This is 175 - a turquoise lagoon, clearly the same ocean and player's sea is 210, this is 175 - a turquoise lagoon, clearly the same ocean and
clearly not the same room. clearly not the same room.").
The pink --accent stays exactly as it is. It is the family's signature highlight. */
:root { Classes here are prefixed `tp-` and colours scoped under `.tp-stage` rather than
redefined at `:root`, because app.css already claims the *same* custom property
names (--ink, --paper, --accent, --shadow, ...) for the music player's own blue hue -
redefining them globally would have silently reskinned one app or the other. Anything
below with no hue baked into it - .bubble, .card, .key-cap, .view-enter,
.backdrop-enter, and the dolphinBob/bubbleRise/viewEnter/backdropEnter keyframes - is
identical between the two apps and reused directly from app.css instead of being
duplicated here. */
.tp-stage {
--ink: oklch(30% 0.04 175); --ink: oklch(30% 0.04 175);
--paper: oklch(97% 0.01 175); --paper: oklch(97% 0.01 175);
--accent: oklch(70% 0.16 340); --accent: oklch(70% 0.16 340);
--accent-dim: oklch(78% 0.14 340); --accent-dim: oklch(78% 0.14 340);
--sea-deep: oklch(20% 0.045 175); --sea-deep: oklch(20% 0.045 175);
--shadow: oklch(15% 0.05 175 / 0.35); --shadow: oklch(15% 0.05 175 / 0.35);
/* This app's own additions: the feedback colours. Note there is no red - a wrong key /* This app's own additions: the feedback colours. Note there is no red - a wrong key
is amber and gentle, never an alarm. See `wrongShake` below. */ is amber and gentle, never an alarm. */
--correct: oklch(80% 0.17 150); --correct: oklch(80% 0.17 150);
--wrong: oklch(80% 0.13 75); --wrong: oklch(80% 0.13 75);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Nunito", system-ui, sans-serif;
overscroll-behavior: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
button {
font-family: inherit;
}
/* ------------------------------------------------------------------ stage -- */
.stage {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100vh; height: 100%;
height: 100dvh;
overflow: hidden; overflow: hidden;
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
@@ -54,51 +38,22 @@ button {
); );
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* Its own stacking context, so the swimming pets (z-index -1, AquariumTiere.tsx) paint /* Its own stacking context, so the swimming pets (AquariumCreatures.tsx) paint above
above this gradient and below every screen - rather than below the page itself. */ this gradient and below every screen, rather than below the page itself. */
isolation: isolate; isolation: isolate;
} }
.bubble { /* Locked lessons stay visible, just dimmed - seeing what comes next is half the reason
position: absolute; to finish what is open. Extends the shared .card rule from app.css; harmless on the
bottom: -40px; music player's own cards, which never set this attribute. */
border-radius: 50%; .card[data-locked="true"] {
animation-name: bubbleRise; cursor: default;
animation-timing-function: linear; opacity: 0.45;
animation-iteration-count: infinite;
pointer-events: none;
} }
/* ------------------------------------------------------------- animations -- */ /* None of these five collide with app.css's own keyframes (bubbleRise, dolphinBob,
dolphinSwim, viewEnter, sheetEnter, backdropEnter), so they keep their plain names -
@keyframes bubbleRise { only classes that would otherwise collide are `tp-`-prefixed. */
0% { transform: translateY(0) scale(1); opacity: 0.55; }
100% { transform: translateY(-120vh) scale(1.3); opacity: 0; }
}
@keyframes dolphinBob {
0%, 100% { transform: translateY(0) rotate(-2deg); }
50% { transform: translateY(-10px) rotate(2deg); }
}
@keyframes viewEnter {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sheetEnter {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes backdropEnter {
from { opacity: 0; }
to { opacity: 1; }
}
.view-enter { animation: viewEnter 200ms ease-out; }
.sheet-enter { animation: sheetEnter 240ms ease-out; }
.backdrop-enter { animation: backdropEnter 150ms ease; }
/* A correct key. Short and bouncy - the reward has to land before the next keystroke. */
@keyframes correctPop { @keyframes correctPop {
0% { transform: scale(1); } 0% { transform: scale(1); }
40% { transform: scale(1.25); } 40% { transform: scale(1.25); }
@@ -129,19 +84,23 @@ button {
100% { opacity: 1; transform: scale(1); } 100% { opacity: 1; transform: scale(1); }
} }
/* Its own duration (240ms vs app.css's 200ms for the shared .sheet-enter) - kept as
its own class for exactly that reason, but reuses app.css's identical `sheetEnter`
keyframe body. */
.tp-sheet-enter { animation: sheetEnter 240ms ease-out; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
/* Keep the colour feedback, drop the movement. */ /* Keep the colour feedback, drop the movement - covers the shared .bubble/.view-enter/
.backdrop-enter classes too, which app.css does not otherwise handle. */
.bubble, .bubble,
.view-enter, .view-enter,
.sheet-enter, .tp-sheet-enter,
.backdrop-enter { .backdrop-enter {
animation: none; animation: none;
} }
} }
/* ----------------------------------------------------------------- glass -- */ .tp-glass-panel {
.glass-panel {
background: linear-gradient(160deg, oklch(97% 0.01 175 / 0.14), oklch(97% 0.01 175 / 0.05)); background: linear-gradient(160deg, oklch(97% 0.01 175 / 0.14), oklch(97% 0.01 175 / 0.05));
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
@@ -152,14 +111,14 @@ button {
inset 0 1px 0 oklch(100% 0 0 / 0.14); inset 0 1px 0 oklch(100% 0 0 / 0.14);
} }
.stage[data-blur="off"] .glass-panel, .tp-stage[data-blur="off"] .tp-glass-panel,
.stage[data-blur="off"] .card, .tp-stage[data-blur="off"] .card,
.stage[data-blur="off"] .kb-key { .tp-stage[data-blur="off"] .tp-kb-key {
backdrop-filter: none; backdrop-filter: none;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
} }
.pill { .tp-pill {
border: none; border: none;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
@@ -171,45 +130,19 @@ button {
color: var(--paper); color: var(--paper);
} }
.pill[data-active="true"] { .tp-pill[data-active="true"] {
background: var(--paper); background: var(--paper);
color: oklch(28% 0.04 175); color: oklch(28% 0.04 175);
box-shadow: 0 4px 14px var(--shadow); box-shadow: 0 4px 14px var(--shadow);
} }
.card { .tp-sheet {
backdrop-filter: blur(6px);
overflow: hidden;
cursor: pointer;
transition: transform 0.12s ease;
border: none;
padding: 0;
text-align: left;
font: inherit;
display: block;
width: 100%;
}
.card[data-selected="true"] {
outline: 5px solid var(--paper);
outline-offset: 3px;
transform: translateY(-3px);
}
/* A locked lesson is dimmed but never hidden - seeing what comes next is half the
reason to finish what is open. */
.card[data-locked="true"] {
cursor: default;
opacity: 0.45;
}
.sheet {
background: var(--paper); background: var(--paper);
border-radius: 24px; border-radius: 24px;
box-shadow: 0 20px 50px oklch(10% 0.04 175 / 0.5); box-shadow: 0 20px 50px oklch(10% 0.04 175 / 0.5);
} }
.overlay { .tp-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: oklch(15% 0.03 175 / 0.55); background: oklch(15% 0.03 175 / 0.55);
@@ -220,19 +153,9 @@ button {
z-index: 20; z-index: 20;
} }
.key-cap {
font: 800 12px ui-monospace, Menlo, monospace;
background: var(--ink);
color: #fff;
padding: 5px 12px;
border-radius: 8px;
min-width: 56px;
text-align: center;
}
/* ------------------------------------------------------- the on-screen keyboard -- */ /* ------------------------------------------------------- the on-screen keyboard -- */
.kb { .tp-kb {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 7px; gap: 7px;
@@ -240,12 +163,13 @@ button {
transition: opacity 400ms ease; transition: opacity 400ms ease;
} }
.kb-row { .tp-kb-row {
display: flex; display: flex;
gap: 7px; gap: 7px;
} }
.kb-key { .tp-kb-key {
position: relative;
width: var(--kb-size, 42px); width: var(--kb-size, 42px);
height: var(--kb-size, 42px); height: var(--kb-size, 42px);
border-radius: 10px; border-radius: 10px;
@@ -265,17 +189,14 @@ button {
/* A key that this lesson does not use yet fades back so the eye goes to the ones that /* A key that this lesson does not use yet fades back so the eye goes to the ones that
matter. It stays visible: the keyboard is a map, and a map with holes is confusing. */ matter. It stays visible: the keyboard is a map, and a map with holes is confusing. */
.kb-key[data-active="false"] { .tp-kb-key[data-active="false"] {
opacity: 0.3; opacity: 0.3;
} }
/* Each finger owns a hue (lib/fingers.ts), so "der grüne Finger" is something you can /* Each finger owns a hue (lib/tippen/fingers.ts), so "der grüne Finger" is something
say out loud to a six-year-old and have her understand it. */ you can say out loud to a six-year-old and have her understand it. Saturated on
/* Saturated on purpose. The finger colour is not decoration - it is how a six-year-old purpose - it has to survive being read at arm's length against a dark background. */
is told which finger to use ("der grüne Finger"), so it has to survive being read at .tp-kb-key[data-finger] {
arm's length against a dark blue-green background. The first pass was tinted at 50%
alpha and every key came out the same murky green. */
.kb-key[data-finger] {
background: linear-gradient( background: linear-gradient(
160deg, 160deg,
oklch(68% 0.19 var(--finger-hue) / 0.92), oklch(68% 0.19 var(--finger-hue) / 0.92),
@@ -286,14 +207,14 @@ button {
text-shadow: 0 1px 0 oklch(100% 0 0 / 0.25); text-shadow: 0 1px 0 oklch(100% 0 0 / 0.25);
} }
.kb-key[data-next="true"] { .tp-kb-key[data-next="true"] {
background: var(--paper); background: var(--paper);
color: oklch(25% 0.05 175); color: oklch(25% 0.05 175);
transform: translateY(-3px) scale(1.08); transform: translateY(-3px) scale(1.08);
animation: keyPulse 1.3s ease-out infinite; animation: keyPulse 1.3s ease-out infinite;
} }
.kb-key[data-home="true"]::after { .tp-kb-key[data-home="true"]::after {
/* The tactile bump on F and J, drawn so it can be pointed at on screen too. */ /* The tactile bump on F and J, drawn so it can be pointed at on screen too. */
content: ""; content: "";
position: absolute; position: absolute;
@@ -305,17 +226,21 @@ button {
opacity: 0.55; opacity: 0.55;
} }
.kb-key { .tp-kb-space {
position: relative; width: calc(var(--kb-size, 42px) * 6);
} }
.kb-space { /* The two Shift keys, drawn either side of the space bar. Wider than a letter key so
width: calc(var(--kb-size, 42px) * 6); they read as the modifier they are, and dim until a capital actually needs one. */
.tp-kb-shift {
width: calc(var(--kb-size, 42px) * 1.8);
background: oklch(97% 0.01 175 / 0.12);
font-size: calc(var(--kb-size, 42px) * 0.4);
} }
/* ------------------------------------------------------------- the target line -- */ /* ------------------------------------------------------------- the target line -- */
.target { .tp-target {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
@@ -329,11 +254,11 @@ button {
max-width: min(900px, 88vw); max-width: min(900px, 88vw);
} }
.target-chunk { .tp-target-chunk {
display: flex; display: flex;
} }
.target-char { .tp-target-char {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -346,11 +271,11 @@ button {
transition: color 0.12s ease; transition: color 0.12s ease;
} }
.target-char[data-state="done"] { .tp-target-char[data-state="done"] {
color: var(--correct); color: var(--correct);
} }
.target-char[data-state="current"] { .tp-target-char[data-state="current"] {
color: oklch(25% 0.05 175); color: oklch(25% 0.05 175);
background: var(--paper); background: var(--paper);
border-radius: 10px; border-radius: 10px;
@@ -358,7 +283,7 @@ button {
animation: correctPop 200ms ease-out; animation: correctPop 200ms ease-out;
} }
.target-char[data-state="current"][data-wrong="true"] { .tp-target-char[data-state="current"][data-wrong="true"] {
background: var(--wrong); background: var(--wrong);
animation: wrongShake 260ms ease; animation: wrongShake 260ms ease;
} }
@@ -366,22 +291,14 @@ button {
/* A space inside the target needs a visible body, or the cursor lands on nothing. Only /* A space inside the target needs a visible body, or the cursor lands on nothing. Only
the space at the cursor shows : marking every upcoming one turned a sentence into the space at the cursor shows : marking every upcoming one turned a sentence into
"Der␣Delfin␣schwimmt␣sehr␣schnell", which a six-year-old cannot read. */ "Der␣Delfin␣schwimmt␣sehr␣schnell", which a six-year-old cannot read. */
.target-char[data-blank="true"] { .tp-target-char[data-blank="true"] {
min-width: 0.9em; min-width: 0.9em;
} }
/* The two Shift keys, drawn either side of the space bar. Wider than a letter key so
they read as the modifier they are, and dim until a capital actually needs one. */
.kb-shift {
width: calc(var(--kb-size, 42px) * 1.8);
background: oklch(97% 0.01 175 / 0.12);
font-size: calc(var(--kb-size, 42px) * 0.4);
}
/* A dome plus three trailing tentacles. Without them the jellyfish read as plain /* A dome plus three trailing tentacles. Without them the jellyfish read as plain
circles, and jellyfish mode stops being a picture of anything. Drawn in CSS rather circles, and jellyfish mode stops being a picture of anything. Drawn in CSS rather
than as an emoji so the letter stays centred and legible inside the dome. */ than as an emoji so the letter stays centred and legible inside the dome. */
.jellyfish::after { .tp-jellyfish::after {
content: ""; content: "";
position: absolute; position: absolute;
bottom: -11px; bottom: -11px;