Files
musicmouse/python-backend/musicmouse/tippen/curriculum.py
Martin Bauer a7fb56c9fe Target the Python that Raspberry Pi OS ships
Requiring 3.13 meant the device needed an interpreter the distribution does not
have, which is what dragged uv in, and uv then had to be matched to the Pi's
32-bit userland by hand and to build Pillow from source because no armv7 wheel
exists for a 3.13 ABI. Dropping to 3.11 removes all of that: apt provides the
interpreter and piwheels has prebuilt armhf wheels for the native dependencies.

The 3.13-only syntax was shallow - PEP 695 throughout, which converts back
mechanically:

  type X = Y               ->  X: TypeAlias = Y
  type Handler[E: Event]   ->  E = TypeVar("E", bound=Event) plus a plain alias,
                               which is generic anyway because it carries a TypeVar
  def f[T: Bound](...)     ->  a module-level TypeVar

Also drop the one @override (3.12, and static-only), and stop the lirc test
harness calling Server.close_clients(), which is 3.13: the scripted handler now
releases its connection when asked, which is what that call was there to force.

Verified on 3.11.14 - 485 passed, mypy strict clean - and still 496 passed on
the 3.14 dev venv, which additionally has the analysis extra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:20:59 +02:00

340 lines
12 KiB
Python

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