Files
musicmouse/python-backend/musicmouse/services/web/tippen_api.py
Martin Bauer b6342cc117 Drop the pearls currency
Pearls were earned every run and spent on nothing: the aquarium fills up by
finishing worlds, not by paying for it. A counter that only ever goes up is one
more stat competing for attention on the result sheet and the home screen, and
one more field to carry through the run payload, the progress file and both test
suites.

Stars and the animal ladder already say how a run went, so nothing is lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:08 +02:00

186 lines
6.4 KiB
Python

"""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()
},
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,
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,
)