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)}")
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,

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

View File

@@ -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: <path>
#
# 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"