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

@@ -0,0 +1,229 @@
"""Typing-progress persistence and the ``record_run`` bookkeeping it drives."""
from __future__ import annotations
from pathlib import Path
from musicmouse.tippen.curriculum import Curriculum, Lesson, World
from musicmouse.tippen.progress import (
DILIGENCE_ATTEMPTS,
RunResult,
Stroke,
fresh_progress,
load_progress,
record_run,
save_progress,
)
def _lesson(id: str, world: int, number: int, **overrides: object) -> Lesson:
base: dict = {
"id": id,
"world": world,
"number": number,
"title": id,
"subtitle": "",
"kind": "letters",
"new_keys": (),
"spotlight_keys": (),
"emphasis": None,
"active_keys": (),
"primary_mode": "dive",
"bonus_modes": (),
"words": (),
"is_drill": False,
"chunks": 1,
"chunk_size": 1,
"unlocks": None,
}
base.update(overrides)
return Lesson(**base) # type: ignore[arg-type]
def _curriculum() -> Curriculum:
lessons = (
_lesson("l01", 1, 1),
_lesson("l02", 1, 2, unlocks="Musik/Album/00.mp3"),
_lesson("l03", 2, 1),
)
worlds = (
World(number=1, title="Riff", emoji="🐠", reward="clownfish"),
World(number=2, title="Höhle", emoji="🐙", reward="octopus"),
)
return Curriculum(worlds=worlds, lessons=lessons)
def _result(*, stars: int, passed: bool, points: float = 10.0, pearls: int = 3) -> RunResult:
return RunResult(
stars=stars, # type: ignore[arg-type]
animal="fish",
points=points,
passed=passed,
pearls=pearls,
strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),),
)
# ------------------------------------------------------------------------ persistence
def test_fresh_progress_unlocks_only_the_first_lesson() -> None:
progress = fresh_progress(_curriculum())
assert progress.lessons["l01"].unlocked is True
assert progress.lessons["l02"].unlocked is False
assert progress.lessons["l03"].unlocked is False
def test_a_missing_file_is_a_fresh_start(tmp_path: Path) -> None:
progress = load_progress(tmp_path / "no-such-file.json", _curriculum())
assert progress == fresh_progress(_curriculum())
def test_a_corrupt_file_is_a_fresh_start_not_a_crash(tmp_path: Path) -> None:
path = tmp_path / "progress.json"
path.write_text("{not valid json", encoding="utf-8")
progress = load_progress(path, _curriculum())
assert progress == fresh_progress(_curriculum())
def test_save_then_load_round_trips(tmp_path: Path) -> None:
path = tmp_path / "progress.json"
curriculum = _curriculum()
progress = fresh_progress(curriculum)
outcome = record_run(
progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-01"
)
save_progress(path, outcome.progress)
reloaded = load_progress(path, curriculum)
assert reloaded.lessons["l01"].best_stars == 3
assert reloaded.lessons["l02"].unlocked is True
assert reloaded.pearls == 3
# Atomic write leaves no temp file behind.
assert list(tmp_path.glob("*.tmp*")) == []
def test_a_lesson_added_to_the_curriculum_since_the_last_save_gets_an_entry(tmp_path: Path) -> None:
path = tmp_path / "progress.json"
small = Curriculum(
worlds=(World(number=1, title="Riff", emoji="🐠", reward="clownfish"),),
lessons=(_lesson("l01", 1, 1),),
)
save_progress(path, fresh_progress(small))
grown = _curriculum()
reloaded = load_progress(path, grown)
assert "l02" in reloaded.lessons
assert "l03" in reloaded.lessons
# --------------------------------------------------------------------------- earning
def test_two_stars_unlocks_the_next_lesson() -> None:
curriculum = _curriculum()
progress = fresh_progress(curriculum)
outcome = record_run(
progress, "l01", _result(stars=2, passed=True), curriculum, day="2026-01-01"
)
assert outcome.newly_earned is True
assert outcome.unlocked_lesson_id == "l02"
assert outcome.progress.lessons["l02"].unlocked is True
def test_one_star_does_not_unlock_the_next_lesson() -> None:
curriculum = _curriculum()
progress = fresh_progress(curriculum)
outcome = record_run(
progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-01"
)
assert outcome.newly_earned is False
assert outcome.unlocked_lesson_id is None
def test_the_diligence_attempt_unlocks_regardless_of_score() -> None:
curriculum = _curriculum()
progress = fresh_progress(curriculum)
for _ in range(DILIGENCE_ATTEMPTS - 1):
outcome = record_run(
progress, "l01", _result(stars=0, passed=False), curriculum, day="2026-01-01"
)
progress = outcome.progress
assert outcome.newly_earned is False
outcome = record_run(
progress, "l01", _result(stars=0, passed=False), curriculum, day="2026-01-01"
)
assert outcome.progress.lessons["l01"].runs == DILIGENCE_ATTEMPTS
assert outcome.newly_earned is True
assert outcome.unlocked_lesson_id == "l02"
def test_a_replay_after_already_earned_does_not_re_unlock() -> None:
curriculum = _curriculum()
progress = fresh_progress(curriculum)
first = record_run(progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-01")
assert first.unlocked_lesson_id == "l02"
again = record_run(
first.progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-02"
)
assert again.newly_earned is False
assert again.unlocked_lesson_id is None
def test_best_score_only_improves() -> None:
curriculum = _curriculum()
progress = fresh_progress(curriculum)
good = record_run(
progress, "l01", _result(stars=3, passed=True, points=100), curriculum, day="2026-01-01"
)
worse = record_run(
good.progress, "l01", _result(stars=1, passed=False, points=5), curriculum, day="2026-01-02"
)
assert worse.progress.lessons["l01"].best_stars == 3
assert worse.progress.lessons["l01"].best_points == 100
assert worse.is_new_best is False
def test_finishing_a_world_awards_its_creature_exactly_once() -> None:
curriculum = _curriculum()
progress = fresh_progress(curriculum)
# Pass l01 (does not cross a world boundary: l02 is still world 1).
step1 = record_run(progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-01")
assert step1.new_creature is None
# Pass l02: the next lesson (l03) is world 2, so this crosses the boundary.
step2 = record_run(
step1.progress, "l02", _result(stars=3, passed=True), curriculum, day="2026-01-02"
)
assert step2.new_creature == "clownfish"
assert step2.progress.aquarium == ["clownfish"]
# Replaying l02 after l03 is already unlocked must never award it twice.
step3 = record_run(
step2.progress, "l02", _result(stars=3, passed=True), curriculum, day="2026-01-03"
)
assert step3.new_creature is None
assert step3.progress.aquarium == ["clownfish"]
def test_pearls_and_streak_accumulate() -> None:
curriculum = _curriculum()
progress = fresh_progress(curriculum)
day1 = record_run(
progress, "l01", _result(stars=1, passed=False, pearls=3), curriculum, day="2026-01-01"
)
day2 = record_run(
day1.progress, "l01", _result(stars=1, passed=False, pearls=4), curriculum, day="2026-01-02"
)
assert day2.progress.pearls == 7
assert day2.progress.streak.days == 2
assert day2.progress.streak.last_played == "2026-01-02"
# A missed day (skip to 2026-01-05) restarts the streak at 1, not 0.
day5 = record_run(
day2.progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-05"
)
assert day5.progress.streak.days == 1