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>
340 lines
12 KiB
Python
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))
|