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