Add backend support for the typing game: curriculum, progress, and reward unlocks

Moves the typing app's lesson plan and progress from client-side YAML/localStorage
into the backend, and adds a reward system that ties passing a lesson to unlocking
part of the music library. Lock state is always recomputed live from curriculum x
progress x the live library, never persisted separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 21:03:45 +02:00
parent a5210fead2
commit f7a5d24d8d
18 changed files with 2794 additions and 17 deletions

View File

@@ -43,6 +43,7 @@ __all__ = [
"LircConfig",
"MqttConfig",
"RemoteSlotConfig",
"TippenConfig",
"WebConfig",
"format_validation_error",
"load_config",
@@ -116,14 +117,19 @@ class FigureColors(_Strict):
return data
def _resolve_folder(folder: Path, info: ValidationInfo, *, must_exist: bool) -> Path:
def _resolve_folder(
folder: Path, info: ValidationInfo, *, must_exist: bool, kind: Literal["dir", "file"] = "dir"
) -> Path:
"""Make a configured path absolute against the config file, and optionally check it."""
context = info.context or {}
base = context.get("config_dir")
if base is not None and not folder.is_absolute():
folder = (Path(base) / folder).resolve()
if must_exist and context.get("check_paths", True) and not folder.is_dir():
raise ValueError(f"no such directory: {folder}")
if must_exist and context.get("check_paths", True):
exists = folder.is_file() if kind == "file" else folder.is_dir()
if not exists:
noun = "file" if kind == "file" else "directory"
raise ValueError(f"no such {noun}: {folder}")
return folder
@@ -227,6 +233,29 @@ class HaConfig(_Strict):
return self
class TippenConfig(_Strict):
"""The typing game. Omit the whole section to run without it.
The curriculum is content, not device settings, so it lives in its own file
(``curriculum_file``) rather than inline here - see ``tippen-curriculum.yml.example``.
``progress_file`` is written by the app itself, not hand-edited, and defaults to a
name next to ``config.yml`` if not given a folder of its own.
"""
curriculum_file: Path
progress_file: Path = Path("tippen-progress.json")
@field_validator("curriculum_file")
@classmethod
def _resolve_curriculum_file(cls, path: Path, info: ValidationInfo) -> Path:
return _resolve_folder(path, info, must_exist=True, kind="file")
@field_validator("progress_file")
@classmethod
def _resolve_progress_file(cls, path: Path, info: ValidationInfo) -> Path:
return _resolve_folder(path, info, must_exist=False, kind="file")
class GeneralConfig(_Strict):
library: LibraryConfig
@@ -244,6 +273,7 @@ class GeneralConfig(_Strict):
web: WebConfig | None = None
ha: HaConfig | None = None
lirc: LircConfig | None = None
tippen: TippenConfig | None = None
min_volume: int = Field(default=0, ge=0, le=200)
max_volume: int = Field(default=100, ge=0, le=200)