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