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

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

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

View File

@@ -118,6 +118,16 @@ general:
- entity_id: scene.kinderzimmer_gute_nacht - entity_id: scene.kinderzimmer_gute_nacht
name: "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. # One entry per figurine. The key is the figure name and the subfolder name.
figures: figures:
fuchs: fuchs:

View File

@@ -40,6 +40,8 @@ from musicmouse.services.lirc import LircService
from musicmouse.services.mqtt import MqttService, build_entities from musicmouse.services.mqtt import MqttService, build_entities
from musicmouse.services.podcasts import PodcastFeedService from musicmouse.services.podcasts import PodcastFeedService
from musicmouse.services.web import WebService 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") _log = logging.getLogger("musicmouse")
@@ -100,10 +102,20 @@ def main(argv: list[str] | None = None) -> int:
print(f"error: {exc}", file=sys.stderr) print(f"error: {exc}", file=sys.stderr)
return 2 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 = ( runner = (
run_simulated(config, args.config, args.script) run_simulated(config, args.config, args.script, tippen=tippen)
if args.simulate 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: try:
asyncio.run(runner) asyncio.run(runner)
@@ -115,7 +127,13 @@ def main(argv: list[str] | None = None) -> int:
# ------------------------------------------------------------------------- real # ------------------------------------------------------------------------- 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() bus = EventBus()
await bus.start() await bus.start()
clock = RealClock() 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) player = _build_player(bus, general, clock=clock)
library = await build_library(config) 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) services = _build_services(app, mouse, player, clock=clock, config_path=config_path)
_log.info( _log.info(
@@ -176,7 +194,9 @@ async def run_real(config: Config, config_path: Path, *, hardware: bool = True)
# -------------------------------------------------------------------- simulated # -------------------------------------------------------------------- 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. # Imported here so the production path never touches the simulator.
from musicmouse.simulator.harness import build_simulation from musicmouse.simulator.harness import build_simulation
from musicmouse.simulator.repl import run_repl 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 # 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. # real clock, so playback ticks along while you watch it.
sim = await build_simulation( 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( services = _build_services(
@@ -280,6 +300,7 @@ def _build_app(
library: MusicLibrary, library: MusicLibrary,
*, *,
clock: RealClock, clock: RealClock,
tippen: TippenRuntime | None = None,
) -> App: ) -> App:
app = App( app = App(
config=config, config=config,
@@ -292,6 +313,7 @@ def _build_app(
# identity check. # identity check.
playlists=library.figure_playlists(), playlists=library.figure_playlists(),
clock=clock, clock=clock,
tippen=tippen,
) )
register_all(bus, app) register_all(bus, app)
return app return app

View File

@@ -14,6 +14,7 @@ from musicmouse.devices.player import Player
from musicmouse.library import MusicLibrary from musicmouse.library import MusicLibrary
from musicmouse.library.models import Album from musicmouse.library.models import Album
from musicmouse.media import Playlist from musicmouse.media import Playlist
from musicmouse.tippen.runtime import TippenRuntime
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -46,6 +47,8 @@ class App:
playlists: dict[str, Playlist] playlists: dict[str, Playlist]
clock: Clock = field(default_factory=RealClock) clock: Clock = field(default_factory=RealClock)
state: AppState = field(default_factory=AppState) 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: def colors(self, figure: str) -> FigureColors:
return self.config.figures[figure].colors return self.config.figures[figure].colors

View File

@@ -43,6 +43,7 @@ __all__ = [
"LircConfig", "LircConfig",
"MqttConfig", "MqttConfig",
"RemoteSlotConfig", "RemoteSlotConfig",
"TippenConfig",
"WebConfig", "WebConfig",
"format_validation_error", "format_validation_error",
"load_config", "load_config",
@@ -116,14 +117,19 @@ class FigureColors(_Strict):
return data 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.""" """Make a configured path absolute against the config file, and optionally check it."""
context = info.context or {} context = info.context or {}
base = context.get("config_dir") base = context.get("config_dir")
if base is not None and not folder.is_absolute(): if base is not None and not folder.is_absolute():
folder = (Path(base) / folder).resolve() folder = (Path(base) / folder).resolve()
if must_exist and context.get("check_paths", True) and not folder.is_dir(): if must_exist and context.get("check_paths", True):
raise ValueError(f"no such directory: {folder}") 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 return folder
@@ -227,6 +233,29 @@ class HaConfig(_Strict):
return self 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): class GeneralConfig(_Strict):
library: LibraryConfig library: LibraryConfig
@@ -244,6 +273,7 @@ class GeneralConfig(_Strict):
web: WebConfig | None = None web: WebConfig | None = None
ha: HaConfig | None = None ha: HaConfig | None = None
lirc: LircConfig | None = None lirc: LircConfig | None = None
tippen: TippenConfig | None = None
min_volume: int = Field(default=0, ge=0, le=200) min_volume: int = Field(default=0, ge=0, le=200)
max_volume: int = Field(default=100, ge=0, le=200) max_volume: int = Field(default=100, ge=0, le=200)

View File

@@ -46,6 +46,12 @@ from musicmouse.services.web.schemas import (
SeekIn, SeekIn,
SettingsIn, SettingsIn,
SettingsOut, SettingsOut,
TippenCurriculumOut,
TippenProgressOut,
TippenRunIn,
TippenRunOut,
TippenSettingsIn,
TippenSettingsOut,
TrackCurvesOut, TrackCurvesOut,
TrackDetailOut, TrackDetailOut,
VolumeIn, VolumeIn,
@@ -57,6 +63,9 @@ from musicmouse.services.web.settings import (
write_settings, write_settings,
) )
from musicmouse.services.web.state import snapshot 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__) _log = logging.getLogger(__name__)
@@ -75,7 +84,17 @@ def build_router(
@router.get("/library") @router.get("/library")
def get_library() -> LibraryOut: 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") @router.get("/albums/{album_id}/cover")
def get_cover(album_id: str) -> FileResponse: def get_cover(album_id: str) -> FileResponse:
@@ -200,6 +219,45 @@ def build_router(
await hub.broadcast_state() await hub.broadcast_state()
return read_settings(general) 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 # -------------------------------------------------------------- IR remote
@router.get("/lirc") @router.get("/lirc")

View File

@@ -15,6 +15,9 @@ from pydantic import BaseModel, Field
from musicmouse.config import Digit from musicmouse.config import Digit
from musicmouse.library import Album from musicmouse.library import Album
from musicmouse.library.analysis import TrackAnalysis 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__ = [ __all__ = [
"AlbumOut", "AlbumOut",
@@ -31,9 +34,25 @@ __all__ = [
"SeekIn", "SeekIn",
"SettingsIn", "SettingsIn",
"SettingsOut", "SettingsOut",
"TippenCurriculumOut",
"TippenGhostStrokeOut",
"TippenKeyStatOut",
"TippenLessonOut",
"TippenLessonProgressOut",
"TippenProgressOut",
"TippenRewardOut",
"TippenRunIn",
"TippenRunOut",
"TippenSettingsIn",
"TippenSettingsOut",
"TippenStreakOut",
"TippenStrokeIn",
"TippenUnlockedRewardOut",
"TippenWorldOut",
"TrackCurvesOut", "TrackCurvesOut",
"TrackDetailOut", "TrackDetailOut",
"TrackOut", "TrackOut",
"UnlockHintOut",
"VolumeIn", "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): class TrackOut(BaseModel):
title: str title: str
duration: float duration: float
#: Scalars only. The beat grid is fetched per track from ``/api/tracks/...``. #: Scalars only. The beat grid is fetched per track from ``/api/tracks/...``.
analysis: AnalysisOut | None = None 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): class AlbumOut(BaseModel):
@@ -81,9 +133,13 @@ class AlbumOut(BaseModel):
has_cover: bool has_cover: bool
duration: float duration: float
tracks: list[TrackOut] 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 @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( return cls(
id=album.id, id=album.id,
section=album.section, section=album.section,
@@ -96,13 +152,10 @@ class AlbumOut(BaseModel):
colors=list(album.colors), colors=list(album.colors),
has_cover=album.cover is not None, has_cover=album.cover is not None,
duration=album.duration, duration=album.duration,
locked=lock.locked if lock is not None else False,
tracks=[ tracks=[
TrackOut( TrackOut.of(track, track_locks[i] if i < len(track_locks) else None)
title=track.title, for i, track in enumerate(album.tracks)
duration=track.duration,
analysis=AnalysisOut.of(track.analysis),
)
for track in album.tracks
], ],
) )
@@ -230,3 +283,136 @@ class RemoteMappingIn(BaseModel):
"""Full replacement, like ``SettingsIn``: a digit absent here becomes unassigned.""" """Full replacement, like ``SettingsIn``: a digit absent here becomes unassigned."""
slots: dict[Digit, RemoteSlotIn] 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

View File

@@ -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,
)

View File

@@ -19,6 +19,7 @@ from musicmouse.reactions import register_all
from musicmouse.simulator.driver import SimulatorDriver from musicmouse.simulator.driver import SimulatorDriver
from musicmouse.simulator.fake_player import DEFAULT_TRACK_DURATION, FakePlayer from musicmouse.simulator.fake_player import DEFAULT_TRACK_DURATION, FakePlayer
from musicmouse.simulator.fake_transport import FakeTransport from musicmouse.simulator.fake_transport import FakeTransport
from musicmouse.tippen.runtime import TippenRuntime
__all__ = ["Simulation", "build_simulation"] __all__ = ["Simulation", "build_simulation"]
@@ -43,6 +44,7 @@ async def build_simulation(
clock: Clock | None = None, clock: Clock | None = None,
track_duration: float = DEFAULT_TRACK_DURATION, track_duration: float = DEFAULT_TRACK_DURATION,
library: MusicLibrary | None = None, library: MusicLibrary | None = None,
tippen: TippenRuntime | None = None,
) -> Simulation: ) -> Simulation:
bus = EventBus() bus = EventBus()
await bus.start() await bus.start()
@@ -78,6 +80,7 @@ async def build_simulation(
library=library, library=library,
playlists=library.figure_playlists(), playlists=library.figure_playlists(),
clock=clock, clock=clock,
tippen=tippen,
) )
register_all(bus, app) register_all(bus, app)
mouse.on_connected() mouse.on_connected()

View File

@@ -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",
]

View File

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

View File

@@ -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,
)

View File

@@ -0,0 +1,247 @@
"""Turning a lesson's ``unlocks: <path>`` 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)

View File

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

View File

@@ -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*")) == []

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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: <path>
#
# 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."