"""The typing game's lesson plan: a server-side mirror of tippen's ``curriculum.ts``. Content lives in a YAML file named by ``general.tippen.curriculum_file``; this module only loads, validates and derives it - the same split the frontend used to do entirely on its own before progress (and therefore "has this lesson been passed") moved to the backend, which is also what reward unlocking needs the derived lesson list for. Validation follows this codebase's own rule (see ``musicmouse.config``): unknown keys are rejected and every problem in the file is collected and reported at once, not one ``ValueError`` per run. """ from __future__ import annotations import logging from dataclasses import dataclass from pathlib import Path from typing import Final, Literal, TypeAlias from pydantic import BaseModel, ConfigDict, ValidationError, model_validator from ruamel.yaml import YAML from ruamel.yaml.error import YAMLError _log = logging.getLogger(__name__) __all__ = [ "CREATURE_IDS", "CreatureId", "Curriculum", "CurriculumError", "Lesson", "World", "first_lesson_id", "lesson_by_id", "load_curriculum", "next_lesson", "world_reward", ] LessonKind: TypeAlias = Literal["letters", "fragments", "words", "sentences"] ModeId: TypeAlias = Literal["dive", "bubbles", "feed", "race"] CreatureId: TypeAlias = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"] #: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is #: allowed to grow, since each id names a drawing under ``public/aquarium/``. CREATURE_IDS: Final[tuple[CreatureId, ...]] = ( "clownfish", "octopus", "seahorse", "turtle", "pearlmussel", ) #: Which modes make sense for a kind - letters rounds are single keys, so only the #: arcade mode fits; only words and sentences are long enough for a race. ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = { "letters": ("bubbles",), "fragments": ("dive", "feed"), "words": ("dive", "feed", "race"), "sentences": ("dive", "race"), } #: The home row, left to right - see ``tippen/src/lib/fingers.ts``. HOME_ROW: Final[tuple[str, ...]] = ("a", "s", "d", "f", "j", "k", "l", "ö") SPACE_KEY: Final = " " class CurriculumError(Exception): """Raised with an already human-readable, multi-line message.""" class _Strict(BaseModel): model_config = ConfigDict(extra="forbid") class _YamlLesson(_Strict): title: str subtitle: str kind: LessonKind | None = None keys: tuple[str, ...] | None = None drill: bool = False mode: ModeId | None = None words: tuple[str, ...] | None = None #: A library path (``~`` allowed) this lesson unlocks, inclusive of the track or #: episode it names - see ``musicmouse.tippen.rewards``. unlocks: str | None = None class _YamlWorld(_Strict): number: int title: str emoji: str reward: CreatureId lessons: tuple[_YamlLesson, ...] class _YamlRoot(_Strict): worlds: tuple[_YamlWorld, ...] @model_validator(mode="after") def _check_plan(self) -> _YamlRoot: problems: list[str] = [] if len(self.worlds) != len(CREATURE_IDS): 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}") seen_rewards.add(world.reward) for i, lesson in enumerate(world.lessons): where = f"world {world.number}, lesson {i + 1} ({lesson.title!r})" words = lesson.words or () kind: LessonKind = lesson.kind or "letters" if words and lesson.kind is None: problems.append(f"{where}: has words but no explicit kind") if kind == "letters" and words: problems.append(f'{where}: kind "letters" cannot have words') if kind != "letters" and not words: 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") 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}"') if problems: plural = "s" if len(problems) != 1 else "" raise ValueError( f"{len(problems)} problem{plural} in the curriculum file:\n" + "\n".join(f" {p}" for p in problems) ) return self def _format_curriculum_errors(error: ValidationError) -> str: lines: list[str] = [] for entry in error.errors(): message = entry["msg"] for prefix in ("Value error, ", "Assertion failed, "): message = message.removeprefix(prefix) location = ".".join( f"[{part}]" if isinstance(part, int) else str(part) for part in entry["loc"] ).replace(".[", "[") if entry["type"] == "extra_forbidden": message = f"unknown option: {location}" elif entry["type"] == "missing": message = f"required: {location}" lines.append(message) return "\n".join(lines) @dataclass(frozen=True, slots=True) class World: number: int title: str emoji: str reward: CreatureId @dataclass(frozen=True, slots=True) class Lesson: id: str world: int number: int title: str subtitle: str kind: LessonKind new_keys: tuple[str, ...] spotlight_keys: tuple[str, ...] emphasis: Literal["isolated", "mixed"] | None active_keys: tuple[str, ...] primary_mode: ModeId bonus_modes: tuple[ModeId, ...] words: tuple[str, ...] is_drill: bool chunks: int chunk_size: int #: Raw config value, not yet resolved against the library - see #: ``musicmouse.tippen.rewards.resolve_lesson_reward``. unlocks: str | None @dataclass(frozen=True, slots=True) class Curriculum: worlds: tuple[World, ...] lessons: tuple[Lesson, ...] def lesson_by_id(curriculum: Curriculum, lesson_id: str) -> Lesson | None: return next((lesson for lesson in curriculum.lessons if lesson.id == lesson_id), None) def next_lesson(curriculum: Curriculum, lesson_id: str) -> Lesson | None: ids = [lesson.id for lesson in curriculum.lessons] try: index = ids.index(lesson_id) except ValueError: return None return curriculum.lessons[index + 1] if index + 1 < len(curriculum.lessons) else None def world_reward(curriculum: Curriculum, world_number: int) -> CreatureId | None: return next((world.reward for world in curriculum.worlds if world.number == world_number), None) def first_lesson_id(curriculum: Curriculum) -> str | None: return curriculum.lessons[0].id if curriculum.lessons else None def _length_for(world: int, kind: LessonKind) -> tuple[int, int]: """Line length by world and kind - a full block of text per round.""" if kind == "fragments": return 16, 4 # 64 if kind == "sentences": return 10, 4 # ten whole sentences if kind == "words": return 25, 4 # kind == "letters" if world == 1: return 24, 3 # 72 characters if world == 2: return 25, 4 # 100 return 30, 4 # 120, world 3 onward def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]: lessons: list[Lesson] = [] active: set[str] = set() seen_before: set[str] = set() for world in worlds: for entry in world.lessons: keys = entry.keys or () new_keys = tuple(key for key in keys if key not in seen_before) for key in keys: seen_before.add(key) active.add(key) # The space-bar lesson activates every home key - belt-and-braces # confirmation that the four finger-pair lessons before it covered all eight. if SPACE_KEY in keys: active.update(HOME_ROW) # Shift is not a character the generator can emit, so it never enters # active_keys. active_keys = tuple(sorted(key for key in active if key != "⇧")) words = entry.words or () kind: LessonKind = entry.kind or "letters" is_drill = entry.drill if kind != "letters" or is_drill: emphasis: Literal["isolated", "mixed"] | None = None elif new_keys: emphasis = "isolated" else: emphasis = "mixed" primary_mode: ModeId = entry.mode or ("bubbles" if kind == "letters" else "dive") # Bonus replays are only ever feed/race - dive is the plain default, and # bubbles is the only letters-round arcade mode. bonus_modes = tuple( mode for mode in ELIGIBLE_MODES[kind] if mode in ("feed", "race") and mode != primary_mode ) chunks, chunk_size = _length_for(world.number, kind) lessons.append( Lesson( id=f"l{len(lessons) + 1:02d}", world=world.number, number=len(lessons) + 1, title=entry.title, subtitle=entry.subtitle, kind=kind, new_keys=new_keys, # 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, bonus_modes=bonus_modes, words=words, is_drill=is_drill, chunks=chunks, chunk_size=chunk_size, unlocks=entry.unlocks, ) ) return tuple(lessons) def load_curriculum(path: Path) -> Curriculum: """Load and validate a curriculum file. Raises: CurriculumError: with a message that can be printed straight to the terminal. """ path = Path(path) try: text = path.read_text(encoding="utf-8") except OSError as exc: raise CurriculumError(f"Cannot read curriculum file {path}: {exc.strerror}") from exc try: data = YAML(typ="safe").load(text) except YAMLError as exc: raise CurriculumError(f"{path} is not valid YAML:\n {exc}") from exc if not isinstance(data, dict): raise CurriculumError( f"{path} must contain a mapping at the top level, got {type(data).__name__}" ) try: root = _YamlRoot.model_validate(data) except ValidationError as exc: raise CurriculumError(f"{path}\n{_format_curriculum_errors(exc)}") from exc worlds = tuple( World(number=world.number, title=world.title, emoji=world.emoji, reward=world.reward) for world in root.worlds ) return Curriculum(worlds=worlds, lessons=_build_lessons(root.worlds))