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

@@ -40,6 +40,8 @@ from musicmouse.services.lirc import LircService
from musicmouse.services.mqtt import MqttService, build_entities
from musicmouse.services.podcasts import PodcastFeedService
from musicmouse.services.web import WebService
from musicmouse.tippen.curriculum import CurriculumError
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
_log = logging.getLogger("musicmouse")
@@ -100,10 +102,20 @@ def main(argv: list[str] | None = None) -> int:
print(f"error: {exc}", file=sys.stderr)
return 2
tippen: TippenRuntime | None = None
if config.general.tippen is not None:
try:
tippen = build_tippen_runtime(config.general.tippen)
except CurriculumError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
runner = (
run_simulated(config, args.config, args.script)
run_simulated(config, args.config, args.script, tippen=tippen)
if args.simulate
else run_real(config, args.config, hardware=wants_hardware(config, args.no_hardware))
else run_real(
config, args.config, hardware=wants_hardware(config, args.no_hardware), tippen=tippen
)
)
try:
asyncio.run(runner)
@@ -115,7 +127,13 @@ def main(argv: list[str] | None = None) -> int:
# ------------------------------------------------------------------------- real
async def run_real(config: Config, config_path: Path, *, hardware: bool = True) -> None:
async def run_real(
config: Config,
config_path: Path,
*,
hardware: bool = True,
tippen: TippenRuntime | None = None,
) -> None:
bus = EventBus()
await bus.start()
clock = RealClock()
@@ -137,7 +155,7 @@ async def run_real(config: Config, config_path: Path, *, hardware: bool = True)
player = _build_player(bus, general, clock=clock)
library = await build_library(config)
app = _build_app(config, bus, mouse, player, library, clock=clock)
app = _build_app(config, bus, mouse, player, library, clock=clock, tippen=tippen)
services = _build_services(app, mouse, player, clock=clock, config_path=config_path)
_log.info(
@@ -176,7 +194,9 @@ async def run_real(config: Config, config_path: Path, *, hardware: bool = True)
# -------------------------------------------------------------------- simulated
async def run_simulated(config: Config, config_path: Path, script: Path | None) -> None:
async def run_simulated(
config: Config, config_path: Path, script: Path | None, *, tippen: TippenRuntime | None = None
) -> None:
# Imported here so the production path never touches the simulator.
from musicmouse.simulator.harness import build_simulation
from musicmouse.simulator.repl import run_repl
@@ -185,7 +205,7 @@ async def run_simulated(config: Config, config_path: Path, script: Path | None)
# A script runs on virtual time, so `wait 1s` is instant. The prompt runs on the
# real clock, so playback ticks along while you watch it.
sim = await build_simulation(
config, clock=RealClock() if script is None else None, track_duration=5.0
config, clock=RealClock() if script is None else None, track_duration=5.0, tippen=tippen
)
services = _build_services(
@@ -280,6 +300,7 @@ def _build_app(
library: MusicLibrary,
*,
clock: RealClock,
tippen: TippenRuntime | None = None,
) -> App:
app = App(
config=config,
@@ -292,6 +313,7 @@ def _build_app(
# identity check.
playlists=library.figure_playlists(),
clock=clock,
tippen=tippen,
)
register_all(bus, app)
return app