Files
musicmouse/python-backend/musicmouse/tippen/rewards.py
Martin Bauer f7a5d24d8d 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>
2026-09-12 21:03:45 +02:00

248 lines
9.0 KiB
Python

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