Let a lesson list review keys beside the two it introduces

A lesson's `keys` was capped at two outright, which made it impossible to write
"K, with the four keys before it still in play". The cap now counts only what is
*new*; keys already taught can be listed freely, and the spotlight falls on the
new ones alone, so a lesson titled "K" drills K instead of spreading itself
evenly over all five keys it names. A round that introduces nothing new still
spotlights its whole list - that is the "mixed" replay, unchanged.

Also documents `unlocks:` paths as relative to the library root. Absolute and
"~" paths still resolve, so an existing file keeps working, but a relative path
survives moving the library.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 18:05:19 +02:00
parent b6342cc117
commit 36e93be79b
3 changed files with 37 additions and 17 deletions

View File

@@ -104,6 +104,10 @@ class _YamlRoot(_Strict):
problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}") problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}")
seen_rewards: set[CreatureId] = set() 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: for world in self.worlds:
if world.reward in seen_rewards: if world.reward in seen_rewards:
problems.append(f"world {world.number} reuses reward {world.reward!r}") 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') problems.append(f'{where}: kind "{kind}" needs a non-empty words list')
if lesson.drill and lesson.keys: if lesson.drill and lesson.keys:
problems.append(f"{where}: a drill cannot also introduce keys") problems.append(f"{where}: a drill cannot also introduce keys")
if lesson.keys and len(lesson.keys) > 2: new_keys = [key for key in (lesson.keys or ()) if key not in seen_keys]
problems.append(f"{where}: at most two keys per lesson") 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]: if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]:
problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{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, subtitle=entry.subtitle,
kind=kind, kind=kind,
new_keys=new_keys, 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, emphasis=emphasis,
active_keys=active_keys, active_keys=active_keys,
primary_mode=primary_mode, primary_mode=primary_mode,

View File

@@ -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: def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None:
worlds = [dict(w) for w in VALID["worlds"]] worlds = [dict(w) for w in VALID["worlds"]]
# Two independent problems in one file: a reused reward, and a lesson with too many # Two independent problems in one file: a reused reward, and a lesson introducing
# keys - both should show up in a single error, not just the first one found. # 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[1] = {**worlds[1], "reward": "clownfish"}
worlds[2] = { worlds[2] = {
**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: with pytest.raises(CurriculumError) as excinfo:
load_curriculum(_write(tmp_path, {"worlds": worlds})) load_curriculum(_write(tmp_path, {"worlds": worlds}))
message = str(excinfo.value) message = str(excinfo.value)
assert "2 problems" in message assert "2 problems" in message
assert "reuses reward" 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: def test_letters_kind_cannot_have_words(tmp_path: Path) -> None:

View File

@@ -1,11 +1,14 @@
# The typing game's lesson plan, referenced from config.yml's general.tippen.curriculum_file. # The typing game's lesson plan, referenced from config.yml's general.tippen.curriculum_file.
# #
# Every lesson: title, subtitle (read aloud). Then either: # Every lesson: title, subtitle (read aloud). Then either:
# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were # - keys: the letters-kind keys this round has on the table. At most two of them may
# already active: the first time a key appears its lesson is "isolated" (heavy # be new; the rest are keys already taught, written out to say "these are in play
# weight, alone); the second (identical) appearance is "mixed" (lighter weight, # too". The loader tracks which keys were already active and spotlights only what is
# blended with everything learned so far). Two lessons with the same `keys` back to # new, so `keys: [f, j, a, d, k]` right after `[f, j, a, d]` is a K lesson with the
# back is exactly how you write "isolated, then mixed" - no separate flag needed. # 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. # - 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 # - kind + words - a fragments/words/sentences consolidation round (dive mode by
# default; `mode:` overrides it - see the backend's `ELIGIBLE_MODES` for which # default; `mode:` overrides it - see the backend's `ELIGIBLE_MODES` for which
@@ -18,8 +21,11 @@
# #
# unlocks: <path> # unlocks: <path>
# #
# A path into the music library (see config.yml's general.library.root - absolute, or # A path into the music library, written relative to config.yml's
# relative to it; "~" is expanded). Passing this lesson (two stars, or five attempts # 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, # 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 # 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 # pointing at track 5 of an audiobook unlocks chapters 1 through 5. The web front-end
@@ -59,7 +65,7 @@ worlds:
subtitle: "Die Ringfinger" subtitle: "Die Ringfinger"
keys: [s, l] keys: [s, l]
# Example - unlocks tracks 1 through 5 (inclusive) of this album: # 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" - title: "S und L üben"
subtitle: "Die neuen Tasten festigen" subtitle: "Die neuen Tasten festigen"
keys: [s, l] keys: [s, l]
@@ -195,7 +201,7 @@ worlds:
drill: true drill: true
# Example - unlocks a whole audiobook (its last chapter, inclusive of every # Example - unlocks a whole audiobook (its last chapter, inclusive of every
# chapter before it): # 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 - number: 3
title: "Nach unten" title: "Nach unten"
@@ -295,7 +301,7 @@ worlds:
drill: true drill: true
# Example - unlocks a whole podcast show (its most recent episode, and every # Example - unlocks a whole podcast show (its most recent episode, and every
# earlier one of the same show, chronologically): # 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 - number: 4
title: "Große Buchstaben" title: "Große Buchstaben"