>
)}
- {outcome && (
+ {outcome && lesson && (
void;
}
+const NODE_SIZE = 88;
+/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
+const PATH_HALF_WIDTH = 160;
+
+/** What a consolidation node's key-label line says when it has no keys of its own to
+ * show - a child-friendly word rather than the raw `LessonKind`. */
+const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> = {
+ fragments: "Wörter",
+ words: "Wörter",
+ sentences: "Sätze",
+};
+
export function LessonMap({ progress, selected, onPick }: Props) {
return (
- );
-}
diff --git a/tippen/src/components/ResultSheet.tsx b/tippen/src/components/ResultSheet.tsx
index 057370f..17fc083 100644
--- a/tippen/src/components/ResultSheet.tsx
+++ b/tippen/src/components/ResultSheet.tsx
@@ -9,9 +9,11 @@ import { useEffect, useRef } from "react";
import { creatureById } from "../lib/aquarium";
import type { CreatureId } from "../lib/aquarium";
+import type { ModeId } from "../lib/curriculum";
import type { RunResult } from "../lib/grading";
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../lib/grading";
import type { AnimalId } from "../lib/grading";
+import { MODE_INFO } from "../lib/modeInfo";
interface Props {
result: RunResult;
@@ -23,6 +25,10 @@ interface Props {
/** The fastest animal earned on any lesson so far - decides how much of the ladder
* may be revealed. */
bestEver: AnimalId | null;
+ /** Extra games this lesson didn't gate progress on - feed or race, offered only once
+ * the lesson is passed. Playing one never changes the unlock, only the best score. */
+ bonusModes: readonly ModeId[];
+ onPlayBonus: (mode: ModeId) => void;
onRetry: () => void;
onContinue: () => void;
/** Null at the end of the curriculum. */
@@ -35,6 +41,8 @@ export function ResultSheet({
newCreature,
isNewBest,
bestEver,
+ bonusModes,
+ onPlayBonus,
onRetry,
onContinue,
continueLabel,
@@ -159,6 +167,33 @@ export function ResultSheet({
)}
+
+ {/* Extra games this lesson didn't need to pass - a treat, not a requirement, so
+ they only appear once the lesson is already behind her. `.pill` is styled for
+ the dark stage background, not this light sheet, so these get their own
+ (smaller, quieter) version of the sheet's own button look instead. */}
+ {bonusModes.length > 0 && (
+
+ {bonusModes.map((mode) => (
+
+ ))}
+
+ )}
);
diff --git a/tippen/src/components/modes/DiveRun.tsx b/tippen/src/components/modes/DiveRun.tsx
index ab616b5..116d234 100644
--- a/tippen/src/components/modes/DiveRun.tsx
+++ b/tippen/src/components/modes/DiveRun.tsx
@@ -1,7 +1,7 @@
-/** Dive mode - the core drill, and the run that counts for the unlock.
- *
- * A line of chunks with a moving cursor above the keyboard. Everything else in the
- * game is a variation on this; this is the one that is measured. */
+/** Dive mode - the plain drill: a line of chunks with a moving cursor above the
+ * keyboard. Most fragments/words/sentences lessons play this by default, though feed
+ * 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. */
import { currentChar } from "../../lib/engine";
import type { RunResult } from "../../lib/grading";
diff --git a/tippen/src/components/modes/PearlsRun.tsx b/tippen/src/components/modes/PearlsRun.tsx
deleted file mode 100644
index 04e6e1d..0000000
--- a/tippen/src/components/modes/PearlsRun.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-/** Pearls mode - the mode with no clock.
- *
- * Every other mode measures something. This one deliberately does not show a timer, a
- * progress bar, a streak or a speed: an oyster opens, a word is inside it, and each
- * correct letter is a pearl on the string. A mistake costs a pearl, and that is the
- * only pressure there is.
- *
- * It exists because a six-year-old having a bad afternoon needs somewhere to go that is
- * still the same lesson and still counts, but cannot be failed at quickly. Every lesson
- * offers it, always. The run is still graded the same way underneath - a calm round and
- * a frantic one land in the same `grade()` - but nothing on screen is urging her on. */
-
-import { useState } from "react";
-
-import { currentChar } from "../../lib/engine";
-import type { RunEvent } from "../../lib/engine";
-import { chunkOffsets } from "../../lib/generator";
-import type { RunResult } from "../../lib/grading";
-import type { Progress } from "../../lib/progress";
-import { useRun } from "../../hooks/useRun";
-import { Keyboard } from "../Keyboard";
-
-interface Props {
- chunks: readonly string[];
- text: string;
- spaceActive: boolean;
- activeKeys: readonly string[];
- progress: Progress;
- paused: boolean;
- onFinished: (result: RunResult) => void;
-}
-
-export function PearlsRun({
- chunks,
- text,
- spaceActive,
- activeKeys,
- progress,
- paused,
- onFinished,
-}: Props) {
- const [pearls, setPearls] = useState(0);
-
- const onEvent = (event: RunEvent) => {
- // A pearl per correct letter, one lost per mistake - but never below zero. Watching
- // the string shrink past empty is the kind of punishment this mode exists to avoid.
- if (event.type === "correct") setPearls((n) => n + 1);
- else if (event.type === "wrong" && event.firstAt) setPearls((n) => Math.max(0, n - 1));
- };
-
- const { state, wrong } = useRun({
- target: text,
- sound: progress.settings.sound,
- paused,
- onFinished,
- onEvent,
- });
-
- const next = currentChar(state);
- const offsets = chunkOffsets(chunks, spaceActive);
-
- // Which word the oyster is holding right now.
- const currentChunkIndex = offsets.findIndex((start, i) => {
- const end = start + (chunks[i]?.length ?? 0);
- return state.index <= end;
- });
- const word = chunks[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? "";
- const wordStart = offsets[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? 0;
-
- return (
-
- {/* The pearl string. It only ever grows, one bead per letter. */}
-
- );
-}
diff --git a/tippen/src/data/curriculum.yaml b/tippen/src/data/curriculum.yaml
new file mode 100644
index 0000000..1ab8275
--- /dev/null
+++ b/tippen/src/data/curriculum.yaml
@@ -0,0 +1,422 @@
+# 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."
diff --git a/tippen/src/lib/__tests__/curriculum.test.ts b/tippen/src/lib/__tests__/curriculum.test.ts
index 05f913b..64a3bf3 100644
--- a/tippen/src/lib/__tests__/curriculum.test.ts
+++ b/tippen/src/lib/__tests__/curriculum.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
+import { ELIGIBLE_MODES, FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
import { fingerOf, keyForChar, needsShift } from "../fingers";
describe("LESSONS", () => {
@@ -125,15 +125,52 @@ describe("LESSONS", () => {
}
});
- it("only offers word modes where words exist", () => {
+ it("plays a mode that actually fits its kind", () => {
for (const lesson of LESSONS) {
- const wordModes = lesson.modes.some((mode) => mode === "feed" || mode === "race");
- expect(wordModes).toBe(lesson.words.length > 0);
+ 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("always offers the pressure-free pearl-diving mode", () => {
- for (const lesson of LESSONS) expect(lesson.modes).toContain("pearls");
+ 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", () => {
@@ -154,7 +191,7 @@ describe("LESSONS", () => {
it("follows every pair of new keys with a drill", () => {
const drills = LESSONS.filter((lesson) => lesson.isDrill);
- expect(drills.length).toBeGreaterThanOrEqual(12);
+ 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([]);
@@ -162,8 +199,8 @@ describe("LESSONS", () => {
}
});
- it("is long enough to be a real course", () => {
- expect(LESSONS.length).toBeGreaterThanOrEqual(40);
+ it("is long enough to be a real, Duolingo-length course", () => {
+ expect(LESSONS.length).toBeGreaterThanOrEqual(80);
expect(WORLDS.length).toBe(5);
});
diff --git a/tippen/src/lib/__tests__/lessonPath.test.ts b/tippen/src/lib/__tests__/lessonPath.test.ts
new file mode 100644
index 0000000..c756073
--- /dev/null
+++ b/tippen/src/lib/__tests__/lessonPath.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+
+import { PATH_AMPLITUDE, pathD, pointFor, xOffsetFor } from "../lessonPath";
+
+describe("xOffsetFor", () => {
+ it("starts centred", () => {
+ expect(xOffsetFor(0)).toBeCloseTo(0);
+ });
+
+ it("never strays further than the amplitude", () => {
+ for (let i = 0; i < 40; i++) {
+ expect(Math.abs(xOffsetFor(i))).toBeLessThanOrEqual(PATH_AMPLITUDE + 1e-9);
+ }
+ });
+
+ it("is deterministic", () => {
+ expect(xOffsetFor(5)).toBe(xOffsetFor(5));
+ });
+});
+
+describe("pointFor", () => {
+ it("places nodes strictly further down as the index grows", () => {
+ let previousY = -Infinity;
+ for (let i = 0; i < 10; i++) {
+ const point = pointFor(i);
+ expect(point.y).toBeGreaterThan(previousY);
+ previousY = point.y;
+ }
+ });
+});
+
+describe("pathD", () => {
+ it("draws nothing for fewer than two points", () => {
+ expect(pathD([])).toBe("");
+ expect(pathD([{ x: 0, y: 0 }])).toBe("");
+ });
+
+ it("starts at the first point and mentions every point", () => {
+ const points = [pointFor(0), pointFor(1), pointFor(2)];
+ const d = pathD(points);
+ expect(d.startsWith(`M ${points[0]!.x} ${points[0]!.y}`)).toBe(true);
+ for (const point of points) expect(d).toContain(String(point.x));
+ });
+});
diff --git a/tippen/src/lib/__tests__/progress.test.ts b/tippen/src/lib/__tests__/progress.test.ts
index ea00877..ff88dd5 100644
--- a/tippen/src/lib/__tests__/progress.test.ts
+++ b/tippen/src/lib/__tests__/progress.test.ts
@@ -150,7 +150,7 @@ 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(1);
+ expect(progress.version).toBe(2);
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
}
});
@@ -177,6 +177,33 @@ describe("migrate", () => {
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", () => {
diff --git a/tippen/src/lib/curriculum.ts b/tippen/src/lib/curriculum.ts
index 175daa4..52c7735 100644
--- a/tippen/src/lib/curriculum.ts
+++ b/tippen/src/lib/curriculum.ts
@@ -1,41 +1,36 @@
-/** The lesson plan: 48 lessons in 5 worlds.
+/** 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.
+ * 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 the deviation is *pace*. A
- * six-year-old gets:
+ * 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.
*
- * - **one new key per lesson** from world 2 onward (world 1 pairs the same finger on
- * both hands, which is one motion, not two);
- * - **a drill lesson after every pair of new keys** - no new keys at all, just the
- * ones she has. Consolidation is where typing actually becomes automatic, and a
- * course that only ever moves forward never gives it room;
- * - **short rounds**, growing from twelve characters in world 1 to whole sentences in
- * world 5.
- *
- * That is 48 short lessons rather than 17 big ones. The curriculum is the same; the
- * steps between are small enough to climb.
- *
- * `activeKeys` is cumulative on purpose: lesson 30 still drills `f`, or the first
+ * `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";
-export type ModeId =
- | "dive"
- | "bubbles"
- | "jellyfish"
- | "feed"
- | "race"
- | "pearls";
+/** 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";
-/** What a lesson's targets are made of. Decides which modes make sense: jellyfish mode
- * cannot show a sentence, and feed mode cannot put one on a fish. */
-export type LessonKind = "letters" | "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;
@@ -44,16 +39,31 @@ export interface Lesson {
title: string;
/** What this lesson is about, in words a six-year-old hears read aloud. */
subtitle: string;
- /** The keys introduced here - what the generator weights toward. Empty for a drill. */
+ 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[];
- /** Which game modes this lesson offers, in carousel order. */
- modes: readonly ModeId[];
- /** Real German words (or sentences) for the word modes. */
+ /** 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[];
- kind: LessonKind;
- /** True for a consolidation lesson - no new keys, just practice. */
+ /** 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;
@@ -70,233 +80,168 @@ export interface World {
/** 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. The
- * pets grow with the worlds: a small fish first, the pearl mussel - the pearls' own
- * home - last. */
-export const WORLDS: readonly World[] = [
- { number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" },
- { number: 2, title: "Nach oben", emoji: "🌊", reward: "octopus" },
- { number: 3, title: "Nach unten", emoji: "🪸", reward: "seahorse" },
- { number: 4, title: "Große Buchstaben", emoji: "👑", reward: "turtle" },
- { number: 5, title: "Ganze Sätze", emoji: "📖", reward: "pearlmussel" },
-];
+ * emoji. lib/aquarium.ts has why the two are kept in different visual languages. */
+const CREATURE_IDS: readonly CreatureId[] = ["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"];
-/** Letters only - no words can be spelled yet. */
-const LETTER_MODES: readonly ModeId[] = ["dive", "bubbles", "jellyfish", "pearls"];
-/** Real words exist: the word modes and the race join in. */
-const WORD_MODES: readonly ModeId[] = ["dive", "feed", "bubbles", "race", "jellyfish", "pearls"];
-/** Sentences do not fit on a bubble or a fish. */
-const SENTENCE_MODES: readonly ModeId[] = ["dive", "race", "pearls"];
+/** 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 = {
+ letters: ["bubbles", "jellyfish"],
+ fragments: ["dive", "feed"],
+ words: ["dive", "feed", "race"],
+ sentences: ["dive", "race"],
+};
-/** Line length by world - a full block of text per round, at least five lines of it.
- *
- * Deliberately five times what the first version used. Twelve characters was long
- * enough to *finish*, which is what the first day needs, but far too short to build
- * anything: a round that ends before the hands settle measures reaction time rather
- * than typing, and the score bounces around so much that getting better is invisible.
- * Sixty-plus characters is long enough for a rhythm to appear and for the characters
- * per minute to mean something.
- *
- * World 1 is still the gentlest by a wide margin, and the rounds still grow from there. */
+/** 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 (world === 1) return kind === "words" ? { chunks: 25, chunkSize: 3 } : { chunks: 24, chunkSize: 3 }; // 72 characters
+ 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
- if (world === 3) return { chunks: 30, chunkSize: 4 }; // 120
- if (world === 4) return { chunks: 25, chunkSize: 4 }; // 25 words
- return { chunks: 10, chunkSize: 4 }; // world 5: ten whole sentences
+ return { chunks: 30, chunkSize: 4 }; // 120, world 3 onward
}
-interface PlanEntry {
- world: number;
+interface YamlLesson {
title: string;
subtitle: string;
- /** Empty marks a drill - consolidation, no new keys. */
- newKeys: readonly string[];
- words?: readonly string[];
kind?: LessonKind;
+ keys?: readonly string[];
+ drill?: boolean;
+ mode?: ModeId;
+ words?: readonly string[];
}
-/** The plan. Everything else is derived from this, so a curriculum change is one edit. */
-const PLAN: readonly PlanEntry[] = [
- // ---------------------------------------------------------------- World 1 --
- // Two keys per lesson, always the same finger on each hand - one motion, mirrored.
- // F and J first because they carry the tactile bumps: they are the two keys a child
- // can find without looking, and every other key is taught as an offset from them.
- { world: 1, title: "F und J", subtitle: "Die Zeigefinger - die Tasten mit den Punkten", newKeys: ["f", "j"] },
- { world: 1, title: "D und K", subtitle: "Die Mittelfinger", newKeys: ["d", "k"] },
- { world: 1, title: "Übung: F J D K", subtitle: "Die vier Tasten zusammen", newKeys: [] },
- { world: 1, title: "S und L", subtitle: "Die Ringfinger", newKeys: ["s", "l"] },
- { world: 1, title: "Übung: sechs Tasten", subtitle: "Alles bisher zusammen", newKeys: [] },
- { world: 1, title: "A und Ö", subtitle: "Die kleinen Finger", newKeys: ["a", "ö"] },
- { world: 1, title: "Übung: die Grundstellung", subtitle: "Alle acht Finger", newKeys: [],
- words: [
- "da", "das", "dass", "ja", "als", "all", "fall", "falls", "lass",
- "aal", "aas", "as", "ass", "fass", "saal", "kalk", "salsa", "jass",
- "asa", "sas", "sad", "sal", "dal", "fad", "fal", "jak", "jas", "kal",
- "kas", "lak", "las", "lad", "dasa", "sala", "kala", "jala", "fasa",
- "daka", "kasa", "salla", "dalla", "jassa", "fassa", "kalla", "falla",
- "salak", "dalas", "jasal", "kalas",
- "lö", "döl", "söl", "jöl", "köl", "löl",
- ] },
- { world: 1, title: "Die Leertaste", subtitle: "Der Daumen kommt dazu", newKeys: [SPACE_KEY] },
+interface YamlWorld {
+ number: number;
+ title: string;
+ emoji: string;
+ reward: CreatureId;
+ lessons: readonly YamlLesson[];
+}
- // ---------------------------------------------------------------- World 2 --
- // One key per lesson from here on, an Übung after every two.
- { world: 2, title: "Das E", subtitle: "Mittelfinger links nach oben", newKeys: ["e"],
- words: ["elf", "alle", "esel", "see", "keks", "fell"] },
- { world: 2, title: "Das I", subtitle: "Mittelfinger rechts nach oben", newKeys: ["i"],
- words: ["die", "sie", "eis", "fiel", "lied", "leise", "diese", "seide"] },
- { world: 2, title: "Übung: E und I", subtitle: "Die neuen Tasten festigen", newKeys: [],
- words: ["die", "eis", "elf", "leise", "diese", "esel", "keks", "fiel"] },
- { world: 2, title: "Das R", subtitle: "Zeigefinger links nach oben", newKeys: ["r"],
- words: ["rad", "reis", "eier", "riese", "leider", "feier", "keller", "kerle"] },
- { world: 2, title: "Das U", subtitle: "Zeigefinger rechts nach oben", newKeys: ["u"],
- words: ["rufe", "kurs", "lauf", "feuer", "sauer", "ruder", "saurier", "raus"] },
- { world: 2, title: "Übung: R und U", subtitle: "Die Zeigefinger nach oben", newKeys: [],
- words: ["rufe", "reis", "feuer", "sauer", "eier", "lauf", "ruder", "leider"] },
- { world: 2, title: "Das T", subtitle: "Zeigefinger links weit nach oben", newKeys: ["t"],
- words: ["tier", "tafel", "titel", "kette", "leiter", "reiter", "dritte", "alter"] },
- { world: 2, title: "Das Z", subtitle: "Zeigefinger rechts weit nach oben", newKeys: ["z"],
- words: ["zeit", "salz", "zelt", "sitz", "katze", "kreuz", "zirkus", "zettel"] },
- { world: 2, title: "Übung: T und Z", subtitle: "Weit nach oben greifen", newKeys: [],
- words: ["zeit", "tier", "salz", "katze", "leiter", "zelt", "reiter", "zirkus"] },
- { world: 2, title: "Das O", subtitle: "Ringfinger rechts nach oben", newKeys: ["o"],
- words: ["rot", "tor", "los", "sofa", "foto", "oder", "torte", "koffer"] },
- { world: 2, title: "Das W", subtitle: "Ringfinger links nach oben", newKeys: ["w"],
- words: ["wo", "wald", "weit", "zwei", "wolke", "wurst", "wasser", "wetter"] },
- { world: 2, title: "Übung: W und O", subtitle: "Die Ringfinger nach oben", newKeys: [],
- words: ["wo", "wald", "torte", "wolke", "foto", "wasser", "zwei", "oder"] },
- { world: 2, title: "Das P", subtitle: "Kleiner Finger rechts nach oben", newKeys: ["p"],
- words: ["pause", "post", "kopf", "apfel", "platz", "puppe", "papier", "palette"] },
- { world: 2, title: "Das Q", subtitle: "Kleiner Finger links nach oben", newKeys: ["q"],
- words: ["quiz", "quark", "quelle", "qualle", "quader", "quitte"] },
- { world: 2, title: "Das Ü", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ü"],
- words: ["für", "tür", "tüte", "wüste", "küste", "prüfe", "würfel", "flüsse"] },
- { world: 2, title: "Übung: die obere Reihe", subtitle: "Die ganze Reihe zusammen", newKeys: [],
- words: ["wolke", "zeit", "pause", "prüfe", "qualle", "torte", "reiter", "würfel"] },
+interface YamlRoot {
+ worlds: readonly YamlWorld[];
+}
- // ---------------------------------------------------------------- World 3 --
- { world: 3, title: "Das N", subtitle: "Zeigefinger rechts nach unten", newKeys: ["n"],
- words: ["nase", "nein", "nudel", "kind", "wind", "sonne", "kanne", "unten"] },
- { world: 3, title: "Das M", subtitle: "Zeigefinger rechts, neben dem N", newKeys: ["m"],
- words: ["mama", "mond", "meer", "maus", "matte", "sommer", "moment", "tomate"] },
- { world: 3, title: "Übung: N und M", subtitle: "Die neuen Tasten festigen", newKeys: [],
- words: ["mond", "nase", "meer", "sonne", "name", "maus", "moment", "kind"] },
- { world: 3, title: "Das G", subtitle: "Zeigefinger links, in der Mitte", newKeys: ["g"],
- words: ["gut", "gans", "regen", "wagen", "tiger", "garten", "morgen", "gestern"] },
- { world: 3, title: "Das H", subtitle: "Zeigefinger rechts, in der Mitte", newKeys: ["h"],
- words: ["hase", "haus", "hund", "hemd", "hupe", "sehen", "hunger", "höhle"] },
- { world: 3, title: "Übung: G und H", subtitle: "Die Mitte der Grundreihe", newKeys: [],
- words: ["haus", "tiger", "hund", "garten", "hunger", "regen", "höhle", "morgen"] },
- { world: 3, title: "Das C", subtitle: "Mittelfinger links nach unten", newKeys: ["c"],
- words: ["koch", "milch", "schaf", "schule", "sicher", "chaos", "clown", "cousin"] },
- { world: 3, title: "Das V", subtitle: "Zeigefinger links nach unten", newKeys: ["v"],
- words: ["vier", "vase", "voll", "vogel", "vater", "video", "verein", "vulkan"] },
- { world: 3, title: "Übung: C und V", subtitle: "Nach unten greifen", newKeys: [],
- words: ["vogel", "milch", "vater", "schule", "vier", "koch", "clown", "vulkan"] },
- { world: 3, title: "Das B", subtitle: "Zeigefinger links, neben dem V", newKeys: ["b"],
- words: ["baum", "boot", "bunt", "bild", "brot", "bauch", "bagger", "arbeit"] },
- { world: 3, title: "Das Y", subtitle: "Kleiner Finger links nach unten", newKeys: ["y"],
- words: ["yoga", "baby", "typ", "pony", "hobby", "yacht", "system"] },
- { world: 3, title: "Übung: B und Y", subtitle: "Ganz unten links", newKeys: [],
- words: ["baby", "boot", "baum", "hobby", "brot", "pony", "bagger", "yoga"] },
- { world: 3, title: "Das X", subtitle: "Ringfinger links nach unten", newKeys: ["x"],
- words: ["hexe", "taxi", "box", "text", "extra", "xylofon", "maximal"] },
- { world: 3, title: "Das Ä", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ä"],
- words: ["bär", "käse", "bäume", "gläser", "ärmel", "träume", "hände", "mädchen"] },
- { world: 3, title: "Übung: alle Buchstaben", subtitle: "Das ganze Alphabet", newKeys: [],
- words: ["delfin", "wasser", "xylofon", "bäume", "vogel", "qualle", "muschel", "tauchen"] },
+/** 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();
+ 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 4 --
- { world: 4, title: "Umschalttaste rechts", subtitle: "Große Buchstaben der linken Hand", newKeys: ["⇧"],
- words: ["Delfin", "Wal", "Fisch", "Baum", "Garten", "Ente", "Vogel", "Riff",
- "Sonne", "Auto", "Tiger", "Robbe", "Qualle", "Stern", "Wolke", "Ball"] },
- { world: 4, title: "Umschalttaste links", subtitle: "Große Buchstaben der rechten Hand", newKeys: [],
- words: ["Haus", "Kind", "Mond", "Nase", "Lampe", "Onkel", "Uhr", "Puppe",
- "Hai", "Muschel", "Insel", "Opa", "Oma", "Pinguin", "Kuchen", "Zelt"] },
- { world: 4, title: "Übung: Namen", subtitle: "Namen fangen groß an", newKeys: [],
- words: ["Anna", "Lena", "Paul", "Mia", "Emil", "Jonas", "Tom", "Lisa",
- "Ben", "Nora", "Finn", "Ida", "Max", "Ella", "Oskar", "Greta"] },
- { world: 4, title: "Übung: große und kleine", subtitle: "Beides gemischt", newKeys: [],
- 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"] },
+ 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}"`);
+ }
+ });
+ }
+}
- // ---------------------------------------------------------------- World 5 --
- { world: 5, title: "Der Punkt", subtitle: "Ringfinger rechts nach unten", newKeys: ["."], 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.",
- ] },
- { world: 5, title: "Das Komma", subtitle: "Mittelfinger rechts nach unten", newKeys: [","], kind: "sentences",
- 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.",
- ] },
- { world: 5, title: "Der Bindestrich", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["-"], 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.",
- ] },
- { world: 5, title: "Fragezeichen und Ausrufezeichen", subtitle: "Mit der Umschalttaste", newKeys: ["ß", "1"], 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!",
- ] },
- { world: 5, title: "Übung: ganze Sätze", subtitle: "Alles zusammen", newKeys: [], kind: "sentences",
- 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.",
- ] },
-];
+function loadPlan(): readonly YamlWorld[] {
+ const root = parse(curriculumYaml) as YamlRoot;
+ validatePlan(root.worlds);
+ return root.worlds;
+}
-function buildLessons(): Lesson[] {
+function buildLessons(worlds: readonly YamlWorld[]): Lesson[] {
const lessons: Lesson[] = [];
const active = new Set();
+ const seenBefore = new Set();
+ // 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
- PLAN.forEach((entry, i) => {
- for (const key of entry.newKeys) active.add(key);
- // The space-bar lesson is where the whole home row comes together, so it
- // activates every home key - a belt-and-braces guarantee that the four finger-pair
- // lessons before it really did cover all eight.
- if (entry.newKeys.includes(SPACE_KEY)) for (const key of HOME_ROW) active.add(key);
+ 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. The question-mark lesson
- // reaches its marks with Shift too, so ß and 1 are active as *keys* even though the
- // characters that appear are ? and !.
- const activeKeys = [...active].filter((key) => key !== "⇧").sort();
- const words = entry.words ?? [];
- const kind: LessonKind = entry.kind ?? (words.length > 0 ? "words" : "letters");
+ // 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;
- lessons.push({
- id: `l${String(i + 1).padStart(2, "0")}`,
- world: entry.world,
- number: i + 1,
- title: entry.title,
- subtitle: entry.subtitle,
- newKeys: entry.newKeys,
- activeKeys,
- modes: kind === "sentences" ? SENTENCE_MODES : kind === "words" ? WORD_MODES : LETTER_MODES,
- words,
- kind,
- isDrill: entry.newKeys.length === 0,
- ...lengthFor(entry.world, kind),
- });
- });
+ 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;
}
-export const LESSONS: readonly Lesson[] = buildLessons();
+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]));
diff --git a/tippen/src/lib/generator.ts b/tippen/src/lib/generator.ts
index 31993d2..aa8797d 100644
--- a/tippen/src/lib/generator.ts
+++ b/tippen/src/lib/generator.ts
@@ -38,29 +38,33 @@ export interface LineOptions {
chunkSize?: number;
/** The key to over-represent, if any. */
focusKey?: string | null;
- /** The keys this lesson introduces. They take the majority of the line; everything
+ /** The keys this lesson spotlights. They take the majority of the line; everything
* learned earlier keeps appearing as review. */
newKeys?: readonly string[];
+ /** How much of the line the spotlighted keys should take, in place of the default
+ * (see `NEW_KEY_SHARE`). An isolated round wants this high; a mixed round - the same
+ * keys again, but blended with everything else - wants it lower. */
+ newKeyShare?: number;
}
/** The most of a line one key may ever occupy. Above this it stops being practice and
* starts being a stutter - and on a small key set it starves the other fingers. */
const MAX_FOCUS_SHARE = 0.3;
-/** How much of a line the lesson's *new* keys should take. The rest is review of
- * everything learned so far, which is what stops the early lessons rotting while the
- * late ones are learned.
+/** How much of a line the lesson's spotlighted keys should take by default. The rest is
+ * review of everything learned so far, which is what stops the early lessons rotting
+ * while the late ones are learned.
*
* Without this, lesson 2 ("the right hand") drew evenly from all eight home keys and
* spent half the line on the left hand it had already taught - which is not what a
* lesson called "the right hand" should drill. */
const NEW_KEY_SHARE = 0.6;
-/** Copies of each new key needed to reach `NEW_KEY_SHARE` of the pool, clamped so a
- * lesson with one new key and many old ones does not bury the review entirely. */
-function newKeyCopies(newCount: number, oldCount: number): number {
+/** Copies of each spotlighted key needed to reach `share` of the pool, clamped so a
+ * lesson with one spotlighted key and many old ones does not bury the review entirely. */
+function newKeyCopies(newCount: number, oldCount: number, share: number): number {
if (newCount === 0 || oldCount === 0) return 1;
- const exact = (NEW_KEY_SHARE * oldCount) / (newCount * (1 - NEW_KEY_SHARE));
+ const exact = (share * oldCount) / (newCount * (1 - share));
return Math.max(1, Math.min(6, Math.round(exact)));
}
@@ -81,6 +85,7 @@ function weighted(
activeKeys: readonly string[],
focusKey: string | null | undefined,
newKeys: readonly string[] = [],
+ newKeyShare: number = NEW_KEY_SHARE,
): string[] {
const letters = activeKeys.filter((key) => key !== " ");
if (letters.length === 0) return [];
@@ -91,7 +96,7 @@ function weighted(
const pool = [...letters];
if (newActive.length > 0 && oldActive.length > 0) {
- const copies = newKeyCopies(newActive.length, oldActive.length);
+ const copies = newKeyCopies(newActive.length, oldActive.length, newKeyShare);
for (const key of newActive) for (let i = 1; i < copies; i++) pool.push(key);
}
@@ -137,8 +142,8 @@ export function drillChunks(
rng: Rng,
options: LineOptions = {},
): string[] {
- const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [] } = options;
- const pool = weighted(activeKeys, focusKey, newKeys);
+ const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [], newKeyShare } = options;
+ const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
if (pool.length === 0) return [];
const letters = bagDraw(pool, chunks * chunkSize, rng);
@@ -225,8 +230,9 @@ export function letterStream(
count: number,
focusKey?: string | null,
newKeys: readonly string[] = [],
+ newKeyShare?: number,
): string[] {
- const pool = weighted(activeKeys, focusKey, newKeys);
+ const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
if (pool.length === 0) return [];
return bagDraw(pool, count, rng);
}
diff --git a/tippen/src/lib/lessonPath.ts b/tippen/src/lib/lessonPath.ts
new file mode 100644
index 0000000..c18db5a
--- /dev/null
+++ b/tippen/src/lib/lessonPath.ts
@@ -0,0 +1,45 @@
+/** The geometry of the zigzag lesson path - kept pure and out of the component so it can
+ * be tested the way `aquarium.ts`'s swim physics are. */
+
+/** How many nodes make one full left-to-right-to-left swing. */
+export const PATH_PERIOD = 6;
+
+/** The furthest a node strays from the centre line, in px. */
+export const PATH_AMPLITUDE = 110;
+
+/** Vertical spacing between two nodes, in px. */
+export const NODE_SPACING = 108;
+
+/** Horizontal offset for the nth node of a world's path, centred on 0. A sine wave
+ * rather than a zigzag of straight segments, so the path reads as one smooth ribbon
+ * instead of a jagged staircase. */
+export function xOffsetFor(indexInWorld: number): number {
+ return Math.sin((indexInWorld / PATH_PERIOD) * 2 * Math.PI) * PATH_AMPLITUDE;
+}
+
+export interface PathPoint {
+ x: number;
+ y: number;
+}
+
+/** The centre of the nth node, for both its own placement and the connector line. */
+export function pointFor(indexInWorld: number): PathPoint {
+ return { x: xOffsetFor(indexInWorld), y: indexInWorld * NODE_SPACING + NODE_SPACING / 2 };
+}
+
+/** An SVG path string threading every node centre with a smooth curve - a straight
+ * polyline through a sine wave looks faceted; a vertical Bezier through each segment
+ * does not. Empty/one-point paths draw nothing, which is fine: a one-lesson world needs
+ * no connector. */
+export function pathD(points: readonly PathPoint[]): string {
+ if (points.length < 2) return "";
+ const [first, ...rest] = points;
+ let d = `M ${first!.x} ${first!.y}`;
+ for (let i = 0; i < rest.length; i++) {
+ const from = points[i]!;
+ const to = rest[i]!;
+ const midY = (from.y + to.y) / 2;
+ d += ` C ${from.x} ${midY}, ${to.x} ${midY}, ${to.x} ${to.y}`;
+ }
+ return d;
+}
diff --git a/tippen/src/lib/modeInfo.ts b/tippen/src/lib/modeInfo.ts
new file mode 100644
index 0000000..9c1e8bf
--- /dev/null
+++ b/tippen/src/lib/modeInfo.ts
@@ -0,0 +1,12 @@
+/** Labels and emoji for each game, shared by the result sheet's bonus buttons and the
+ * lesson map's mode badge. */
+
+import type { ModeId } from "./curriculum";
+
+export const MODE_INFO: Record = {
+ dive: { emoji: "🤿", name: "Tauchgang" },
+ bubbles: { emoji: "🫧", name: "Blasenplatzen" },
+ jellyfish: { emoji: "🦑", name: "Quallenalarm" },
+ feed: { emoji: "🐟", name: "Fütterungszeit" },
+ race: { emoji: "🐬", name: "Delfinrennen" },
+};
diff --git a/tippen/src/lib/progress.ts b/tippen/src/lib/progress.ts
index a41b106..2fc127f 100644
--- a/tippen/src/lib/progress.ts
+++ b/tippen/src/lib/progress.ts
@@ -46,7 +46,7 @@ export interface Settings {
}
export interface Progress {
- version: 1;
+ version: 2;
lessons: Record;
keyStats: Record;
pearls: number;
@@ -66,7 +66,7 @@ export function freshProgress(): Progress {
lessons[lesson.id] = emptyLesson(lesson.id === FIRST_LESSON_ID);
}
return {
- version: 1,
+ version: 2,
lessons,
keyStats: {},
pearls: 0,
@@ -76,18 +76,43 @@ export function freshProgress(): Progress {
};
}
+/** 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, fresh: Progress): Omit