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:
@@ -118,6 +118,16 @@ general:
|
||||
- entity_id: scene.kinderzimmer_gute_nacht
|
||||
name: "Gute Nacht"
|
||||
|
||||
# The typing game ("Tippen"). Omit the whole section to hide its tab in the web
|
||||
# front-end. The lesson plan is content, not device config, so it lives in its own
|
||||
# file - see tippen-curriculum.yml.example for the format, including the optional
|
||||
# `unlocks:` key that turns passing a lesson into unlocking part of the library.
|
||||
tippen:
|
||||
curriculum_file: tippen-curriculum.yml
|
||||
# Where progress (stars, unlocked lessons, streak, ...) is saved. Written by the
|
||||
# app itself - never hand-edited. Relative to this file, like curriculum_file.
|
||||
progress_file: tippen-progress.json
|
||||
|
||||
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||
figures:
|
||||
fuchs:
|
||||
|
||||
@@ -40,6 +40,8 @@ from musicmouse.services.lirc import LircService
|
||||
from musicmouse.services.mqtt import MqttService, build_entities
|
||||
from musicmouse.services.podcasts import PodcastFeedService
|
||||
from musicmouse.services.web import WebService
|
||||
from musicmouse.tippen.curriculum import CurriculumError
|
||||
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
|
||||
|
||||
_log = logging.getLogger("musicmouse")
|
||||
|
||||
@@ -100,10 +102,20 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
tippen: TippenRuntime | None = None
|
||||
if config.general.tippen is not None:
|
||||
try:
|
||||
tippen = build_tippen_runtime(config.general.tippen)
|
||||
except CurriculumError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
runner = (
|
||||
run_simulated(config, args.config, args.script)
|
||||
run_simulated(config, args.config, args.script, tippen=tippen)
|
||||
if args.simulate
|
||||
else run_real(config, args.config, hardware=wants_hardware(config, args.no_hardware))
|
||||
else run_real(
|
||||
config, args.config, hardware=wants_hardware(config, args.no_hardware), tippen=tippen
|
||||
)
|
||||
)
|
||||
try:
|
||||
asyncio.run(runner)
|
||||
@@ -115,7 +127,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# ------------------------------------------------------------------------- real
|
||||
|
||||
|
||||
async def run_real(config: Config, config_path: Path, *, hardware: bool = True) -> None:
|
||||
async def run_real(
|
||||
config: Config,
|
||||
config_path: Path,
|
||||
*,
|
||||
hardware: bool = True,
|
||||
tippen: TippenRuntime | None = None,
|
||||
) -> None:
|
||||
bus = EventBus()
|
||||
await bus.start()
|
||||
clock = RealClock()
|
||||
@@ -137,7 +155,7 @@ async def run_real(config: Config, config_path: Path, *, hardware: bool = True)
|
||||
player = _build_player(bus, general, clock=clock)
|
||||
|
||||
library = await build_library(config)
|
||||
app = _build_app(config, bus, mouse, player, library, clock=clock)
|
||||
app = _build_app(config, bus, mouse, player, library, clock=clock, tippen=tippen)
|
||||
services = _build_services(app, mouse, player, clock=clock, config_path=config_path)
|
||||
|
||||
_log.info(
|
||||
@@ -176,7 +194,9 @@ async def run_real(config: Config, config_path: Path, *, hardware: bool = True)
|
||||
# -------------------------------------------------------------------- simulated
|
||||
|
||||
|
||||
async def run_simulated(config: Config, config_path: Path, script: Path | None) -> None:
|
||||
async def run_simulated(
|
||||
config: Config, config_path: Path, script: Path | None, *, tippen: TippenRuntime | None = None
|
||||
) -> None:
|
||||
# Imported here so the production path never touches the simulator.
|
||||
from musicmouse.simulator.harness import build_simulation
|
||||
from musicmouse.simulator.repl import run_repl
|
||||
@@ -185,7 +205,7 @@ async def run_simulated(config: Config, config_path: Path, script: Path | None)
|
||||
# A script runs on virtual time, so `wait 1s` is instant. The prompt runs on the
|
||||
# real clock, so playback ticks along while you watch it.
|
||||
sim = await build_simulation(
|
||||
config, clock=RealClock() if script is None else None, track_duration=5.0
|
||||
config, clock=RealClock() if script is None else None, track_duration=5.0, tippen=tippen
|
||||
)
|
||||
|
||||
services = _build_services(
|
||||
@@ -280,6 +300,7 @@ def _build_app(
|
||||
library: MusicLibrary,
|
||||
*,
|
||||
clock: RealClock,
|
||||
tippen: TippenRuntime | None = None,
|
||||
) -> App:
|
||||
app = App(
|
||||
config=config,
|
||||
@@ -292,6 +313,7 @@ def _build_app(
|
||||
# identity check.
|
||||
playlists=library.figure_playlists(),
|
||||
clock=clock,
|
||||
tippen=tippen,
|
||||
)
|
||||
register_all(bus, app)
|
||||
return app
|
||||
|
||||
@@ -14,6 +14,7 @@ from musicmouse.devices.player import Player
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.library.models import Album
|
||||
from musicmouse.media import Playlist
|
||||
from musicmouse.tippen.runtime import TippenRuntime
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,6 +47,8 @@ class App:
|
||||
playlists: dict[str, Playlist]
|
||||
clock: Clock = field(default_factory=RealClock)
|
||||
state: AppState = field(default_factory=AppState)
|
||||
#: `None` when `general.tippen` is absent - the typing game is off.
|
||||
tippen: TippenRuntime | None = None
|
||||
|
||||
def colors(self, figure: str) -> FigureColors:
|
||||
return self.config.figures[figure].colors
|
||||
|
||||
@@ -43,6 +43,7 @@ __all__ = [
|
||||
"LircConfig",
|
||||
"MqttConfig",
|
||||
"RemoteSlotConfig",
|
||||
"TippenConfig",
|
||||
"WebConfig",
|
||||
"format_validation_error",
|
||||
"load_config",
|
||||
@@ -116,14 +117,19 @@ class FigureColors(_Strict):
|
||||
return data
|
||||
|
||||
|
||||
def _resolve_folder(folder: Path, info: ValidationInfo, *, must_exist: bool) -> Path:
|
||||
def _resolve_folder(
|
||||
folder: Path, info: ValidationInfo, *, must_exist: bool, kind: Literal["dir", "file"] = "dir"
|
||||
) -> Path:
|
||||
"""Make a configured path absolute against the config file, and optionally check it."""
|
||||
context = info.context or {}
|
||||
base = context.get("config_dir")
|
||||
if base is not None and not folder.is_absolute():
|
||||
folder = (Path(base) / folder).resolve()
|
||||
if must_exist and context.get("check_paths", True) and not folder.is_dir():
|
||||
raise ValueError(f"no such directory: {folder}")
|
||||
if must_exist and context.get("check_paths", True):
|
||||
exists = folder.is_file() if kind == "file" else folder.is_dir()
|
||||
if not exists:
|
||||
noun = "file" if kind == "file" else "directory"
|
||||
raise ValueError(f"no such {noun}: {folder}")
|
||||
return folder
|
||||
|
||||
|
||||
@@ -227,6 +233,29 @@ class HaConfig(_Strict):
|
||||
return self
|
||||
|
||||
|
||||
class TippenConfig(_Strict):
|
||||
"""The typing game. Omit the whole section to run without it.
|
||||
|
||||
The curriculum is content, not device settings, so it lives in its own file
|
||||
(``curriculum_file``) rather than inline here - see ``tippen-curriculum.yml.example``.
|
||||
``progress_file`` is written by the app itself, not hand-edited, and defaults to a
|
||||
name next to ``config.yml`` if not given a folder of its own.
|
||||
"""
|
||||
|
||||
curriculum_file: Path
|
||||
progress_file: Path = Path("tippen-progress.json")
|
||||
|
||||
@field_validator("curriculum_file")
|
||||
@classmethod
|
||||
def _resolve_curriculum_file(cls, path: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(path, info, must_exist=True, kind="file")
|
||||
|
||||
@field_validator("progress_file")
|
||||
@classmethod
|
||||
def _resolve_progress_file(cls, path: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(path, info, must_exist=False, kind="file")
|
||||
|
||||
|
||||
class GeneralConfig(_Strict):
|
||||
library: LibraryConfig
|
||||
|
||||
@@ -244,6 +273,7 @@ class GeneralConfig(_Strict):
|
||||
web: WebConfig | None = None
|
||||
ha: HaConfig | None = None
|
||||
lirc: LircConfig | None = None
|
||||
tippen: TippenConfig | None = None
|
||||
|
||||
min_volume: int = Field(default=0, ge=0, le=200)
|
||||
max_volume: int = Field(default=100, ge=0, le=200)
|
||||
|
||||
@@ -46,6 +46,12 @@ from musicmouse.services.web.schemas import (
|
||||
SeekIn,
|
||||
SettingsIn,
|
||||
SettingsOut,
|
||||
TippenCurriculumOut,
|
||||
TippenProgressOut,
|
||||
TippenRunIn,
|
||||
TippenRunOut,
|
||||
TippenSettingsIn,
|
||||
TippenSettingsOut,
|
||||
TrackCurvesOut,
|
||||
TrackDetailOut,
|
||||
VolumeIn,
|
||||
@@ -57,6 +63,9 @@ from musicmouse.services.web.settings import (
|
||||
write_settings,
|
||||
)
|
||||
from musicmouse.services.web.state import snapshot
|
||||
from musicmouse.services.web.tippen_api import curriculum_out, progress_out, record_tippen_run
|
||||
from musicmouse.tippen.rewards import compute_lock_state
|
||||
from musicmouse.tippen.runtime import TippenRuntime
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
@@ -75,7 +84,17 @@ def build_router(
|
||||
|
||||
@router.get("/library")
|
||||
def get_library() -> LibraryOut:
|
||||
return LibraryOut(albums=[AlbumOut.of(album) for album in app.library.albums])
|
||||
lock_state = (
|
||||
compute_lock_state(app.tippen.curriculum, app.library, app.tippen.progress)
|
||||
if app.tippen is not None
|
||||
else None
|
||||
)
|
||||
return LibraryOut(
|
||||
albums=[
|
||||
AlbumOut.of(album, lock_state.get(album.id) if lock_state else None)
|
||||
for album in app.library.albums
|
||||
]
|
||||
)
|
||||
|
||||
@router.get("/albums/{album_id}/cover")
|
||||
def get_cover(album_id: str) -> FileResponse:
|
||||
@@ -200,6 +219,45 @@ def build_router(
|
||||
await hub.broadcast_state()
|
||||
return read_settings(general)
|
||||
|
||||
# ----------------------------------------------------------------- tippen
|
||||
|
||||
def _require_tippen() -> TippenRuntime:
|
||||
if app.tippen is None:
|
||||
raise HTTPException(status_code=404, detail="tippen not configured")
|
||||
return app.tippen
|
||||
|
||||
@router.get("/tippen/curriculum")
|
||||
def get_tippen_curriculum() -> TippenCurriculumOut:
|
||||
runtime = _require_tippen()
|
||||
return curriculum_out(runtime.curriculum, app.library)
|
||||
|
||||
@router.get("/tippen/progress")
|
||||
def get_tippen_progress() -> TippenProgressOut:
|
||||
return progress_out(_require_tippen().progress)
|
||||
|
||||
@router.put("/tippen/settings")
|
||||
async def put_tippen_settings(body: TippenSettingsIn) -> TippenSettingsOut:
|
||||
runtime = _require_tippen()
|
||||
runtime.progress = runtime.progress.model_copy(
|
||||
update={"settings": runtime.progress.settings.model_copy(update=body.model_dump())}
|
||||
)
|
||||
await asyncio.to_thread(runtime.save)
|
||||
return TippenSettingsOut(
|
||||
sound=runtime.progress.settings.sound,
|
||||
keyboard_hint=runtime.progress.settings.keyboard_hint,
|
||||
)
|
||||
|
||||
@router.post("/tippen/runs")
|
||||
async def post_tippen_run(body: TippenRunIn) -> TippenRunOut:
|
||||
runtime = _require_tippen()
|
||||
if runtime.curriculum.lessons and not any(
|
||||
lesson.id == body.lesson_id for lesson in runtime.curriculum.lessons
|
||||
):
|
||||
raise HTTPException(status_code=422, detail=f"no such lesson {body.lesson_id!r}")
|
||||
result = await asyncio.to_thread(record_tippen_run, app, body)
|
||||
await hub.broadcast_library()
|
||||
return result
|
||||
|
||||
# -------------------------------------------------------------- IR remote
|
||||
|
||||
@router.get("/lirc")
|
||||
|
||||
@@ -15,6 +15,9 @@ from pydantic import BaseModel, Field
|
||||
from musicmouse.config import Digit
|
||||
from musicmouse.library import Album
|
||||
from musicmouse.library.analysis import TrackAnalysis
|
||||
from musicmouse.library.models import LibraryTrack
|
||||
from musicmouse.tippen.progress import AnimalId
|
||||
from musicmouse.tippen.rewards import AlbumLock, TrackLock, UnlockHint
|
||||
|
||||
__all__ = [
|
||||
"AlbumOut",
|
||||
@@ -31,9 +34,25 @@ __all__ = [
|
||||
"SeekIn",
|
||||
"SettingsIn",
|
||||
"SettingsOut",
|
||||
"TippenCurriculumOut",
|
||||
"TippenGhostStrokeOut",
|
||||
"TippenKeyStatOut",
|
||||
"TippenLessonOut",
|
||||
"TippenLessonProgressOut",
|
||||
"TippenProgressOut",
|
||||
"TippenRewardOut",
|
||||
"TippenRunIn",
|
||||
"TippenRunOut",
|
||||
"TippenSettingsIn",
|
||||
"TippenSettingsOut",
|
||||
"TippenStreakOut",
|
||||
"TippenStrokeIn",
|
||||
"TippenUnlockedRewardOut",
|
||||
"TippenWorldOut",
|
||||
"TrackCurvesOut",
|
||||
"TrackDetailOut",
|
||||
"TrackOut",
|
||||
"UnlockHintOut",
|
||||
"VolumeIn",
|
||||
]
|
||||
|
||||
@@ -61,11 +80,44 @@ class AnalysisOut(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class UnlockHintOut(BaseModel):
|
||||
"""Which typing lesson unlocks a still-locked track, for "mention when this
|
||||
unlocks" in the browse view."""
|
||||
|
||||
lesson_id: str
|
||||
lesson_title: str
|
||||
world_number: int
|
||||
world_title: str
|
||||
|
||||
@classmethod
|
||||
def of(cls, hint: UnlockHint) -> UnlockHintOut:
|
||||
return cls(
|
||||
lesson_id=hint.lesson_id,
|
||||
lesson_title=hint.lesson_title,
|
||||
world_number=hint.world_number,
|
||||
world_title=hint.world_title,
|
||||
)
|
||||
|
||||
|
||||
class TrackOut(BaseModel):
|
||||
title: str
|
||||
duration: float
|
||||
#: Scalars only. The beat grid is fetched per track from ``/api/tracks/...``.
|
||||
analysis: AnalysisOut | None = None
|
||||
#: A reward-gated track not yet earned - the browse view shows a placeholder
|
||||
#: instead of the real title.
|
||||
locked: bool = False
|
||||
unlock_hint: UnlockHintOut | None = None
|
||||
|
||||
@classmethod
|
||||
def of(cls, track: LibraryTrack, lock: TrackLock | None = None) -> TrackOut:
|
||||
return cls(
|
||||
title=track.title,
|
||||
duration=track.duration,
|
||||
analysis=AnalysisOut.of(track.analysis),
|
||||
locked=lock.locked if lock is not None else False,
|
||||
unlock_hint=UnlockHintOut.of(lock.hint) if lock is not None and lock.hint else None,
|
||||
)
|
||||
|
||||
|
||||
class AlbumOut(BaseModel):
|
||||
@@ -81,9 +133,13 @@ class AlbumOut(BaseModel):
|
||||
has_cover: bool
|
||||
duration: float
|
||||
tracks: list[TrackOut]
|
||||
#: Every track is still locked - the browse view shows a question mark instead of
|
||||
#: cover art. `False` for an album no typing reward ever targets.
|
||||
locked: bool = False
|
||||
|
||||
@classmethod
|
||||
def of(cls, album: Album) -> AlbumOut:
|
||||
def of(cls, album: Album, lock: AlbumLock | None = None) -> AlbumOut:
|
||||
track_locks = lock.tracks if lock is not None else ()
|
||||
return cls(
|
||||
id=album.id,
|
||||
section=album.section,
|
||||
@@ -96,13 +152,10 @@ class AlbumOut(BaseModel):
|
||||
colors=list(album.colors),
|
||||
has_cover=album.cover is not None,
|
||||
duration=album.duration,
|
||||
locked=lock.locked if lock is not None else False,
|
||||
tracks=[
|
||||
TrackOut(
|
||||
title=track.title,
|
||||
duration=track.duration,
|
||||
analysis=AnalysisOut.of(track.analysis),
|
||||
)
|
||||
for track in album.tracks
|
||||
TrackOut.of(track, track_locks[i] if i < len(track_locks) else None)
|
||||
for i, track in enumerate(album.tracks)
|
||||
],
|
||||
)
|
||||
|
||||
@@ -230,3 +283,136 @@ class RemoteMappingIn(BaseModel):
|
||||
"""Full replacement, like ``SettingsIn``: a digit absent here becomes unassigned."""
|
||||
|
||||
slots: dict[Digit, RemoteSlotIn]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- tippen
|
||||
|
||||
|
||||
class TippenRewardOut(BaseModel):
|
||||
"""What a lesson's ``unlocks:`` resolves to right now - `resolved` is `False` when
|
||||
the configured path matches nothing in the current library (a moved or mistyped
|
||||
path), so the frontend can show a broken-reward state instead of silently
|
||||
dropping it."""
|
||||
|
||||
resolved: bool
|
||||
album_id: str | None = None
|
||||
has_cover: bool = False
|
||||
kind: Literal["tracks", "episode"] | None = None
|
||||
|
||||
|
||||
class TippenLessonOut(BaseModel):
|
||||
id: str
|
||||
world: int
|
||||
number: int
|
||||
title: str
|
||||
subtitle: str
|
||||
kind: str
|
||||
new_keys: list[str]
|
||||
spotlight_keys: list[str]
|
||||
emphasis: Literal["isolated", "mixed"] | None
|
||||
active_keys: list[str]
|
||||
primary_mode: str
|
||||
bonus_modes: list[str]
|
||||
words: list[str]
|
||||
is_drill: bool
|
||||
chunks: int
|
||||
chunk_size: int
|
||||
reward: TippenRewardOut
|
||||
|
||||
|
||||
class TippenWorldOut(BaseModel):
|
||||
number: int
|
||||
title: str
|
||||
emoji: str
|
||||
reward: str
|
||||
|
||||
|
||||
class TippenCurriculumOut(BaseModel):
|
||||
worlds: list[TippenWorldOut]
|
||||
lessons: list[TippenLessonOut]
|
||||
|
||||
|
||||
class TippenGhostStrokeOut(BaseModel):
|
||||
key: str
|
||||
at: float
|
||||
|
||||
|
||||
class TippenLessonProgressOut(BaseModel):
|
||||
unlocked: bool
|
||||
runs: int
|
||||
best_stars: int
|
||||
best_animal: AnimalId | None
|
||||
best_points: float
|
||||
#: Derived, not stored - see ``LessonProgress.earned``.
|
||||
earned: bool
|
||||
ghost: list[TippenGhostStrokeOut] | None
|
||||
|
||||
|
||||
class TippenKeyStatOut(BaseModel):
|
||||
ema: float
|
||||
attempts: int
|
||||
errors: int
|
||||
|
||||
|
||||
class TippenStreakOut(BaseModel):
|
||||
days: int
|
||||
last_played: str | None
|
||||
|
||||
|
||||
class TippenSettingsOut(BaseModel):
|
||||
sound: bool
|
||||
keyboard_hint: Literal["auto", "on", "off"]
|
||||
|
||||
|
||||
class TippenSettingsIn(BaseModel):
|
||||
sound: bool
|
||||
keyboard_hint: Literal["auto", "on", "off"]
|
||||
|
||||
|
||||
class TippenProgressOut(BaseModel):
|
||||
lessons: dict[str, TippenLessonProgressOut]
|
||||
key_stats: dict[str, TippenKeyStatOut]
|
||||
pearls: int
|
||||
aquarium: list[str]
|
||||
streak: TippenStreakOut
|
||||
settings: TippenSettingsOut
|
||||
|
||||
|
||||
class TippenStrokeIn(BaseModel):
|
||||
key: str
|
||||
expected: str
|
||||
correct: bool
|
||||
#: ms timestamp, from the run's own clock.
|
||||
at: float
|
||||
|
||||
|
||||
class TippenRunIn(BaseModel):
|
||||
"""A run the client already graded - see ``grading.ts``. Grading itself stays
|
||||
client-side; this only tells the backend what to do with progress."""
|
||||
|
||||
lesson_id: str
|
||||
stars: Literal[0, 1, 2, 3]
|
||||
animal: AnimalId
|
||||
points: float
|
||||
passed: bool
|
||||
pearls: int = Field(ge=0)
|
||||
strokes: list[TippenStrokeIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TippenUnlockedRewardOut(BaseModel):
|
||||
"""The literal track/episode this run's lesson names in its own ``unlocks:`` -
|
||||
what the unlock animation shows, via the existing ``/api/albums/{id}/cover``."""
|
||||
|
||||
album_id: str
|
||||
title: str
|
||||
has_cover: bool
|
||||
kind: Literal["album", "book", "podcast_episode"]
|
||||
|
||||
|
||||
class TippenRunOut(BaseModel):
|
||||
progress: TippenProgressOut
|
||||
unlocked_lesson_id: str | None
|
||||
unlocked_lesson_title: str | None
|
||||
new_creature: str | None
|
||||
is_new_best: bool
|
||||
unlocked_reward: TippenUnlockedRewardOut | None
|
||||
|
||||
187
python-backend/musicmouse/services/web/tippen_api.py
Normal file
187
python-backend/musicmouse/services/web/tippen_api.py
Normal 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,
|
||||
)
|
||||
@@ -19,6 +19,7 @@ from musicmouse.reactions import register_all
|
||||
from musicmouse.simulator.driver import SimulatorDriver
|
||||
from musicmouse.simulator.fake_player import DEFAULT_TRACK_DURATION, FakePlayer
|
||||
from musicmouse.simulator.fake_transport import FakeTransport
|
||||
from musicmouse.tippen.runtime import TippenRuntime
|
||||
|
||||
__all__ = ["Simulation", "build_simulation"]
|
||||
|
||||
@@ -43,6 +44,7 @@ async def build_simulation(
|
||||
clock: Clock | None = None,
|
||||
track_duration: float = DEFAULT_TRACK_DURATION,
|
||||
library: MusicLibrary | None = None,
|
||||
tippen: TippenRuntime | None = None,
|
||||
) -> Simulation:
|
||||
bus = EventBus()
|
||||
await bus.start()
|
||||
@@ -78,6 +80,7 @@ async def build_simulation(
|
||||
library=library,
|
||||
playlists=library.figure_playlists(),
|
||||
clock=clock,
|
||||
tippen=tippen,
|
||||
)
|
||||
register_all(bus, app)
|
||||
mouse.on_connected()
|
||||
|
||||
34
python-backend/musicmouse/tippen/__init__.py
Normal file
34
python-backend/musicmouse/tippen/__init__.py
Normal 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",
|
||||
]
|
||||
336
python-backend/musicmouse/tippen/curriculum.py
Normal file
336
python-backend/musicmouse/tippen/curriculum.py
Normal 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))
|
||||
330
python-backend/musicmouse/tippen/progress.py
Normal file
330
python-backend/musicmouse/tippen/progress.py
Normal 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,
|
||||
)
|
||||
247
python-backend/musicmouse/tippen/rewards.py
Normal file
247
python-backend/musicmouse/tippen/rewards.py
Normal 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)
|
||||
34
python-backend/musicmouse/tippen/runtime.py
Normal file
34
python-backend/musicmouse/tippen/runtime.py
Normal 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
|
||||
)
|
||||
247
python-backend/tests/test_tippen_api.py
Normal file
247
python-backend/tests/test_tippen_api.py
Normal 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*")) == []
|
||||
152
python-backend/tests/test_tippen_curriculum.py
Normal file
152
python-backend/tests/test_tippen_curriculum.py
Normal 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
|
||||
229
python-backend/tests/test_tippen_progress.py
Normal file
229
python-backend/tests/test_tippen_progress.py
Normal 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
|
||||
219
python-backend/tests/test_tippen_rewards.py
Normal file
219
python-backend/tests/test_tippen_rewards.py
Normal 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]
|
||||
450
python-backend/tippen-curriculum.yml.example
Normal file
450
python-backend/tippen-curriculum.yml.example
Normal 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."
|
||||
Reference in New Issue
Block a user