diff --git a/python-backend/musicmouse/tippen/curriculum.py b/python-backend/musicmouse/tippen/curriculum.py index bcfa26e..09bb0b1 100644 --- a/python-backend/musicmouse/tippen/curriculum.py +++ b/python-backend/musicmouse/tippen/curriculum.py @@ -104,6 +104,10 @@ class _YamlRoot(_Strict): problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}") seen_rewards: set[CreatureId] = set() + # A lesson's `keys` is its spotlight, not only what is new: a round may list + # every key learned so far to drill them evenly. What must stay small is how + # much is *new* in one round - two keys, the mirrored finger pair. + seen_keys: set[str] = set() for world in self.worlds: if world.reward in seen_rewards: problems.append(f"world {world.number} reuses reward {world.reward!r}") @@ -121,8 +125,10 @@ class _YamlRoot(_Strict): problems.append(f'{where}: kind "{kind}" needs a non-empty words list') if lesson.drill and lesson.keys: problems.append(f"{where}: a drill cannot also introduce keys") - if lesson.keys and len(lesson.keys) > 2: - problems.append(f"{where}: at most two keys per lesson") + new_keys = [key for key in (lesson.keys or ()) if key not in seen_keys] + if len(new_keys) > 2: + problems.append(f"{where}: at most two new keys per lesson") + seen_keys.update(lesson.keys or ()) if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]: problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"') @@ -278,7 +284,13 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]: subtitle=entry.subtitle, kind=kind, new_keys=new_keys, - spotlight_keys=keys if kind == "letters" and not is_drill else (), + # A round that lists every key learned so far still spotlights only + # what it introduces - a lesson titled "K" must drill K, not spread + # itself evenly over all five keys it happens to name. With nothing + # new, the whole list is the spotlight: that is the "mixed" replay. + spotlight_keys=( + (new_keys or keys) if kind == "letters" and not is_drill else () + ), emphasis=emphasis, active_keys=active_keys, primary_mode=primary_mode, diff --git a/python-backend/tests/test_tippen_curriculum.py b/python-backend/tests/test_tippen_curriculum.py index 94c7eb5..76458cb 100644 --- a/python-backend/tests/test_tippen_curriculum.py +++ b/python-backend/tests/test_tippen_curriculum.py @@ -117,19 +117,21 @@ def test_wrong_world_count_is_one_problem(tmp_path: Path) -> None: def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None: worlds = [dict(w) for w in VALID["worlds"]] - # Two independent problems in one file: a reused reward, and a lesson with too many - # keys - both should show up in a single error, not just the first one found. + # Two independent problems in one file: a reused reward, and a lesson introducing + # too many keys at once - both should show up in a single error, not just the first + # one found. ("a" and "s" are already taught in world 1, so only the three unseen + # keys count against the cap.) worlds[1] = {**worlds[1], "reward": "clownfish"} worlds[2] = { **worlds[2], - "lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "d"]}], + "lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "q", "w", "e"]}], } with pytest.raises(CurriculumError) as excinfo: load_curriculum(_write(tmp_path, {"worlds": worlds})) message = str(excinfo.value) assert "2 problems" in message assert "reuses reward" in message - assert "at most two keys" in message + assert "at most two new keys" in message def test_letters_kind_cannot_have_words(tmp_path: Path) -> None: diff --git a/python-backend/tippen-curriculum.yml.example b/python-backend/tippen-curriculum.yml.example index 25a2307..818612d 100644 --- a/python-backend/tippen-curriculum.yml.example +++ b/python-backend/tippen-curriculum.yml.example @@ -1,11 +1,14 @@ # The typing game's lesson plan, referenced from config.yml's general.tippen.curriculum_file. # # 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. +# - keys: the letters-kind keys this round has on the table. At most two of them may +# be new; the rest are keys already taught, written out to say "these are in play +# too". The loader tracks which keys were already active and spotlights only what is +# new, so `keys: [f, j, a, d, k]` right after `[f, j, a, d]` is a K lesson with the +# other four as review ("isolated" - heavy weight on K). A round that introduces +# nothing new spotlights its whole list instead ("mixed" - lighter weight, blended +# with everything learned so far), so 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 the backend's `ELIGIBLE_MODES` for which @@ -18,8 +21,11 @@ # # unlocks: # -# A path into the music library (see config.yml's general.library.root - absolute, or -# relative to it; "~" is expanded). Passing this lesson (two stars, or five attempts +# A path into the music library, written relative to config.yml's +# general.library.root - "Musik/Klaviersammlung/Lied.mp3", not a path from "/" or "~". +# (An absolute path, or one starting with "~", still resolves, so an old file keeps +# working - but relative is the form to write, since it survives moving the library.) +# Passing this lesson (two stars, or five attempts # regardless of score) unlocks that track and everything before it in the same album, # inclusive - so pointing at an album's last track unlocks the whole album, and # pointing at track 5 of an audiobook unlocks chapters 1 through 5. The web front-end @@ -59,7 +65,7 @@ worlds: subtitle: "Die Ringfinger" keys: [s, l] # Example - unlocks tracks 1 through 5 (inclusive) of this album: - # unlocks: ~/Music/Musik/Kinderparty - Kinderparty Lieder/05 - Lied.mp3 + # unlocks: "Musik/Kinderparty - Kinderparty Lieder/05 - Lied.mp3" - title: "S und L üben" subtitle: "Die neuen Tasten festigen" keys: [s, l] @@ -195,7 +201,7 @@ worlds: drill: true # Example - unlocks a whole audiobook (its last chapter, inclusive of every # chapter before it): - # unlocks: ~/Music/Hörbücher/Conni - Conni in den Bergen/06 - Teil.mp3 + # unlocks: "Hörbücher/Conni - Conni in den Bergen/06 - Teil.mp3" - number: 3 title: "Nach unten" @@ -295,7 +301,7 @@ worlds: drill: true # Example - unlocks a whole podcast show (its most recent episode, and every # earlier one of the same show, chronologically): - # unlocks: ~/Music/Kinderpodcasts/Wissen macht Ah/20260101 - Neu.mp3 + # unlocks: "Kinderpodcasts/Wissen macht Ah/20260101 - Neu.mp3" - number: 4 title: "Große Buchstaben"