diff --git a/python-backend/config.yml.example b/python-backend/config.yml.example index aa9ef54..486a32b 100644 --- a/python-backend/config.yml.example +++ b/python-backend/config.yml.example @@ -118,6 +118,16 @@ general: - entity_id: scene.kinderzimmer_gute_nacht name: "Gute Nacht" + # The typing game ("Tippen"). Omit the whole section to hide its tab in the web + # front-end. The lesson plan is content, not device config, so it lives in its own + # file - see tippen-curriculum.yml.example for the format, including the optional + # `unlocks:` key that turns passing a lesson into unlocking part of the library. + tippen: + curriculum_file: tippen-curriculum.yml + # Where progress (stars, unlocked lessons, streak, ...) is saved. Written by the + # app itself - never hand-edited. Relative to this file, like curriculum_file. + progress_file: tippen-progress.json + # One entry per figurine. The key is the figure name and the subfolder name. figures: fuchs: diff --git a/python-backend/musicmouse/__main__.py b/python-backend/musicmouse/__main__.py index cdcbabe..e227cfa 100644 --- a/python-backend/musicmouse/__main__.py +++ b/python-backend/musicmouse/__main__.py @@ -40,6 +40,8 @@ from musicmouse.services.lirc import LircService from musicmouse.services.mqtt import MqttService, build_entities from musicmouse.services.podcasts import PodcastFeedService from musicmouse.services.web import WebService +from musicmouse.tippen.curriculum import CurriculumError +from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime _log = logging.getLogger("musicmouse") @@ -100,10 +102,20 @@ def main(argv: list[str] | None = None) -> int: print(f"error: {exc}", file=sys.stderr) return 2 + tippen: TippenRuntime | None = None + if config.general.tippen is not None: + try: + tippen = build_tippen_runtime(config.general.tippen) + except CurriculumError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + runner = ( - run_simulated(config, args.config, args.script) + run_simulated(config, args.config, args.script, tippen=tippen) if args.simulate - else run_real(config, args.config, hardware=wants_hardware(config, args.no_hardware)) + else run_real( + config, args.config, hardware=wants_hardware(config, args.no_hardware), tippen=tippen + ) ) try: asyncio.run(runner) @@ -115,7 +127,13 @@ def main(argv: list[str] | None = None) -> int: # ------------------------------------------------------------------------- real -async def run_real(config: Config, config_path: Path, *, hardware: bool = True) -> None: +async def run_real( + config: Config, + config_path: Path, + *, + hardware: bool = True, + tippen: TippenRuntime | None = None, +) -> None: bus = EventBus() await bus.start() clock = RealClock() @@ -137,7 +155,7 @@ async def run_real(config: Config, config_path: Path, *, hardware: bool = True) player = _build_player(bus, general, clock=clock) library = await build_library(config) - app = _build_app(config, bus, mouse, player, library, clock=clock) + app = _build_app(config, bus, mouse, player, library, clock=clock, tippen=tippen) services = _build_services(app, mouse, player, clock=clock, config_path=config_path) _log.info( @@ -176,7 +194,9 @@ async def run_real(config: Config, config_path: Path, *, hardware: bool = True) # -------------------------------------------------------------------- simulated -async def run_simulated(config: Config, config_path: Path, script: Path | None) -> None: +async def run_simulated( + config: Config, config_path: Path, script: Path | None, *, tippen: TippenRuntime | None = None +) -> None: # Imported here so the production path never touches the simulator. from musicmouse.simulator.harness import build_simulation from musicmouse.simulator.repl import run_repl @@ -185,7 +205,7 @@ async def run_simulated(config: Config, config_path: Path, script: Path | None) # A script runs on virtual time, so `wait 1s` is instant. The prompt runs on the # real clock, so playback ticks along while you watch it. sim = await build_simulation( - config, clock=RealClock() if script is None else None, track_duration=5.0 + config, clock=RealClock() if script is None else None, track_duration=5.0, tippen=tippen ) services = _build_services( @@ -280,6 +300,7 @@ def _build_app( library: MusicLibrary, *, clock: RealClock, + tippen: TippenRuntime | None = None, ) -> App: app = App( config=config, @@ -292,6 +313,7 @@ def _build_app( # identity check. playlists=library.figure_playlists(), clock=clock, + tippen=tippen, ) register_all(bus, app) return app diff --git a/python-backend/musicmouse/app.py b/python-backend/musicmouse/app.py index 288b7ee..99ebf16 100644 --- a/python-backend/musicmouse/app.py +++ b/python-backend/musicmouse/app.py @@ -14,6 +14,7 @@ from musicmouse.devices.player import Player from musicmouse.library import MusicLibrary from musicmouse.library.models import Album from musicmouse.media import Playlist +from musicmouse.tippen.runtime import TippenRuntime _log = logging.getLogger(__name__) @@ -46,6 +47,8 @@ class App: playlists: dict[str, Playlist] clock: Clock = field(default_factory=RealClock) state: AppState = field(default_factory=AppState) + #: `None` when `general.tippen` is absent - the typing game is off. + tippen: TippenRuntime | None = None def colors(self, figure: str) -> FigureColors: return self.config.figures[figure].colors diff --git a/python-backend/musicmouse/config.py b/python-backend/musicmouse/config.py index 2c82c60..e79fd53 100644 --- a/python-backend/musicmouse/config.py +++ b/python-backend/musicmouse/config.py @@ -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) diff --git a/python-backend/musicmouse/services/web/api.py b/python-backend/musicmouse/services/web/api.py index 5937a45..d9c77d6 100644 --- a/python-backend/musicmouse/services/web/api.py +++ b/python-backend/musicmouse/services/web/api.py @@ -46,6 +46,12 @@ from musicmouse.services.web.schemas import ( SeekIn, SettingsIn, SettingsOut, + TippenCurriculumOut, + TippenProgressOut, + TippenRunIn, + TippenRunOut, + TippenSettingsIn, + TippenSettingsOut, TrackCurvesOut, TrackDetailOut, VolumeIn, @@ -57,6 +63,9 @@ from musicmouse.services.web.settings import ( write_settings, ) from musicmouse.services.web.state import snapshot +from musicmouse.services.web.tippen_api import curriculum_out, progress_out, record_tippen_run +from musicmouse.tippen.rewards import compute_lock_state +from musicmouse.tippen.runtime import TippenRuntime _log = logging.getLogger(__name__) @@ -75,7 +84,17 @@ def build_router( @router.get("/library") def get_library() -> LibraryOut: - return LibraryOut(albums=[AlbumOut.of(album) for album in app.library.albums]) + lock_state = ( + compute_lock_state(app.tippen.curriculum, app.library, app.tippen.progress) + if app.tippen is not None + else None + ) + return LibraryOut( + albums=[ + AlbumOut.of(album, lock_state.get(album.id) if lock_state else None) + for album in app.library.albums + ] + ) @router.get("/albums/{album_id}/cover") def get_cover(album_id: str) -> FileResponse: @@ -200,6 +219,45 @@ def build_router( await hub.broadcast_state() return read_settings(general) + # ----------------------------------------------------------------- tippen + + def _require_tippen() -> TippenRuntime: + if app.tippen is None: + raise HTTPException(status_code=404, detail="tippen not configured") + return app.tippen + + @router.get("/tippen/curriculum") + def get_tippen_curriculum() -> TippenCurriculumOut: + runtime = _require_tippen() + return curriculum_out(runtime.curriculum, app.library) + + @router.get("/tippen/progress") + def get_tippen_progress() -> TippenProgressOut: + return progress_out(_require_tippen().progress) + + @router.put("/tippen/settings") + async def put_tippen_settings(body: TippenSettingsIn) -> TippenSettingsOut: + runtime = _require_tippen() + runtime.progress = runtime.progress.model_copy( + update={"settings": runtime.progress.settings.model_copy(update=body.model_dump())} + ) + await asyncio.to_thread(runtime.save) + return TippenSettingsOut( + sound=runtime.progress.settings.sound, + keyboard_hint=runtime.progress.settings.keyboard_hint, + ) + + @router.post("/tippen/runs") + async def post_tippen_run(body: TippenRunIn) -> TippenRunOut: + runtime = _require_tippen() + if runtime.curriculum.lessons and not any( + lesson.id == body.lesson_id for lesson in runtime.curriculum.lessons + ): + raise HTTPException(status_code=422, detail=f"no such lesson {body.lesson_id!r}") + result = await asyncio.to_thread(record_tippen_run, app, body) + await hub.broadcast_library() + return result + # -------------------------------------------------------------- IR remote @router.get("/lirc") diff --git a/python-backend/musicmouse/services/web/schemas.py b/python-backend/musicmouse/services/web/schemas.py index 124255a..6ec2a84 100644 --- a/python-backend/musicmouse/services/web/schemas.py +++ b/python-backend/musicmouse/services/web/schemas.py @@ -15,6 +15,9 @@ from pydantic import BaseModel, Field from musicmouse.config import Digit from musicmouse.library import Album from musicmouse.library.analysis import TrackAnalysis +from musicmouse.library.models import LibraryTrack +from musicmouse.tippen.progress import AnimalId +from musicmouse.tippen.rewards import AlbumLock, TrackLock, UnlockHint __all__ = [ "AlbumOut", @@ -31,9 +34,25 @@ __all__ = [ "SeekIn", "SettingsIn", "SettingsOut", + "TippenCurriculumOut", + "TippenGhostStrokeOut", + "TippenKeyStatOut", + "TippenLessonOut", + "TippenLessonProgressOut", + "TippenProgressOut", + "TippenRewardOut", + "TippenRunIn", + "TippenRunOut", + "TippenSettingsIn", + "TippenSettingsOut", + "TippenStreakOut", + "TippenStrokeIn", + "TippenUnlockedRewardOut", + "TippenWorldOut", "TrackCurvesOut", "TrackDetailOut", "TrackOut", + "UnlockHintOut", "VolumeIn", ] @@ -61,11 +80,44 @@ class AnalysisOut(BaseModel): ) +class UnlockHintOut(BaseModel): + """Which typing lesson unlocks a still-locked track, for "mention when this + unlocks" in the browse view.""" + + lesson_id: str + lesson_title: str + world_number: int + world_title: str + + @classmethod + def of(cls, hint: UnlockHint) -> UnlockHintOut: + return cls( + lesson_id=hint.lesson_id, + lesson_title=hint.lesson_title, + world_number=hint.world_number, + world_title=hint.world_title, + ) + + class TrackOut(BaseModel): title: str duration: float #: Scalars only. The beat grid is fetched per track from ``/api/tracks/...``. analysis: AnalysisOut | None = None + #: A reward-gated track not yet earned - the browse view shows a placeholder + #: instead of the real title. + locked: bool = False + unlock_hint: UnlockHintOut | None = None + + @classmethod + def of(cls, track: LibraryTrack, lock: TrackLock | None = None) -> TrackOut: + return cls( + title=track.title, + duration=track.duration, + analysis=AnalysisOut.of(track.analysis), + locked=lock.locked if lock is not None else False, + unlock_hint=UnlockHintOut.of(lock.hint) if lock is not None and lock.hint else None, + ) class AlbumOut(BaseModel): @@ -81,9 +133,13 @@ class AlbumOut(BaseModel): has_cover: bool duration: float tracks: list[TrackOut] + #: Every track is still locked - the browse view shows a question mark instead of + #: cover art. `False` for an album no typing reward ever targets. + locked: bool = False @classmethod - def of(cls, album: Album) -> AlbumOut: + def of(cls, album: Album, lock: AlbumLock | None = None) -> AlbumOut: + track_locks = lock.tracks if lock is not None else () return cls( id=album.id, section=album.section, @@ -96,13 +152,10 @@ class AlbumOut(BaseModel): colors=list(album.colors), has_cover=album.cover is not None, duration=album.duration, + locked=lock.locked if lock is not None else False, tracks=[ - TrackOut( - title=track.title, - duration=track.duration, - analysis=AnalysisOut.of(track.analysis), - ) - for track in album.tracks + TrackOut.of(track, track_locks[i] if i < len(track_locks) else None) + for i, track in enumerate(album.tracks) ], ) @@ -230,3 +283,136 @@ class RemoteMappingIn(BaseModel): """Full replacement, like ``SettingsIn``: a digit absent here becomes unassigned.""" slots: dict[Digit, RemoteSlotIn] + + +# --------------------------------------------------------------------------- tippen + + +class TippenRewardOut(BaseModel): + """What a lesson's ``unlocks:`` resolves to right now - `resolved` is `False` when + the configured path matches nothing in the current library (a moved or mistyped + path), so the frontend can show a broken-reward state instead of silently + dropping it.""" + + resolved: bool + album_id: str | None = None + has_cover: bool = False + kind: Literal["tracks", "episode"] | None = None + + +class TippenLessonOut(BaseModel): + id: str + world: int + number: int + title: str + subtitle: str + kind: str + new_keys: list[str] + spotlight_keys: list[str] + emphasis: Literal["isolated", "mixed"] | None + active_keys: list[str] + primary_mode: str + bonus_modes: list[str] + words: list[str] + is_drill: bool + chunks: int + chunk_size: int + reward: TippenRewardOut + + +class TippenWorldOut(BaseModel): + number: int + title: str + emoji: str + reward: str + + +class TippenCurriculumOut(BaseModel): + worlds: list[TippenWorldOut] + lessons: list[TippenLessonOut] + + +class TippenGhostStrokeOut(BaseModel): + key: str + at: float + + +class TippenLessonProgressOut(BaseModel): + unlocked: bool + runs: int + best_stars: int + best_animal: AnimalId | None + best_points: float + #: Derived, not stored - see ``LessonProgress.earned``. + earned: bool + ghost: list[TippenGhostStrokeOut] | None + + +class TippenKeyStatOut(BaseModel): + ema: float + attempts: int + errors: int + + +class TippenStreakOut(BaseModel): + days: int + last_played: str | None + + +class TippenSettingsOut(BaseModel): + sound: bool + keyboard_hint: Literal["auto", "on", "off"] + + +class TippenSettingsIn(BaseModel): + sound: bool + keyboard_hint: Literal["auto", "on", "off"] + + +class TippenProgressOut(BaseModel): + lessons: dict[str, TippenLessonProgressOut] + key_stats: dict[str, TippenKeyStatOut] + pearls: int + aquarium: list[str] + streak: TippenStreakOut + settings: TippenSettingsOut + + +class TippenStrokeIn(BaseModel): + key: str + expected: str + correct: bool + #: ms timestamp, from the run's own clock. + at: float + + +class TippenRunIn(BaseModel): + """A run the client already graded - see ``grading.ts``. Grading itself stays + client-side; this only tells the backend what to do with progress.""" + + lesson_id: str + stars: Literal[0, 1, 2, 3] + animal: AnimalId + points: float + passed: bool + pearls: int = Field(ge=0) + strokes: list[TippenStrokeIn] = Field(default_factory=list) + + +class TippenUnlockedRewardOut(BaseModel): + """The literal track/episode this run's lesson names in its own ``unlocks:`` - + what the unlock animation shows, via the existing ``/api/albums/{id}/cover``.""" + + album_id: str + title: str + has_cover: bool + kind: Literal["album", "book", "podcast_episode"] + + +class TippenRunOut(BaseModel): + progress: TippenProgressOut + unlocked_lesson_id: str | None + unlocked_lesson_title: str | None + new_creature: str | None + is_new_best: bool + unlocked_reward: TippenUnlockedRewardOut | None diff --git a/python-backend/musicmouse/services/web/tippen_api.py b/python-backend/musicmouse/services/web/tippen_api.py new file mode 100644 index 0000000..2178e70 --- /dev/null +++ b/python-backend/musicmouse/services/web/tippen_api.py @@ -0,0 +1,187 @@ +"""Converting between the typing game's domain objects and what the browser sees. + +Same job as ``remote_settings.py`` for the reward mapping: resolving a configured +target against the live library. Unlike ``remote_settings.py``, there is no write-back +half here - the curriculum is a parent-edited file, never PUT by the app. +""" + +from __future__ import annotations + +from typing import Literal + +from musicmouse.app import App +from musicmouse.library import MusicLibrary +from musicmouse.services.web.schemas import ( + TippenCurriculumOut, + TippenGhostStrokeOut, + TippenKeyStatOut, + TippenLessonOut, + TippenLessonProgressOut, + TippenProgressOut, + TippenRewardOut, + TippenRunIn, + TippenRunOut, + TippenSettingsOut, + TippenStreakOut, + TippenUnlockedRewardOut, + TippenWorldOut, +) +from musicmouse.tippen.curriculum import Curriculum, Lesson, lesson_by_id +from musicmouse.tippen.progress import RunResult, Stroke, TypingProgress, record_run +from musicmouse.tippen.rewards import resolve_lesson_reward +from musicmouse.tippen.runtime import TippenRuntime + +__all__ = ["curriculum_out", "progress_out", "record_tippen_run"] + + +def _reward_out(lesson: Lesson, library: MusicLibrary) -> TippenRewardOut: + reward = resolve_lesson_reward(lesson, library) + if reward is None: + return TippenRewardOut(resolved=False) + album = library.get(reward.album_id) + return TippenRewardOut( + resolved=True, + album_id=reward.album_id, + has_cover=album.cover is not None if album else False, + kind=reward.kind, + ) + + +def curriculum_out(curriculum: Curriculum, library: MusicLibrary) -> TippenCurriculumOut: + return TippenCurriculumOut( + worlds=[ + TippenWorldOut( + number=world.number, title=world.title, emoji=world.emoji, reward=world.reward + ) + for world in curriculum.worlds + ], + lessons=[ + TippenLessonOut( + id=lesson.id, + world=lesson.world, + number=lesson.number, + title=lesson.title, + subtitle=lesson.subtitle, + kind=lesson.kind, + new_keys=list(lesson.new_keys), + spotlight_keys=list(lesson.spotlight_keys), + emphasis=lesson.emphasis, + active_keys=list(lesson.active_keys), + primary_mode=lesson.primary_mode, + bonus_modes=list(lesson.bonus_modes), + words=list(lesson.words), + is_drill=lesson.is_drill, + chunks=lesson.chunks, + chunk_size=lesson.chunk_size, + reward=_reward_out(lesson, library), + ) + for lesson in curriculum.lessons + ], + ) + + +def progress_out(progress: TypingProgress) -> TippenProgressOut: + return TippenProgressOut( + lessons={ + lesson_id: TippenLessonProgressOut( + unlocked=entry.unlocked, + runs=entry.runs, + best_stars=entry.best_stars, + best_animal=entry.best_animal, + best_points=entry.best_points, + earned=entry.earned, + ghost=( + [TippenGhostStrokeOut(key=g.key, at=g.at) for g in entry.ghost] + if entry.ghost + else None + ), + ) + for lesson_id, entry in progress.lessons.items() + }, + key_stats={ + key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors) + for key, stat in progress.key_stats.items() + }, + pearls=progress.pearls, + aquarium=list(progress.aquarium), + streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played), + settings=TippenSettingsOut( + sound=progress.settings.sound, keyboard_hint=progress.settings.keyboard_hint + ), + ) + + +def _animation_kind( + library: MusicLibrary, album_id: str, reward_kind: Literal["tracks", "episode"] +) -> Literal["album", "book", "podcast_episode"]: + if reward_kind == "episode": + return "podcast_episode" + album = library.get(album_id) + return "book" if album is not None and album.kind == "book" else "album" + + +def _unlocked_reward_out( + runtime: TippenRuntime, library: MusicLibrary, lesson_id: str +) -> TippenUnlockedRewardOut | None: + """The literal target of `lesson_id`'s own ``unlocks:`` - what the unlock + animation shows. Not "one of several" tracks: exactly the one the lesson names.""" + lesson = lesson_by_id(runtime.curriculum, lesson_id) + if lesson is None: + return None + reward = resolve_lesson_reward(lesson, library) + if reward is None: + return None + album = library.get(reward.album_id) + if album is None: + return None + if reward.kind == "episode": + title = album.title + elif reward.until_index < len(album.tracks): + title = album.tracks[reward.until_index].title + else: + title = album.title + return TippenUnlockedRewardOut( + album_id=reward.album_id, + title=title, + has_cover=album.cover is not None, + kind=_animation_kind(library, reward.album_id, reward.kind), + ) + + +def record_tippen_run(app: App, body: TippenRunIn) -> TippenRunOut: + """Grade-agnostic: the client already graded the run (see ``TippenRunIn``); this + only owns what happens to progress, and whether it just revealed a reward.""" + assert app.tippen is not None + runtime = app.tippen + + result = RunResult( + stars=body.stars, + animal=body.animal, + points=body.points, + passed=body.passed, + pearls=body.pearls, + strokes=tuple( + Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes + ), + ) + outcome = record_run(runtime.progress, body.lesson_id, result, runtime.curriculum) + runtime.progress = outcome.progress + runtime.save() + + unlocked_reward = ( + _unlocked_reward_out(runtime, app.library, body.lesson_id) if outcome.newly_earned else None + ) + unlocked_lesson = ( + lesson_by_id(runtime.curriculum, outcome.unlocked_lesson_id) + if outcome.unlocked_lesson_id + else None + ) + + return TippenRunOut( + progress=progress_out(runtime.progress), + unlocked_lesson_id=outcome.unlocked_lesson_id, + unlocked_lesson_title=unlocked_lesson.title if unlocked_lesson else None, + new_creature=outcome.new_creature, + is_new_best=outcome.is_new_best, + unlocked_reward=unlocked_reward, + ) diff --git a/python-backend/musicmouse/simulator/harness.py b/python-backend/musicmouse/simulator/harness.py index b762fb2..59564e2 100644 --- a/python-backend/musicmouse/simulator/harness.py +++ b/python-backend/musicmouse/simulator/harness.py @@ -19,6 +19,7 @@ from musicmouse.reactions import register_all from musicmouse.simulator.driver import SimulatorDriver from musicmouse.simulator.fake_player import DEFAULT_TRACK_DURATION, FakePlayer from musicmouse.simulator.fake_transport import FakeTransport +from musicmouse.tippen.runtime import TippenRuntime __all__ = ["Simulation", "build_simulation"] @@ -43,6 +44,7 @@ async def build_simulation( clock: Clock | None = None, track_duration: float = DEFAULT_TRACK_DURATION, library: MusicLibrary | None = None, + tippen: TippenRuntime | None = None, ) -> Simulation: bus = EventBus() await bus.start() @@ -78,6 +80,7 @@ async def build_simulation( library=library, playlists=library.figure_playlists(), clock=clock, + tippen=tippen, ) register_all(bus, app) mouse.on_connected() diff --git a/python-backend/musicmouse/tippen/__init__.py b/python-backend/musicmouse/tippen/__init__.py new file mode 100644 index 0000000..c424567 --- /dev/null +++ b/python-backend/musicmouse/tippen/__init__.py @@ -0,0 +1,34 @@ +"""The typing game: curriculum, progress and reward-unlock resolution. + +See ``curriculum.py`` (the lesson plan, loaded from a YAML file named by +``general.tippen.curriculum_file``), ``progress.py`` (a JSON sidecar recording what has +been played and passed), ``rewards.py`` (turning a lesson's ``unlocks:`` into which +library tracks/episodes are still locked) and ``runtime.py`` (wiring the three +together at startup). +""" + +from __future__ import annotations + +from musicmouse.tippen.curriculum import Curriculum, CurriculumError, Lesson, World, load_curriculum +from musicmouse.tippen.progress import RecordOutcome, RunResult, Stroke, TypingProgress, record_run +from musicmouse.tippen.rewards import AlbumLock, LockState, ResolvedReward, compute_lock_state +from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime + +__all__ = [ + "AlbumLock", + "Curriculum", + "CurriculumError", + "Lesson", + "LockState", + "RecordOutcome", + "ResolvedReward", + "RunResult", + "Stroke", + "TippenRuntime", + "TypingProgress", + "World", + "build_tippen_runtime", + "compute_lock_state", + "load_curriculum", + "record_run", +] diff --git a/python-backend/musicmouse/tippen/curriculum.py b/python-backend/musicmouse/tippen/curriculum.py new file mode 100644 index 0000000..a852cd1 --- /dev/null +++ b/python-backend/musicmouse/tippen/curriculum.py @@ -0,0 +1,336 @@ +"""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 + +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", +] + +type LessonKind = Literal["letters", "fragments", "words", "sentences"] +type ModeId = Literal["dive", "bubbles", "jellyfish", "feed", "race"] +type CreatureId = 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 modes fit; only words and sentences are long enough for a race. +ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = { + "letters": ("bubbles", "jellyfish"), + "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() + 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") + if lesson.keys and len(lesson.keys) > 2: + problems.append(f"{where}: at most two keys per lesson") + 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() + # Letters rounds alternate bubbles/jellyfish across the whole course, so the arcade + # game never repeats twice in a row even across a fragments/words lesson in between. + last_arcade: ModeId = "jellyfish" # so lesson 1 opens on bubbles + + 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 + if kind == "letters": + default_arcade: ModeId = "jellyfish" if last_arcade == "bubbles" else "bubbles" + primary_mode = entry.mode or default_arcade + last_arcade = primary_mode + else: + primary_mode = entry.mode or "dive" + + # Bonus replays are only ever feed/race - dive is the plain default, and + # bubbles/jellyfish already alternate on their own. + 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, + spotlight_keys=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)) diff --git a/python-backend/musicmouse/tippen/progress.py b/python-backend/musicmouse/tippen/progress.py new file mode 100644 index 0000000..798e19f --- /dev/null +++ b/python-backend/musicmouse/tippen/progress.py @@ -0,0 +1,330 @@ +"""Server-side typing progress: a JSON sidecar, atomic-written, defensively loaded. + +Same convention as everywhere else this codebase persists something outside +``config.yml`` - see ``musicmouse.library.podcast_feeds``'s failed-downloads sidecar. A +missing or corrupt file is just a fresh start, never a crash: nothing here is precious +enough to raise over. + +``record_run`` is a near-verbatim port of tippen's own (client-side, until now) +``recordRun`` in ``tippen/src/lib/progress.ts`` - grading a keystroke-by-keystroke run +into stars/points/an animal stays entirely client-side (see ``TippenRunIn`` in +``musicmouse.services.web.schemas``); this only owns what happens to progress once a +graded result arrives. +""" + +from __future__ import annotations + +import json +import logging +import os +from dataclasses import dataclass +from datetime import date, timedelta +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field, ValidationError + +from musicmouse.tippen.curriculum import ( + CreatureId, + Curriculum, + first_lesson_id, + lesson_by_id, + next_lesson, + world_reward, +) + +_log = logging.getLogger(__name__) + +__all__ = [ + "DILIGENCE_ATTEMPTS", + "AnimalId", + "KeyStat", + "LessonProgress", + "RecordOutcome", + "RunResult", + "Streak", + "Stroke", + "TippenSettings", + "TypingProgress", + "fresh_progress", + "load_progress", + "record_run", + "save_progress", + "today", +] + +#: How many attempts at one lesson unlock the next regardless of score - the safety +#: valve against getting stuck on a single stubborn key. +DILIGENCE_ATTEMPTS = 5 + +AnimalId = Literal[ + "snail", + "crab", + "turtle", + "jellyfish", + "fish", + "penguin", + "seal", + "dolphin", + "shark", + "orca", +] + + +# ------------------------------------------------------------------------- domain + +@dataclass(frozen=True, slots=True) +class Stroke: + key: str + expected: str + correct: bool + #: ms timestamp, from the run's own clock. + at: float + + +@dataclass(frozen=True, slots=True) +class RunResult: + """What the client already graded a finished run as - see ``grading.ts``.""" + + stars: Literal[0, 1, 2, 3] + animal: AnimalId + points: float + passed: bool + pearls: int + strokes: tuple[Stroke, ...] + + +# --------------------------------------------------------------------------- store + +class GhostStroke(BaseModel): + key: str + at: float + + +class LessonProgress(BaseModel): + unlocked: bool = False + runs: int = 0 + best_stars: Literal[0, 1, 2, 3] = 0 + best_animal: AnimalId | None = None + best_points: float = 0 + #: Best-run keystrokes, replayed as the opponent in race mode. + ghost: list[GhostStroke] | None = None + + @property + def earned(self) -> bool: + """Passed on its own merits, or given up on gracefully after enough tries. + + Always derived from ``best_stars``/``runs`` rather than stored, so it can never + drift from the rule that computed it. Two stars is the same "passed" gate the + frontend's ``isPassed`` uses (accuracy >= 93 %); the fifth attempt is the + diligence fallback. + """ + return self.best_stars >= 2 or self.runs >= DILIGENCE_ATTEMPTS + + +class KeyStat(BaseModel): + #: Smoothed reaction time in ms - keybr's exponential moving average. + ema: float = 0 + attempts: int = 0 + errors: int = 0 + + +class Streak(BaseModel): + days: int = 0 + last_played: str | None = None + + +class TippenSettings(BaseModel): + sound: bool = True + keyboard_hint: Literal["auto", "on", "off"] = "auto" + + +class TypingProgress(BaseModel): + version: Literal[1] = 1 + lessons: dict[str, LessonProgress] = Field(default_factory=dict) + key_stats: dict[str, KeyStat] = Field(default_factory=dict) + pearls: int = 0 + #: Pets that have moved into the aquarium, in the order they arrived. + aquarium: list[CreatureId] = Field(default_factory=list) + streak: Streak = Field(default_factory=Streak) + settings: TippenSettings = Field(default_factory=TippenSettings) + + +def fresh_progress(curriculum: Curriculum) -> TypingProgress: + first = first_lesson_id(curriculum) + lessons = { + lesson.id: LessonProgress(unlocked=lesson.id == first) for lesson in curriculum.lessons + } + return TypingProgress(lessons=lessons) + + +def load_progress(path: Path, curriculum: Curriculum) -> TypingProgress: + """A missing or corrupt file is just a fresh start - never fatal.""" + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError: + return fresh_progress(curriculum) + except OSError as exc: + _log.warning("Could not read %s; starting fresh: %s", path, exc) + return fresh_progress(curriculum) + + try: + progress = TypingProgress.model_validate(json.loads(raw)) + except (ValueError, ValidationError) as exc: + _log.warning("Could not parse %s; starting fresh: %s", path, exc) + return fresh_progress(curriculum) + + # A lesson added to the curriculum since the last save needs an entry too, and the + # first lesson is unlocked by definition - a save that says otherwise is wrong. + fresh = fresh_progress(curriculum) + lessons = dict(progress.lessons) + for lesson_id, blank in fresh.lessons.items(): + lessons.setdefault(lesson_id, blank) + first = first_lesson_id(curriculum) + if first is not None and first in lessons: + lessons[first] = lessons[first].model_copy(update={"unlocked": True}) + return progress.model_copy(update={"lessons": lessons}) + + +def save_progress(path: Path, progress: TypingProgress) -> None: + """Write through a sibling temp file so an interrupted save cannot truncate the real + one - the same atomic-write idiom used throughout this codebase.""" + temp = path.with_name(f"{path.name}.tmp{os.getpid()}") + try: + temp.write_text(progress.model_dump_json(), encoding="utf-8") + temp.replace(path) + except BaseException: + temp.unlink(missing_ok=True) + raise + + +# --------------------------------------------------------------------------- record + +def today(day: date | None = None) -> str: + """Today (or `day`) as YYYY-MM-DD.""" + return (day or date.today()).isoformat() + + +def _is_better(candidate: RunResult, best_stars: int, best_points: float) -> bool: + """Stars come first, points break the tie - a careful run is never displaced by a + sloppy fast one.""" + if candidate.stars != best_stars: + return candidate.stars > best_stars + return candidate.points > best_points + + +def _fold_key_stats(stats: dict[str, KeyStat], strokes: tuple[Stroke, ...]) -> dict[str, KeyStat]: + """Fold the per-key reaction times of a run into the stored averages. The EMA weight + of 0.3 is slow enough that one distracted run does not rewrite what is known.""" + next_stats = dict(stats) + previous_at: float | None = None + for stroke in strokes: + key = stroke.expected.lower() + current = next_stats.get(key, KeyStat()) + gap = None if previous_at is None else stroke.at - previous_at + previous_at = stroke.at + # Reaction times over two seconds are a pause for thought, not a measure of the + # key, so they are ignored rather than averaged in. + ema = current.ema + if gap is not None and gap < 2000: + ema = gap if current.ema == 0 else current.ema * 0.7 + gap * 0.3 + next_stats[key] = KeyStat( + ema=ema, + attempts=current.attempts + 1, + errors=current.errors + (0 if stroke.correct else 1), + ) + return next_stats + + +def _bump_streak(streak: Streak, day: str) -> Streak: + if streak.last_played == day: + return streak + yesterday = (date.fromisoformat(day) - timedelta(days=1)).isoformat() + consecutive = streak.last_played == yesterday + # A missed day restarts at 1, never at 0 - playing today always counts for something. + return Streak(days=streak.days + 1 if consecutive else 1, last_played=day) + + +@dataclass(frozen=True, slots=True) +class RecordOutcome: + progress: TypingProgress + #: Set when this run unlocked the following lesson, for the lesson-map celebration. + unlocked_lesson_id: str | None + #: Whether *this* lesson's pass threshold was crossed by this run - the signal a + #: reward attached to it (``Lesson.unlocks``) should now be checked. + newly_earned: bool + #: Set when this run finished a world, for the creature that moved in. + new_creature: CreatureId | None + is_new_best: bool + + +def record_run( + progress: TypingProgress, + lesson_id: str, + result: RunResult, + curriculum: Curriculum, + day: str | None = None, +) -> RecordOutcome: + """Record a finished run: stars, animal, pearls, key stats, streak, and the unlock. + + The unlock rule, in one place: two stars unlocks the next lesson, and so does the + fifth attempt whatever the score. Speed is nowhere in it. + """ + day = day or today() + before = progress.lessons.get(lesson_id) or LessonProgress(unlocked=True) + was_earned = before.earned + runs = before.runs + 1 + + improved = _is_better(result, before.best_stars, before.best_points) + lessons = dict(progress.lessons) + updated = before.model_copy( + update={ + "runs": runs, + "best_stars": result.stars if improved else before.best_stars, + "best_animal": result.animal if improved else before.best_animal, + "best_points": result.points if improved else before.best_points, + "ghost": ( + [GhostStroke(key=s.key, at=s.at) for s in result.strokes if s.correct] + if improved + else before.ghost + ), + } + ) + lessons[lesson_id] = updated + newly_earned = updated.earned and not was_earned + + nxt = next_lesson(curriculum, lesson_id) + unlocked_lesson_id: str | None = None + if newly_earned and nxt is not None and not lessons.get(nxt.id, LessonProgress()).unlocked: + next_before = lessons.get(nxt.id, LessonProgress()) + lessons[nxt.id] = next_before.model_copy(update={"unlocked": True}) + unlocked_lesson_id = nxt.id + + # Finishing the last lesson of a world releases that world's creature. Checked + # against the aquarium so it is only ever awarded once. + aquarium = list(progress.aquarium) + new_creature: CreatureId | None = None + if unlocked_lesson_id and nxt is not None: + finished = lesson_by_id(curriculum, lesson_id) + if finished is not None and nxt.world != finished.world: + reward = world_reward(curriculum, finished.world) + if reward and reward not in aquarium: + aquarium.append(reward) + new_creature = reward + + updated_progress = progress.model_copy( + update={ + "lessons": lessons, + "aquarium": aquarium, + "key_stats": _fold_key_stats(progress.key_stats, result.strokes), + "pearls": progress.pearls + result.pearls, + "streak": _bump_streak(progress.streak, day), + } + ) + return RecordOutcome( + progress=updated_progress, + unlocked_lesson_id=unlocked_lesson_id, + newly_earned=newly_earned, + new_creature=new_creature, + is_new_best=improved, + ) diff --git a/python-backend/musicmouse/tippen/rewards.py b/python-backend/musicmouse/tippen/rewards.py new file mode 100644 index 0000000..a9d6ec3 --- /dev/null +++ b/python-backend/musicmouse/tippen/rewards.py @@ -0,0 +1,247 @@ +"""Turning a lesson's ``unlocks: `` into which tracks/episodes are still locked. + +Two shapes, one config key: + +* A music album or audiobook (a "folder" section - one album, several tracks): the path + names one track, and unlocks every track up to and including it - tracks + ``0..index`` inclusive, by ordinal position in ``Album.tracks``. +* A podcast show (an "episode" section - one album *per episode file*): the path names + one episode. Its show's episodes are sorted chronologically (oldest first, by the + ``YYYYMMDD - Title`` filename convention ``library.sections`` documents - the same + fact ``MusicLibrary.latest_episode`` already relies on), and every episode up to and + including the target unlocks - the same "up to this one" shape, just over episodes + instead of tracks. + +Lock state is never persisted: it is always recomputed fresh from (curriculum reward +config) x (current progress) x (the live library), matching how +``services/web/state.py``'s ``snapshot`` assembles player state on demand rather than +keeping a second copy in sync. An album or show no lesson ever names is simply never +locked - the reward system only ever restricts what it explicitly targets. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +from musicmouse.library import MusicLibrary +from musicmouse.library.models import Album +from musicmouse.library.sections import SECTIONS +from musicmouse.tippen.curriculum import Curriculum, Lesson +from musicmouse.tippen.progress import TypingProgress + +_log = logging.getLogger(__name__) + +__all__ = [ + "AlbumLock", + "LockState", + "ResolvedReward", + "TrackLock", + "UnlockHint", + "compute_lock_state", + "resolve_all", + "resolve_lesson_reward", +] + + +@dataclass(frozen=True, slots=True) +class ResolvedReward: + """Where a lesson's ``unlocks`` path landed in the live library.""" + + kind: Literal["tracks", "episode"] + #: The album a "tracks" reward lives in, or the specific episode-album an "episode" + #: reward names - either way, the concrete thing to show cover art/title for. + album_id: str + #: The show, for an "episode" reward - `None` for "tracks". + series: str | None + #: 0-based, inclusive: everything up to and including this index unlocks. + until_index: int + + +def _expand(raw: str, library_root: Path) -> Path: + path = Path(raw).expanduser() + if not path.is_absolute(): + path = library_root / path + return path.resolve() + + +def _chronological_episodes(library: MusicLibrary, series: str | None) -> list[Album]: + """A show's episode-albums, oldest first.""" + candidates = [ + album + for album in library.albums + if album.series == series + and (section := SECTIONS.get(album.section)) is not None + and section.album_unit == "episode" + ] + return sorted(candidates, key=lambda album: album.tracks[0].path.name if album.tracks else "") + + +def resolve_lesson_reward(lesson: Lesson, library: MusicLibrary) -> ResolvedReward | None: + """`None` (logged, not raised) when ``unlocks`` doesn't match anything on disk - a + moved or mistyped path must never take the whole app down, only that one reward.""" + if lesson.unlocks is None: + return None + target = _expand(lesson.unlocks, library.root) + + for album in library.albums: + section = SECTIONS.get(album.section) + if section is None: + continue + for index, track in enumerate(album.tracks): + if track.path.resolve() != target: + continue + if section.album_unit == "folder": + return ResolvedReward( + kind="tracks", album_id=album.id, series=None, until_index=index + ) + # "episode": the reward's range is this episode's position among its show's + # episodes, chronological - not its (always 0) index within its own album. + episodes = _chronological_episodes(library, album.series) + for episode_index, episode_album in enumerate(episodes): + if episode_album.id == album.id: + return ResolvedReward( + kind="episode", + album_id=album.id, + series=album.series, + until_index=episode_index, + ) + + _log.warning( + "tippen: lesson %r unlocks %r, which matches no track in the library", + lesson.id, + lesson.unlocks, + ) + return None + + +def resolve_all(curriculum: Curriculum, library: MusicLibrary) -> dict[str, ResolvedReward | None]: + return { + lesson.id: resolve_lesson_reward(lesson, library) + for lesson in curriculum.lessons + if lesson.unlocks is not None + } + + +@dataclass(frozen=True, slots=True) +class UnlockHint: + lesson_id: str + lesson_title: str + world_number: int + world_title: str + + +@dataclass(frozen=True, slots=True) +class TrackLock: + locked: bool + hint: UnlockHint | None + + +@dataclass(frozen=True, slots=True) +class AlbumLock: + locked: bool + tracks: tuple[TrackLock, ...] + + +@dataclass(frozen=True, slots=True) +class LockState: + #: Only albums touched by at least one reward - see the module docstring. + albums: dict[str, AlbumLock] = field(default_factory=dict) + + def get(self, album_id: str) -> AlbumLock | None: + return self.albums.get(album_id) + + +def _world_title(curriculum: Curriculum, world_number: int) -> str: + return next((world.title for world in curriculum.worlds if world.number == world_number), "") + + +def _hint_for( + curriculum: Curriculum, candidates: list[tuple[int, Lesson]], index: int +) -> UnlockHint | None: + """The earliest lesson that would unlock `index`, for "mention when this unlocks".""" + eligible = [(until, lesson) for until, lesson in candidates if until >= index] + if not eligible: + return None + _until, lesson = min(eligible, key=lambda pair: pair[0]) + return UnlockHint( + lesson_id=lesson.id, + lesson_title=lesson.title, + world_number=lesson.world, + world_title=_world_title(curriculum, lesson.world), + ) + + +def compute_lock_state( + curriculum: Curriculum, library: MusicLibrary, progress: TypingProgress +) -> LockState: + """Always recomputed fresh - see the module docstring.""" + resolved = resolve_all(curriculum, library) + + tracks_by_album: dict[str, list[tuple[int, Lesson]]] = {} + episodes_by_series: dict[str, list[tuple[int, Lesson]]] = {} + #: The best (highest) *earned* until_index, same two keys. + earned_album: dict[str, int] = {} + earned_series: dict[str, int] = {} + + for lesson in curriculum.lessons: + reward = resolved.get(lesson.id) + if reward is None: + continue + progress_entry = progress.lessons.get(lesson.id) + is_earned = progress_entry.earned if progress_entry is not None else False + + if reward.kind == "tracks": + tracks_by_album.setdefault(reward.album_id, []).append((reward.until_index, lesson)) + if is_earned: + earned_album[reward.album_id] = max( + earned_album.get(reward.album_id, -1), reward.until_index + ) + else: + series = reward.series + assert series is not None + episodes_by_series.setdefault(series, []).append((reward.until_index, lesson)) + if is_earned: + earned_series[series] = max(earned_series.get(series, -1), reward.until_index) + + albums: dict[str, AlbumLock] = {} + for album in library.albums: + section = SECTIONS.get(album.section) + if section is None: + continue + + if section.album_unit == "folder": + if album.id not in tracks_by_album: + continue # never targeted by any lesson: never locked + best = earned_album.get(album.id, -1) + candidates = tracks_by_album[album.id] + track_locks = tuple( + TrackLock( + locked=index > best, + hint=_hint_for(curriculum, candidates, index) if index > best else None, + ) + for index in range(len(album.tracks)) + ) + albums[album.id] = AlbumLock(locked=best < 0, tracks=track_locks) + else: + if album.series not in episodes_by_series: + continue + episodes = _chronological_episodes(library, album.series) + chronological_index = next( + (i for i, a in enumerate(episodes) if a.id == album.id), None + ) + if chronological_index is None: + continue + best = earned_series.get(album.series, -1) + locked = chronological_index > best + hint = ( + _hint_for(curriculum, episodes_by_series[album.series], chronological_index) + if locked + else None + ) + episode_lock = TrackLock(locked=locked, hint=hint) + albums[album.id] = AlbumLock(locked=locked, tracks=(episode_lock,)) + + return LockState(albums=albums) diff --git a/python-backend/musicmouse/tippen/runtime.py b/python-backend/musicmouse/tippen/runtime.py new file mode 100644 index 0000000..fcdd45a --- /dev/null +++ b/python-backend/musicmouse/tippen/runtime.py @@ -0,0 +1,34 @@ +"""The typing game's live in-memory state, built once at startup from config.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from musicmouse.config import TippenConfig +from musicmouse.tippen.curriculum import Curriculum, load_curriculum +from musicmouse.tippen.progress import TypingProgress, load_progress, save_progress + +__all__ = ["TippenRuntime", "build_tippen_runtime"] + + +@dataclass +class TippenRuntime: + curriculum: Curriculum + progress_path: Path + progress: TypingProgress + + def save(self) -> None: + save_progress(self.progress_path, self.progress) + + +def build_tippen_runtime(config: TippenConfig) -> TippenRuntime: + """Raises :class:`musicmouse.tippen.curriculum.CurriculumError` on a broken + curriculum file - a startup-time failure, same gate as a broken ``config.yml``. + Progress never raises; see ``musicmouse.tippen.progress.load_progress``. + """ + curriculum = load_curriculum(config.curriculum_file) + progress = load_progress(config.progress_file, curriculum) + return TippenRuntime( + curriculum=curriculum, progress_path=config.progress_file, progress=progress + ) diff --git a/python-backend/tests/test_tippen_api.py b/python-backend/tests/test_tippen_api.py new file mode 100644 index 0000000..c4301f9 --- /dev/null +++ b/python-backend/tests/test_tippen_api.py @@ -0,0 +1,247 @@ +"""The typing game's REST surface: curriculum, progress, settings, and run recording.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import httpx2 +import pytest + +from musicmouse.config import WebConfig, load_config +from musicmouse.services.web.service import build_app +from musicmouse.simulator.harness import Simulation, build_simulation +from musicmouse.tippen.curriculum import Curriculum, Lesson, World +from musicmouse.tippen.progress import fresh_progress +from musicmouse.tippen.runtime import TippenRuntime +from tests.conftest import VALID_CONFIG, write_config + + +def _lesson(id: str, *, unlocks: str | None = None) -> Lesson: + return Lesson( + id=id, + world=1, + number=1, + title=f"Lektion {id}", + subtitle="x", + kind="letters", + new_keys=(), + spotlight_keys=(), + emphasis=None, + active_keys=(), + primary_mode="dive", + bonus_modes=(), + words=(), + is_drill=False, + chunks=1, + chunk_size=1, + unlocks=unlocks, + ) + + +#: l02 unlocks the whole (two-track) "Kinderparty Lieder" album, via its last track. +_CURRICULUM = Curriculum( + worlds=(World(number=1, title="Riff", emoji="🐠", reward="clownfish"),), + lessons=( + _lesson("l01"), + _lesson("l02", unlocks="Musik/Kinderparty - Kinderparty Lieder/01 - lied.mp3"), + ), +) + + +def _run_body(lesson_id: str, *, stars: int = 3, passed: bool = True) -> dict: + return { + "lesson_id": lesson_id, + "stars": stars, + "animal": "fish", + "points": 42.0, + "passed": passed, + "pearls": 5, + "strokes": [{"key": "a", "expected": "a", "correct": True, "at": 0.0}], + } + + +@pytest.fixture +async def sim(config_dir: Path, tmp_path: Path) -> AsyncIterator[Simulation]: + config = load_config(write_config(config_dir, VALID_CONFIG)) + tippen = TippenRuntime( + curriculum=_CURRICULUM, + progress_path=tmp_path / "tippen-progress.json", + progress=fresh_progress(_CURRICULUM), + ) + simulation = await build_simulation(config, tippen=tippen) + try: + yield simulation + finally: + await simulation.aclose() + + +@pytest.fixture +async def sim_without_tippen(config_dir: Path) -> AsyncIterator[Simulation]: + config = load_config(write_config(config_dir, VALID_CONFIG)) + simulation = await build_simulation(config) + try: + yield simulation + finally: + await simulation.aclose() + + +async def _client(simulation: Simulation, config_path: Path) -> httpx2.AsyncClient: + api, hub = build_app(simulation.app, WebConfig(), config_path) + hub.start() + transport = httpx2.ASGITransport(app=api) + http = httpx2.AsyncClient(transport=transport, base_url="http://mouse") + http._musicmouse_hub = hub # type: ignore[attr-defined] + http._musicmouse_api = api # type: ignore[attr-defined] + return http + + +@pytest.fixture +async def client(sim: Simulation, config_dir: Path) -> AsyncIterator[httpx2.AsyncClient]: + http = await _client(sim, config_dir / "config.yml") + try: + yield http + finally: + http._musicmouse_hub.stop() # type: ignore[attr-defined] + await http._musicmouse_api.state.ha_client.aclose() # type: ignore[attr-defined] + await http.aclose() + + +@pytest.fixture +async def client_without_tippen( + sim_without_tippen: Simulation, config_dir: Path +) -> AsyncIterator[httpx2.AsyncClient]: + http = await _client(sim_without_tippen, config_dir / "config.yml") + try: + yield http + finally: + http._musicmouse_hub.stop() # type: ignore[attr-defined] + await http._musicmouse_api.state.ha_client.aclose() # type: ignore[attr-defined] + await http.aclose() + + +def _album_id(sim: Simulation, title: str) -> str: + return next(a.id for a in sim.app.library.albums if a.title == title) + + +# ----------------------------------------------------------------- unconfigured + + +async def test_tippen_routes_are_a_404_when_unconfigured( + client_without_tippen: httpx2.AsyncClient, +) -> None: + assert (await client_without_tippen.get("/api/tippen/curriculum")).status_code == 404 + assert (await client_without_tippen.get("/api/tippen/progress")).status_code == 404 + response = await client_without_tippen.post("/api/tippen/runs", json=_run_body("l01")) + assert response.status_code == 404 + + +async def test_library_has_no_lock_fields_when_unconfigured( + client_without_tippen: httpx2.AsyncClient, +) -> None: + body = (await client_without_tippen.get("/api/library")).json() + assert all(album["locked"] is False for album in body["albums"]) + assert all(not track["locked"] for album in body["albums"] for track in album["tracks"]) + + +# --------------------------------------------------------------------- curriculum + + +async def test_get_curriculum_reports_resolved_and_unresolved_rewards( + client: httpx2.AsyncClient, +) -> None: + body = (await client.get("/api/tippen/curriculum")).json() + lessons = {lesson["id"]: lesson for lesson in body["lessons"]} + assert lessons["l01"]["reward"] == { + "resolved": False, + "album_id": None, + "has_cover": False, + "kind": None, + } + assert lessons["l02"]["reward"]["resolved"] is True + assert lessons["l02"]["reward"]["kind"] == "tracks" + + +# ------------------------------------------------------------------------ progress + + +async def test_get_progress_is_fresh_with_only_the_first_lesson_unlocked( + client: httpx2.AsyncClient, +) -> None: + body = (await client.get("/api/tippen/progress")).json() + assert body["lessons"]["l01"]["unlocked"] is True + assert body["lessons"]["l02"]["unlocked"] is False + assert body["pearls"] == 0 + + +async def test_put_settings_round_trips(client: httpx2.AsyncClient) -> None: + response = await client.put( + "/api/tippen/settings", json={"sound": False, "keyboard_hint": "off"} + ) + assert response.status_code == 200 + assert response.json() == {"sound": False, "keyboard_hint": "off"} + + progress = (await client.get("/api/tippen/progress")).json() + assert progress["settings"] == {"sound": False, "keyboard_hint": "off"} + + +# ----------------------------------------------------------------------- run recording + + +async def test_an_unknown_lesson_id_is_rejected(client: httpx2.AsyncClient) -> None: + response = await client.post("/api/tippen/runs", json=_run_body("no-such-lesson")) + assert response.status_code == 422 + + +async def test_passing_a_lesson_unlocks_the_next_one(client: httpx2.AsyncClient) -> None: + response = await client.post("/api/tippen/runs", json=_run_body("l01")) + assert response.status_code == 200 + body = response.json() + assert body["unlocked_lesson_id"] == "l02" + assert body["unlocked_lesson_title"] == "Lektion l02" + assert body["progress"]["lessons"]["l02"]["unlocked"] is True + # l01 carries no reward of its own. + assert body["unlocked_reward"] is None + + +async def test_passing_a_lesson_with_a_reward_reveals_it_and_unlocks_the_album( + client: httpx2.AsyncClient, sim: Simulation +) -> None: + response = await client.post("/api/tippen/runs", json=_run_body("l02")) + assert response.status_code == 200 + body = response.json() + + reward = body["unlocked_reward"] + assert reward is not None + assert reward["kind"] == "album" + assert reward["title"] == "Lied 1" + assert reward["album_id"] == _album_id(sim, "Kinderparty Lieder") + + # And the album is now fully unlocked in the library snapshot. + library = (await client.get("/api/library")).json() + album = next(a for a in library["albums"] if a["id"] == reward["album_id"]) + assert album["locked"] is False + assert all(not track["locked"] for track in album["tracks"]) + + +async def test_a_locked_album_is_reported_as_locked_before_the_reward_is_earned( + client: httpx2.AsyncClient, sim: Simulation +) -> None: + library = (await client.get("/api/library")).json() + album = next(a for a in library["albums"] if a["id"] == _album_id(sim, "Kinderparty Lieder")) + assert album["locked"] is True + assert all(track["locked"] for track in album["tracks"]) + assert album["tracks"][0]["unlock_hint"]["lesson_id"] == "l02" + + +async def test_progress_survives_a_rebuilt_runtime( + client: httpx2.AsyncClient, sim: Simulation, tmp_path: Path +) -> None: + await client.post("/api/tippen/runs", json=_run_body("l01")) + assert sim.app.tippen is not None + + from musicmouse.tippen.progress import load_progress + + reloaded = load_progress(sim.app.tippen.progress_path, _CURRICULUM) + assert reloaded.lessons["l02"].unlocked is True + assert list(tmp_path.glob("*.tmp*")) == [] diff --git a/python-backend/tests/test_tippen_curriculum.py b/python-backend/tests/test_tippen_curriculum.py new file mode 100644 index 0000000..9b8dd57 --- /dev/null +++ b/python-backend/tests/test_tippen_curriculum.py @@ -0,0 +1,152 @@ +"""Loading and validating the typing game's curriculum file.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from ruamel.yaml import YAML + +from musicmouse.tippen.curriculum import CurriculumError, load_curriculum + +VALID: dict = { + "worlds": [ + { + "number": 1, + "title": "Riff", + "emoji": "🐠", + "reward": "clownfish", + "lessons": [ + {"title": "A und S", "subtitle": "Die Startfinger", "keys": ["a", "s"]}, + { + "title": "Wörter", + "subtitle": "Kurze Wörter", + "kind": "words", + "words": ["as", "sass"], + }, + ], + }, + { + "number": 2, + "title": "Höhle", + "emoji": "🐙", + "reward": "octopus", + "lessons": [{"title": "D und F", "subtitle": "Weiter", "keys": ["d", "f"]}], + }, + { + "number": 3, + "title": "Riff2", + "emoji": "🦑", + "reward": "seahorse", + "lessons": [{"title": "J und K", "subtitle": "Weiter", "keys": ["j", "k"]}], + }, + { + "number": 4, + "title": "Riff3", + "emoji": "🐳", + "reward": "turtle", + "lessons": [{"title": "L", "subtitle": "Weiter", "keys": ["l"]}], + }, + { + "number": 5, + "title": "Riff4", + "emoji": "🧜", + "reward": "pearlmussel", + "lessons": [{"title": "Ö", "subtitle": "Weiter", "keys": ["ö"]}], + }, + ] +} + + +def _write(directory: Path, data: dict, name: str = "curriculum.yaml") -> Path: + path = directory / name + with path.open("w", encoding="utf-8") as handle: + YAML(typ="safe").dump(data, handle) + return path + + +def test_a_valid_curriculum_loads(tmp_path: Path) -> None: + curriculum = load_curriculum(_write(tmp_path, VALID)) + assert [w.reward for w in curriculum.worlds] == [ + "clownfish", + "octopus", + "seahorse", + "turtle", + "pearlmussel", + ] + assert len(curriculum.lessons) == 6 + assert curriculum.lessons[0].id == "l01" + assert curriculum.lessons[0].new_keys == ("a", "s") + assert curriculum.lessons[1].kind == "words" + assert curriculum.lessons[1].words == ("as", "sass") + + +def test_missing_file_is_a_curriculum_error(tmp_path: Path) -> None: + with pytest.raises(CurriculumError, match="Cannot read"): + load_curriculum(tmp_path / "no-such-file.yaml") + + +def test_not_yaml_is_a_curriculum_error(tmp_path: Path) -> None: + path = tmp_path / "curriculum.yaml" + path.write_text("worlds: [this is not: valid: yaml", encoding="utf-8") + with pytest.raises(CurriculumError, match="not valid YAML"): + load_curriculum(path) + + +def test_unknown_key_is_rejected(tmp_path: Path) -> None: + data = {"worlds": VALID["worlds"], "extra_top_level_key": True} + with pytest.raises(CurriculumError, match="extra_top_level_key"): + load_curriculum(_write(tmp_path, data)) + + +def test_wrong_world_count_is_one_problem(tmp_path: Path) -> None: + data = {"worlds": VALID["worlds"][:4]} + with pytest.raises(CurriculumError, match="expected 5 worlds, found 4"): + load_curriculum(_write(tmp_path, data)) + + +def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None: + worlds = [dict(w) for w in VALID["worlds"]] + # Two independent problems in one file: a reused reward, and a lesson with too many + # keys - both should show up in a single error, not just the first one found. + worlds[1] = {**worlds[1], "reward": "clownfish"} + worlds[2] = { + **worlds[2], + "lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "d"]}], + } + with pytest.raises(CurriculumError) as excinfo: + load_curriculum(_write(tmp_path, {"worlds": worlds})) + message = str(excinfo.value) + assert "2 problems" in message + assert "reuses reward" in message + assert "at most two keys" in message + + +def test_letters_kind_cannot_have_words(tmp_path: Path) -> None: + worlds = [dict(w) for w in VALID["worlds"]] + worlds[0] = { + **worlds[0], + "lessons": [{"title": "x", "subtitle": "x", "words": ["hallo"]}], + } + with pytest.raises(CurriculumError, match="has words but no explicit kind"): + load_curriculum(_write(tmp_path, {"worlds": worlds})) + + +def test_non_letters_kind_needs_words(tmp_path: Path) -> None: + worlds = [dict(w) for w in VALID["worlds"]] + worlds[0] = { + **worlds[0], + "lessons": [{"title": "x", "subtitle": "x", "kind": "words"}], + } + with pytest.raises(CurriculumError, match='kind "words" needs a non-empty words list'): + load_curriculum(_write(tmp_path, {"worlds": worlds})) + + +def test_unlocks_is_carried_through_unresolved(tmp_path: Path) -> None: + worlds = [dict(w) for w in VALID["worlds"]] + lessons = [dict(lesson) for lesson in worlds[0]["lessons"]] + lessons[0] = {**lessons[0], "unlocks": "~/Music/Musik/Album/05Track.mp3"} + worlds[0] = {**worlds[0], "lessons": lessons} + curriculum = load_curriculum(_write(tmp_path, {"worlds": worlds})) + assert curriculum.lessons[0].unlocks == "~/Music/Musik/Album/05Track.mp3" + assert curriculum.lessons[1].unlocks is None diff --git a/python-backend/tests/test_tippen_progress.py b/python-backend/tests/test_tippen_progress.py new file mode 100644 index 0000000..8a3a180 --- /dev/null +++ b/python-backend/tests/test_tippen_progress.py @@ -0,0 +1,229 @@ +"""Typing-progress persistence and the ``record_run`` bookkeeping it drives.""" + +from __future__ import annotations + +from pathlib import Path + +from musicmouse.tippen.curriculum import Curriculum, Lesson, World +from musicmouse.tippen.progress import ( + DILIGENCE_ATTEMPTS, + RunResult, + Stroke, + fresh_progress, + load_progress, + record_run, + save_progress, +) + + +def _lesson(id: str, world: int, number: int, **overrides: object) -> Lesson: + base: dict = { + "id": id, + "world": world, + "number": number, + "title": id, + "subtitle": "", + "kind": "letters", + "new_keys": (), + "spotlight_keys": (), + "emphasis": None, + "active_keys": (), + "primary_mode": "dive", + "bonus_modes": (), + "words": (), + "is_drill": False, + "chunks": 1, + "chunk_size": 1, + "unlocks": None, + } + base.update(overrides) + return Lesson(**base) # type: ignore[arg-type] + + +def _curriculum() -> Curriculum: + lessons = ( + _lesson("l01", 1, 1), + _lesson("l02", 1, 2, unlocks="Musik/Album/00.mp3"), + _lesson("l03", 2, 1), + ) + worlds = ( + World(number=1, title="Riff", emoji="🐠", reward="clownfish"), + World(number=2, title="Höhle", emoji="🐙", reward="octopus"), + ) + return Curriculum(worlds=worlds, lessons=lessons) + + +def _result(*, stars: int, passed: bool, points: float = 10.0, pearls: int = 3) -> RunResult: + return RunResult( + stars=stars, # type: ignore[arg-type] + animal="fish", + points=points, + passed=passed, + pearls=pearls, + strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),), + ) + + +# ------------------------------------------------------------------------ persistence + + +def test_fresh_progress_unlocks_only_the_first_lesson() -> None: + progress = fresh_progress(_curriculum()) + assert progress.lessons["l01"].unlocked is True + assert progress.lessons["l02"].unlocked is False + assert progress.lessons["l03"].unlocked is False + + +def test_a_missing_file_is_a_fresh_start(tmp_path: Path) -> None: + progress = load_progress(tmp_path / "no-such-file.json", _curriculum()) + assert progress == fresh_progress(_curriculum()) + + +def test_a_corrupt_file_is_a_fresh_start_not_a_crash(tmp_path: Path) -> None: + path = tmp_path / "progress.json" + path.write_text("{not valid json", encoding="utf-8") + progress = load_progress(path, _curriculum()) + assert progress == fresh_progress(_curriculum()) + + +def test_save_then_load_round_trips(tmp_path: Path) -> None: + path = tmp_path / "progress.json" + curriculum = _curriculum() + progress = fresh_progress(curriculum) + outcome = record_run( + progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-01" + ) + + save_progress(path, outcome.progress) + reloaded = load_progress(path, curriculum) + + assert reloaded.lessons["l01"].best_stars == 3 + assert reloaded.lessons["l02"].unlocked is True + assert reloaded.pearls == 3 + # Atomic write leaves no temp file behind. + assert list(tmp_path.glob("*.tmp*")) == [] + + +def test_a_lesson_added_to_the_curriculum_since_the_last_save_gets_an_entry(tmp_path: Path) -> None: + path = tmp_path / "progress.json" + small = Curriculum( + worlds=(World(number=1, title="Riff", emoji="🐠", reward="clownfish"),), + lessons=(_lesson("l01", 1, 1),), + ) + save_progress(path, fresh_progress(small)) + + grown = _curriculum() + reloaded = load_progress(path, grown) + assert "l02" in reloaded.lessons + assert "l03" in reloaded.lessons + + +# --------------------------------------------------------------------------- earning + + +def test_two_stars_unlocks_the_next_lesson() -> None: + curriculum = _curriculum() + progress = fresh_progress(curriculum) + outcome = record_run( + progress, "l01", _result(stars=2, passed=True), curriculum, day="2026-01-01" + ) + assert outcome.newly_earned is True + assert outcome.unlocked_lesson_id == "l02" + assert outcome.progress.lessons["l02"].unlocked is True + + +def test_one_star_does_not_unlock_the_next_lesson() -> None: + curriculum = _curriculum() + progress = fresh_progress(curriculum) + outcome = record_run( + progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-01" + ) + assert outcome.newly_earned is False + assert outcome.unlocked_lesson_id is None + + +def test_the_diligence_attempt_unlocks_regardless_of_score() -> None: + curriculum = _curriculum() + progress = fresh_progress(curriculum) + for _ in range(DILIGENCE_ATTEMPTS - 1): + outcome = record_run( + progress, "l01", _result(stars=0, passed=False), curriculum, day="2026-01-01" + ) + progress = outcome.progress + assert outcome.newly_earned is False + + outcome = record_run( + progress, "l01", _result(stars=0, passed=False), curriculum, day="2026-01-01" + ) + assert outcome.progress.lessons["l01"].runs == DILIGENCE_ATTEMPTS + assert outcome.newly_earned is True + assert outcome.unlocked_lesson_id == "l02" + + +def test_a_replay_after_already_earned_does_not_re_unlock() -> None: + curriculum = _curriculum() + progress = fresh_progress(curriculum) + first = record_run(progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-01") + assert first.unlocked_lesson_id == "l02" + + again = record_run( + first.progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-02" + ) + assert again.newly_earned is False + assert again.unlocked_lesson_id is None + + +def test_best_score_only_improves() -> None: + curriculum = _curriculum() + progress = fresh_progress(curriculum) + good = record_run( + progress, "l01", _result(stars=3, passed=True, points=100), curriculum, day="2026-01-01" + ) + worse = record_run( + good.progress, "l01", _result(stars=1, passed=False, points=5), curriculum, day="2026-01-02" + ) + assert worse.progress.lessons["l01"].best_stars == 3 + assert worse.progress.lessons["l01"].best_points == 100 + assert worse.is_new_best is False + + +def test_finishing_a_world_awards_its_creature_exactly_once() -> None: + curriculum = _curriculum() + progress = fresh_progress(curriculum) + # Pass l01 (does not cross a world boundary: l02 is still world 1). + step1 = record_run(progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-01") + assert step1.new_creature is None + + # Pass l02: the next lesson (l03) is world 2, so this crosses the boundary. + step2 = record_run( + step1.progress, "l02", _result(stars=3, passed=True), curriculum, day="2026-01-02" + ) + assert step2.new_creature == "clownfish" + assert step2.progress.aquarium == ["clownfish"] + + # Replaying l02 after l03 is already unlocked must never award it twice. + step3 = record_run( + step2.progress, "l02", _result(stars=3, passed=True), curriculum, day="2026-01-03" + ) + assert step3.new_creature is None + assert step3.progress.aquarium == ["clownfish"] + + +def test_pearls_and_streak_accumulate() -> None: + curriculum = _curriculum() + progress = fresh_progress(curriculum) + day1 = record_run( + progress, "l01", _result(stars=1, passed=False, pearls=3), curriculum, day="2026-01-01" + ) + day2 = record_run( + day1.progress, "l01", _result(stars=1, passed=False, pearls=4), curriculum, day="2026-01-02" + ) + assert day2.progress.pearls == 7 + assert day2.progress.streak.days == 2 + assert day2.progress.streak.last_played == "2026-01-02" + + # A missed day (skip to 2026-01-05) restarts the streak at 1, not 0. + day5 = record_run( + day2.progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-05" + ) + assert day5.progress.streak.days == 1 diff --git a/python-backend/tests/test_tippen_rewards.py b/python-backend/tests/test_tippen_rewards.py new file mode 100644 index 0000000..b9a0d0b --- /dev/null +++ b/python-backend/tests/test_tippen_rewards.py @@ -0,0 +1,219 @@ +"""Resolving a lesson's ``unlocks:`` against the library, and the lock state it drives. + +Music/audiobook albums are one folder with many tracks; podcasts are one album *per +episode file*, grouped by show - see ``library/sections.py``. The two need different +math (an ordinal track index vs. a chronological episode index across many albums), so +both are exercised here against a real scanned library, not a hand-built one. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest + +from musicmouse.library import MusicLibrary +from musicmouse.tippen.curriculum import Curriculum, Lesson, World +from musicmouse.tippen.progress import LessonProgress, TypingProgress +from musicmouse.tippen.rewards import compute_lock_state, resolve_lesson_reward +from tests.conftest import write_track + + +def _lesson(id: str, *, unlocks: str | None) -> Lesson: + return Lesson( + id=id, + world=1, + number=1, + title=id, + subtitle="", + kind="letters", + new_keys=(), + spotlight_keys=(), + emphasis=None, + active_keys=(), + primary_mode="dive", + bonus_modes=(), + words=(), + is_drill=False, + chunks=1, + chunk_size=1, + unlocks=unlocks, + ) + + +@pytest.fixture +async def library(tmp_path: Path) -> MusicLibrary: + root = tmp_path / "music" + + for index in range(6): + write_track( + root / "Musik" / "Kinderparty - Kinderparty Lieder" / f"{index:02d} - lied.mp3", + title=f"Lied {index}", + album="Kinderparty Lieder", + albumartist="Kinderparty", + ) + + # An album no lesson ever targets - proves "never referenced, never locked". + for index in range(2): + write_track( + root / "Hörbücher" / "Conni - Conni in den Bergen" / f"{index:02d} - teil.mp3", + title=f"Teil {index}", + album="Conni in den Bergen", + albumartist="Conni", + ) + + show = root / "Kinderpodcasts" / "Wissen macht Ah" + # Created out of chronological order on purpose, to prove the *filename date*, not + # creation order or directory listing order, decides "chronological". + for date, title in (("20260301", "Dritte"), ("20260101", "Erste"), ("20260201", "Zweite")): + write_track( + show / f"{date} - {title}.mp3", + title=title, + album="Wissen macht Ah! - Podcast", + albumartist="Various", + ) + + return await MusicLibrary.build(root, tmp_path / ".cache", frozenset({".mp3"})) + + +def _album(library: MusicLibrary, title: str): + return next(a for a in library.albums if a.title == title) + + +# ------------------------------------------------------------------------- resolving + + +async def test_a_music_track_resolves_to_its_ordinal_index(library: MusicLibrary) -> None: + album = _album(library, "Kinderparty Lieder") + target = album.tracks[2].path + lesson = _lesson("l1", unlocks=str(target)) + + reward = resolve_lesson_reward(lesson, library) + + assert reward is not None + assert reward.kind == "tracks" + assert reward.album_id == album.id + assert reward.until_index == 2 + + +async def test_a_relative_path_resolves_against_the_library_root(library: MusicLibrary) -> None: + lesson = _lesson("l1", unlocks="Musik/Kinderparty - Kinderparty Lieder/02 - lied.mp3") + reward = resolve_lesson_reward(lesson, library) + assert reward is not None + assert reward.until_index == 2 + + +async def test_an_unresolvable_path_is_none_not_a_crash( + library: MusicLibrary, caplog: pytest.LogCaptureFixture +) -> None: + lesson = _lesson("l1", unlocks="Musik/Nope/Nothing.mp3") + with caplog.at_level(logging.WARNING): + reward = resolve_lesson_reward(lesson, library) + assert reward is None + assert "matches no track" in caplog.text + + +async def test_a_lesson_with_no_unlocks_resolves_to_none(library: MusicLibrary) -> None: + assert resolve_lesson_reward(_lesson("l1", unlocks=None), library) is None + + +async def test_a_podcast_episode_resolves_to_its_chronological_index(library: MusicLibrary) -> None: + zweite = next(t for t in library.albums if t.title == "Zweite") + lesson = _lesson("l1", unlocks=str(zweite.tracks[0].path)) + + reward = resolve_lesson_reward(lesson, library) + + assert reward is not None + assert reward.kind == "episode" + assert reward.series == "Wissen macht Ah" + # Erste (2026-01-01) = 0, Zweite (2026-02-01) = 1, Dritte (2026-03-01) = 2 - + # chronological by filename date, regardless of the creation order above. + assert reward.until_index == 1 + + +# --------------------------------------------------------------------------- locking + + +def _curriculum(*lessons: Lesson) -> Curriculum: + return Curriculum( + worlds=(World(number=1, title="Riff", emoji="🐠", reward="clownfish"),), lessons=lessons + ) + + +def _progress(**earned: bool) -> TypingProgress: + """`earned["l1"] = True` etc - built via best_stars, since `.earned` is derived.""" + lessons = { + lesson_id: LessonProgress(unlocked=True, runs=1, best_stars=3 if is_earned else 0) + for lesson_id, is_earned in earned.items() + } + return TypingProgress(lessons=lessons) + + +async def test_an_album_no_lesson_targets_is_never_locked(library: MusicLibrary) -> None: + curriculum = _curriculum() + state = compute_lock_state(curriculum, library, _progress()) + conni = _album(library, "Conni in den Bergen") + assert state.get(conni.id) is None + + +async def test_an_unearned_music_lesson_locks_the_whole_album(library: MusicLibrary) -> None: + album = _album(library, "Kinderparty Lieder") + lesson = _lesson("l1", unlocks=str(album.tracks[2].path)) + state = compute_lock_state(_curriculum(lesson), library, _progress(l1=False)) + + lock = state.get(album.id) + assert lock is not None + assert lock.locked is True + assert all(track.locked for track in lock.tracks) + assert lock.tracks[0].hint is not None + assert lock.tracks[0].hint.lesson_id == "l1" + + +async def test_an_earned_music_lesson_partially_unlocks_the_album(library: MusicLibrary) -> None: + album = _album(library, "Kinderparty Lieder") + # l1 is passed and covers tracks 0-2; l2 covers further (0-4) but is not passed yet - + # so track 5 is locked with no covering lesson at all, and tracks 3-4 are locked but + # do have a hint, from the still-unearned l2. + l1 = _lesson("l1", unlocks=str(album.tracks[2].path)) + l2 = _lesson("l2", unlocks=str(album.tracks[4].path)) + state = compute_lock_state(_curriculum(l1, l2), library, _progress(l1=True, l2=False)) + + lock = state.get(album.id) + assert lock is not None + assert lock.locked is False # some tracks are unlocked, so the cover shows + assert [track.locked for track in lock.tracks] == [False, False, False, True, True, True] + assert lock.tracks[0].hint is None # already unlocked, no hint needed + assert lock.tracks[3].hint is not None + assert lock.tracks[3].hint.lesson_id == "l2" + assert lock.tracks[5].hint is None # nothing configured unlocks track 5 at all + + +async def test_two_lessons_targeting_the_same_album_take_the_max_earned_range( + library: MusicLibrary, +) -> None: + album = _album(library, "Kinderparty Lieder") + early = _lesson("l1", unlocks=str(album.tracks[1].path)) + late = _lesson("l2", unlocks=str(album.tracks[4].path)) + # Only the earlier lesson passed: unlocked through track 1. + state = compute_lock_state(_curriculum(early, late), library, _progress(l1=True, l2=False)) + assert [t.locked for t in state.get(album.id).tracks] == [False, False, True, True, True, True] # type: ignore[union-attr] + + # Both passed: unlocked through track 4 (the later lesson's own range wins). + state = compute_lock_state(_curriculum(early, late), library, _progress(l1=True, l2=True)) + locks = [t.locked for t in state.get(album.id).tracks] # type: ignore[union-attr] + assert locks == [False, False, False, False, False, True] + + +async def test_podcast_unlock_is_chronological_not_per_episode(library: MusicLibrary) -> None: + zweite = _album(library, "Zweite") + dritte = _album(library, "Dritte") + l1 = _lesson("l1", unlocks=str(zweite.tracks[0].path)) # earned: unlocks through Zweite + l2 = _lesson("l2", unlocks=str(dritte.tracks[0].path)) # not earned yet: covers Dritte + state = compute_lock_state(_curriculum(l1, l2), library, _progress(l1=True, l2=False)) + + erste = _album(library, "Erste") + assert state.get(erste.id).locked is False # type: ignore[union-attr] + assert state.get(zweite.id).locked is False # type: ignore[union-attr] + assert state.get(dritte.id).locked is True # type: ignore[union-attr] + assert state.get(dritte.id).tracks[0].hint.lesson_id == "l2" # type: ignore[union-attr] diff --git a/python-backend/tippen-curriculum.yml.example b/python-backend/tippen-curriculum.yml.example new file mode 100644 index 0000000..25a2307 --- /dev/null +++ b/python-backend/tippen-curriculum.yml.example @@ -0,0 +1,450 @@ +# The typing game's lesson plan, referenced from config.yml's general.tippen.curriculum_file. +# +# Every lesson: title, subtitle (read aloud). Then either: +# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were +# already active: the first time a key appears its lesson is "isolated" (heavy +# weight, alone); the second (identical) appearance is "mixed" (lighter weight, +# blended with everything learned so far). Two lessons with the same `keys` back to +# back is exactly how you write "isolated, then mixed" - no separate flag needed. +# - drill: true - a pure review round, no new content, whatever is active so far. +# - kind + words - a fragments/words/sentences consolidation round (dive mode by +# default; `mode:` overrides it - see the backend's `ELIGIBLE_MODES` for which +# modes fit which kind). +# +# Letter order follows German letter frequency, adapted to a home-row-first, mirrored +# pace for a six-year-old. +# +# Optional reward key, on any lesson: +# +# unlocks: +# +# A path into the music library (see config.yml's general.library.root - absolute, or +# relative to it; "~" is expanded). Passing this lesson (two stars, or five attempts +# regardless of score) unlocks that track and everything before it in the same album, +# inclusive - so pointing at an album's last track unlocks the whole album, and +# pointing at track 5 of an audiobook unlocks chapters 1 through 5. The web front-end +# shows a still-locked track as a question mark, with a note on which lesson unlocks it. +# +# For a podcast (one album per episode under Kinderpodcasts), the same key unlocks that +# episode and every earlier one of the same show, oldest first - so pointing at the +# third episode ever published unlocks the first three, regardless of upload order. +# +# A path that matches nothing in the library is logged as a warning at startup, not a +# fatal error - a moved or mistyped path only loses that one reward, never the app. +# +# Unlike config.yml, this file is never written back to by the app - only progress is. + +worlds: + - number: 1 + title: "Die Grundstellung" + emoji: "🏝️" + reward: clownfish + lessons: + - title: "F und J" + subtitle: "Die Zeigefinger - die Tasten mit den Punkten" + keys: [f, j] + - title: "F und J üben" + subtitle: "Die neuen Tasten festigen" + keys: [f, j] + - title: "D und K" + subtitle: "Die Mittelfinger" + keys: [d, k] + - title: "D und K üben" + subtitle: "Die neuen Tasten festigen" + keys: [d, k] + - title: "Übung: F J D K" + subtitle: "Die vier Tasten zusammen" + drill: true + - title: "S und L" + subtitle: "Die Ringfinger" + keys: [s, l] + # Example - unlocks tracks 1 through 5 (inclusive) of this album: + # unlocks: ~/Music/Musik/Kinderparty - Kinderparty Lieder/05 - Lied.mp3 + - title: "S und L üben" + subtitle: "Die neuen Tasten festigen" + keys: [s, l] + - title: "Übung: sechs Tasten" + subtitle: "Alles bisher zusammen" + drill: true + - title: "A und Ö" + subtitle: "Die kleinen Finger" + keys: [a, ö] + - title: "A und Ö üben" + subtitle: "Die neuen Tasten festigen" + keys: [a, ö] + - title: "Übung: die Grundstellung" + subtitle: "Alle acht Finger" + drill: true + - title: "Erste kleine Wörter" + subtitle: "Echte Wörter mit acht Tasten" + kind: fragments + words: [da, ja, das, dass, als, all, fall, falls, lass, saal, kalk, salsa, jass] + - title: "Die Leertaste" + subtitle: "Der Daumen kommt dazu" + keys: [" "] + - title: "Übung: Grundstellung mit Leertaste" + subtitle: "Jetzt mit dem Daumen" + drill: true + - title: "Wörter mit Leertaste" + subtitle: "Kleine Wörter, kleine Sätze" + kind: fragments + words: ["lass das", "das da", "da ja", "fall da", "kalk da", "saal da", "ja lass das", "als da"] + + - number: 2 + title: "Nach oben" + emoji: "🌊" + reward: octopus + lessons: + - title: "Das E" + subtitle: "Mittelfinger links nach oben" + keys: [e] + - title: "E üben" + subtitle: "Die neue Taste festigen" + keys: [e] + - title: "Das I" + subtitle: "Mittelfinger rechts nach oben" + keys: [i] + - title: "I üben" + subtitle: "Die neue Taste festigen" + keys: [i] + - title: "Übung: E und I" + subtitle: "Beide Mittelfinger nach oben" + drill: true + - title: "Das R" + subtitle: "Zeigefinger links nach oben" + keys: [r] + - title: "R üben" + subtitle: "Die neue Taste festigen" + keys: [r] + - title: "Das U" + subtitle: "Zeigefinger rechts nach oben" + keys: [u] + - title: "U üben" + subtitle: "Die neue Taste festigen" + keys: [u] + - title: "Übung: R und U" + subtitle: "Beide Zeigefinger nach oben" + drill: true + - title: "Kleine Wörter: E I R U" + subtitle: "Erste echte Wörter mit der oberen Reihe" + kind: fragments + words: [die, sie, elf, eis, esel, see, keks, fiel, lied, rad, reis, eier, rufe, feuer, sauer, lauf] + - title: "Das T" + subtitle: "Zeigefinger links weit nach oben" + keys: [t] + - title: "T üben" + subtitle: "Die neue Taste festigen" + keys: [t] + - title: "Das Z" + subtitle: "Zeigefinger rechts weit nach oben" + keys: [z] + - title: "Z üben" + subtitle: "Die neue Taste festigen" + keys: [z] + - title: "Übung: T und Z" + subtitle: "Weit nach oben greifen" + drill: true + - title: "Das O" + subtitle: "Ringfinger rechts nach oben" + keys: [o] + - title: "O üben" + subtitle: "Die neue Taste festigen" + keys: [o] + - title: "Das W" + subtitle: "Ringfinger links nach oben" + keys: [w] + - title: "W üben" + subtitle: "Die neue Taste festigen" + keys: [w] + - title: "Übung: O und W" + subtitle: "Beide Ringfinger nach oben" + drill: true + - title: "Kleine Wörter: T Z O W" + subtitle: "Noch mehr echte Wörter" + kind: fragments + mode: feed + words: [tier, tafel, kette, leiter, zeit, salz, zelt, katze, rot, tor, los, foto, wo, wald, zwei, wolke] + - title: "Das P" + subtitle: "Kleiner Finger rechts nach oben" + keys: [p] + - title: "P üben" + subtitle: "Die neue Taste festigen" + keys: [p] + - title: "Das Q" + subtitle: "Kleiner Finger links nach oben" + keys: [q] + - title: "Q üben" + subtitle: "Die neue Taste festigen" + keys: [q] + - title: "Das Ü" + subtitle: "Kleiner Finger rechts, ganz außen" + keys: [ü] + - title: "Ü üben" + subtitle: "Die neue Taste festigen" + keys: [ü] + - title: "Übung: P Q Ü" + subtitle: "Die kleinen Finger nach oben" + drill: true + - title: "Übung: die obere Reihe" + subtitle: "Die ganze Reihe zusammen" + kind: words + mode: feed + words: [wolke, zeit, pause, prüfe, qualle, torte, reiter, würfel] + - title: "Übung: Welt 2 komplett" + subtitle: "Alles aus der oberen Reihe" + drill: true + # Example - unlocks a whole audiobook (its last chapter, inclusive of every + # chapter before it): + # unlocks: ~/Music/Hörbücher/Conni - Conni in den Bergen/06 - Teil.mp3 + + - number: 3 + title: "Nach unten" + emoji: "🪸" + reward: seahorse + lessons: + - title: "Das N" + subtitle: "Zeigefinger rechts nach unten" + keys: [n] + - title: "N üben" + subtitle: "Die neue Taste festigen" + keys: [n] + - title: "Das M" + subtitle: "Zeigefinger rechts, neben dem N" + keys: [m] + - title: "M üben" + subtitle: "Die neue Taste festigen" + keys: [m] + - title: "Übung: N und M" + subtitle: "Die neuen Tasten festigen" + drill: true + - title: "Das G" + subtitle: "Zeigefinger links, in der Mitte" + keys: [g] + - title: "G üben" + subtitle: "Die neue Taste festigen" + keys: [g] + - title: "Das H" + subtitle: "Zeigefinger rechts, in der Mitte" + keys: [h] + - title: "H üben" + subtitle: "Die neue Taste festigen" + keys: [h] + - title: "Übung: G und H" + subtitle: "Die Mitte der Grundreihe" + drill: true + - title: "Kleine Wörter: N M G H" + subtitle: "Erste echte Wörter nach unten" + kind: fragments + words: [nase, nein, kind, wind, sonne, mama, mond, meer, maus, gut, gans, regen, hase, haus, hund, hupe] + - title: "Das C" + subtitle: "Mittelfinger links nach unten" + keys: [c] + - title: "C üben" + subtitle: "Die neue Taste festigen" + keys: [c] + - title: "Das V" + subtitle: "Zeigefinger links nach unten" + keys: [v] + - title: "V üben" + subtitle: "Die neue Taste festigen" + keys: [v] + - title: "Übung: C und V" + subtitle: "Nach unten greifen" + drill: true + - title: "Das B" + subtitle: "Zeigefinger links, neben dem V" + keys: [b] + - title: "B üben" + subtitle: "Die neue Taste festigen" + keys: [b] + - title: "Das Y" + subtitle: "Kleiner Finger links nach unten" + keys: [y] + - title: "Y üben" + subtitle: "Die neue Taste festigen" + keys: [y] + - title: "Übung: B und Y" + subtitle: "Ganz unten links" + drill: true + - title: "Kleine Wörter: C V B Y" + subtitle: "Noch mehr echte Wörter" + kind: fragments + words: [koch, milch, schule, chaos, vier, vase, voll, vater, baum, boot, bunt, brot, yoga, baby, typ, pony] + - title: "Das X" + subtitle: "Ringfinger links nach unten" + keys: [x] + - title: "X üben" + subtitle: "Die neue Taste festigen" + keys: [x] + - title: "Das Ä" + subtitle: "Kleiner Finger rechts, ganz außen" + keys: [ä] + - title: "Ä üben" + subtitle: "Die neue Taste festigen" + keys: [ä] + - title: "Übung: X und Ä" + subtitle: "Die letzten beiden Tasten" + drill: true + - title: "Übung: alle Buchstaben" + subtitle: "Das ganze Alphabet" + kind: words + mode: race + words: [delfin, wasser, xylofon, bäume, vogel, qualle, muschel, tauchen] + - title: "Übung: Welt 3 komplett" + subtitle: "Alles aus der unteren Reihe" + drill: true + # Example - unlocks a whole podcast show (its most recent episode, and every + # earlier one of the same show, chronologically): + # unlocks: ~/Music/Kinderpodcasts/Wissen macht Ah/20260101 - Neu.mp3 + + - number: 4 + title: "Große Buchstaben" + emoji: "👑" + reward: turtle + lessons: + - title: "Umschalttaste rechts, Teil 1" + subtitle: "Große Buchstaben der linken Hand" + keys: ["⇧"] + kind: words + words: [Delfin, Wal, Fisch, Baum] + - title: "Umschalttaste rechts, Teil 2" + subtitle: "Noch mehr große Buchstaben" + kind: words + words: [Garten, Ente, Vogel, Riff] + - title: "Umschalttaste links, Teil 1" + subtitle: "Große Buchstaben der rechten Hand" + kind: words + words: [Haus, Kind, Mond, Nase] + - title: "Umschalttaste links, Teil 2" + subtitle: "Noch mehr große Buchstaben" + kind: words + words: [Lampe, Onkel, Uhr, Puppe] + - title: "Übung: Namen, Teil 1" + subtitle: "Namen fangen groß an" + kind: words + words: [Anna, Lena, Paul, Mia, Emil, Jonas, Tom, Lisa] + - title: "Übung: Namen, Teil 2" + subtitle: "Noch mehr Namen" + kind: words + mode: feed + words: [Ben, Nora, Finn, Ida, Max, Ella, Oskar, Greta] + - title: "Übung: große und kleine" + subtitle: "Beides gemischt" + kind: words + words: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel", "Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"] + - title: "Übung: Welt 4 komplett" + subtitle: "Groß und klein zusammen" + kind: words + drill: true + words: [Delfin, Haus, Anna, Ben, "Das Meer", "Der Wal", Mond, Riff] + + - number: 5 + title: "Ganze Sätze" + emoji: "📖" + reward: pearlmussel + lessons: + - title: "Der Punkt" + subtitle: "Ringfinger rechts nach unten" + keys: ["."] + - title: "Punkt üben" + subtitle: "Die neue Taste festigen" + keys: ["."] + - title: "Erste Sätze mit Punkt" + subtitle: "Ein Satz, ein Punkt" + kind: sentences + words: + - "Das Meer ist tief." + - "Der Hund bellt." + - "Ich mag Kekse." + - "Die Sonne scheint." + - "Der Wal ist riesig." + - "Wir gehen baden." + - "Mama liest ein Buch." + - "Der Fisch schwimmt." + - "Heute ist es warm." + - "Ich habe einen Ball." + - "Die Welle ist hoch." + - "Papa kocht Suppe." + - title: "Das Komma" + subtitle: "Mittelfinger rechts nach unten" + keys: [","] + - title: "Komma üben" + subtitle: "Die neue Taste festigen" + keys: [","] + - title: "Sätze mit Komma" + subtitle: "Zwei Gedanken, ein Satz" + kind: sentences + mode: race + words: + - "Ich mag Wale, Delfine und Fische." + - "Erst lesen, dann tippen." + - "Es ist warm, also baden wir." + - "Rot, gelb und blau sind Farben." + - "Wenn es regnet, bleiben wir drinnen." + - "Der Delfin springt, taucht und spielt." + - "Morgen, sagt Papa, fahren wir los." + - "Eins, zwei, drei, vier." + - "Oma, Opa und ich gehen schwimmen." + - "Die Sonne scheint, das Meer glitzert." + - "Muscheln, Steine und Sand liegen am Strand." + - title: "Der Bindestrich" + subtitle: "Kleiner Finger rechts, ganz außen" + keys: ["-"] + - title: "Bindestrich üben" + subtitle: "Die neue Taste festigen" + keys: ["-"] + - title: "Sätze mit Bindestrich" + subtitle: "Zwei Wörter, ein Strich" + kind: sentences + words: + - "Wir spielen mit dem Wasser-Ball." + - "Das ist ein Delfin-Baby." + - "Meine Ur-Oma kommt heute." + - "Wir bauen eine Sand-Burg." + - "Der Fisch-Schwarm ist riesig." + - "Ich trage mein T-Shirt." + - "Das Schwimm-Bad ist offen." + - "Die Bade-Hose ist nass." + - "Wir essen ein Eis-Hörnchen." + - "Das Segel-Boot ist blau." + - "Mein Lieblings-Tier ist der Delfin." + - title: "Fragezeichen und Ausrufezeichen" + subtitle: "Mit der Umschalttaste" + keys: ["ß", "1"] + - title: "Fragezeichen und Ausrufezeichen üben" + subtitle: "Die neuen Tasten festigen" + keys: ["ß", "1"] + - title: "Fragen und Rufe" + subtitle: "Wie klingt ein Satz?" + kind: sentences + words: + - "Wo ist der Delfin?" + - "Das war toll!" + - "Wie geht es dir?" + - "Pass auf!" + - "Kommst du mit?" + - "Der Wal ist so groß!" + - "Hast du Hunger?" + - "Hurra, Ferien!" + - "Was schwimmt da?" + - "Schau mal, ein Hai!" + - "Wie tief ist das Meer?" + - "Wir haben es geschafft!" + - title: "Übung: ganze Sätze" + subtitle: "Alles zusammen" + kind: sentences + drill: true + mode: race + words: + - "Der Delfin schwimmt sehr schnell." + - "Wo ist mein Boot?" + - "Ich tippe jetzt mit zehn Fingern!" + - "Das Meer ist blau, tief und kalt." + - "Kannst du das auch?" + - "Wir bauen eine Sand-Burg am Strand." + - "Die Möwe fliegt über das Wasser." + - "Oma, Opa und ich gehen schwimmen." + - "Das ist ja super!" + - "Wie heißt der große Wal?" + - "Im Riff wohnen bunte Fische." + - "Der Krake hat acht Arme."