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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,187 @@
"""Converting between the typing game's domain objects and what the browser sees.
Same job as ``remote_settings.py`` for the reward mapping: resolving a configured
target against the live library. Unlike ``remote_settings.py``, there is no write-back
half here - the curriculum is a parent-edited file, never PUT by the app.
"""
from __future__ import annotations
from typing import Literal
from musicmouse.app import App
from musicmouse.library import MusicLibrary
from musicmouse.services.web.schemas import (
TippenCurriculumOut,
TippenGhostStrokeOut,
TippenKeyStatOut,
TippenLessonOut,
TippenLessonProgressOut,
TippenProgressOut,
TippenRewardOut,
TippenRunIn,
TippenRunOut,
TippenSettingsOut,
TippenStreakOut,
TippenUnlockedRewardOut,
TippenWorldOut,
)
from musicmouse.tippen.curriculum import Curriculum, Lesson, lesson_by_id
from musicmouse.tippen.progress import RunResult, Stroke, TypingProgress, record_run
from musicmouse.tippen.rewards import resolve_lesson_reward
from musicmouse.tippen.runtime import TippenRuntime
__all__ = ["curriculum_out", "progress_out", "record_tippen_run"]
def _reward_out(lesson: Lesson, library: MusicLibrary) -> TippenRewardOut:
reward = resolve_lesson_reward(lesson, library)
if reward is None:
return TippenRewardOut(resolved=False)
album = library.get(reward.album_id)
return TippenRewardOut(
resolved=True,
album_id=reward.album_id,
has_cover=album.cover is not None if album else False,
kind=reward.kind,
)
def curriculum_out(curriculum: Curriculum, library: MusicLibrary) -> TippenCurriculumOut:
return TippenCurriculumOut(
worlds=[
TippenWorldOut(
number=world.number, title=world.title, emoji=world.emoji, reward=world.reward
)
for world in curriculum.worlds
],
lessons=[
TippenLessonOut(
id=lesson.id,
world=lesson.world,
number=lesson.number,
title=lesson.title,
subtitle=lesson.subtitle,
kind=lesson.kind,
new_keys=list(lesson.new_keys),
spotlight_keys=list(lesson.spotlight_keys),
emphasis=lesson.emphasis,
active_keys=list(lesson.active_keys),
primary_mode=lesson.primary_mode,
bonus_modes=list(lesson.bonus_modes),
words=list(lesson.words),
is_drill=lesson.is_drill,
chunks=lesson.chunks,
chunk_size=lesson.chunk_size,
reward=_reward_out(lesson, library),
)
for lesson in curriculum.lessons
],
)
def progress_out(progress: TypingProgress) -> TippenProgressOut:
return TippenProgressOut(
lessons={
lesson_id: TippenLessonProgressOut(
unlocked=entry.unlocked,
runs=entry.runs,
best_stars=entry.best_stars,
best_animal=entry.best_animal,
best_points=entry.best_points,
earned=entry.earned,
ghost=(
[TippenGhostStrokeOut(key=g.key, at=g.at) for g in entry.ghost]
if entry.ghost
else None
),
)
for lesson_id, entry in progress.lessons.items()
},
key_stats={
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
for key, stat in progress.key_stats.items()
},
pearls=progress.pearls,
aquarium=list(progress.aquarium),
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
settings=TippenSettingsOut(
sound=progress.settings.sound, keyboard_hint=progress.settings.keyboard_hint
),
)
def _animation_kind(
library: MusicLibrary, album_id: str, reward_kind: Literal["tracks", "episode"]
) -> Literal["album", "book", "podcast_episode"]:
if reward_kind == "episode":
return "podcast_episode"
album = library.get(album_id)
return "book" if album is not None and album.kind == "book" else "album"
def _unlocked_reward_out(
runtime: TippenRuntime, library: MusicLibrary, lesson_id: str
) -> TippenUnlockedRewardOut | None:
"""The literal target of `lesson_id`'s own ``unlocks:`` - what the unlock
animation shows. Not "one of several" tracks: exactly the one the lesson names."""
lesson = lesson_by_id(runtime.curriculum, lesson_id)
if lesson is None:
return None
reward = resolve_lesson_reward(lesson, library)
if reward is None:
return None
album = library.get(reward.album_id)
if album is None:
return None
if reward.kind == "episode":
title = album.title
elif reward.until_index < len(album.tracks):
title = album.tracks[reward.until_index].title
else:
title = album.title
return TippenUnlockedRewardOut(
album_id=reward.album_id,
title=title,
has_cover=album.cover is not None,
kind=_animation_kind(library, reward.album_id, reward.kind),
)
def record_tippen_run(app: App, body: TippenRunIn) -> TippenRunOut:
"""Grade-agnostic: the client already graded the run (see ``TippenRunIn``); this
only owns what happens to progress, and whether it just revealed a reward."""
assert app.tippen is not None
runtime = app.tippen
result = RunResult(
stars=body.stars,
animal=body.animal,
points=body.points,
passed=body.passed,
pearls=body.pearls,
strokes=tuple(
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
),
)
outcome = record_run(runtime.progress, body.lesson_id, result, runtime.curriculum)
runtime.progress = outcome.progress
runtime.save()
unlocked_reward = (
_unlocked_reward_out(runtime, app.library, body.lesson_id) if outcome.newly_earned else None
)
unlocked_lesson = (
lesson_by_id(runtime.curriculum, outcome.unlocked_lesson_id)
if outcome.unlocked_lesson_id
else None
)
return TippenRunOut(
progress=progress_out(runtime.progress),
unlocked_lesson_id=outcome.unlocked_lesson_id,
unlocked_lesson_title=unlocked_lesson.title if unlocked_lesson else None,
new_creature=outcome.new_creature,
is_new_best=outcome.is_new_best,
unlocked_reward=unlocked_reward,
)