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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user