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>
186 lines
6.4 KiB
Python
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,
|
|
)
|