10 Commits

Author SHA1 Message Date
dd14f5d901 Add a script to seed a device's library and analysis cache
Copying the library to a Pi is one rsync; copying the cache with it is not,
because its three parts travel differently. covers/ is keyed by the album
folder's path relative to the library root, so it survives the move. analysis/
is keyed by name:size:mtime, so it survives only if mtimes do - hence -a
everywhere, and a comment saying why. index.json holds absolute source paths and
must not be copied at all: scan_library() reuses a cached album on a matching
(name, size, mtime) fingerprint without re-checking the path, so copying it
leaves every album pointing at the dev machine and playback fails on files that
are present. It is the cheap part, and the device rebuilds it on first scan
while reusing everything expensive.

Syncs shelf by shelf rather than the whole directory, which is what makes
--delete safe: config.yml, tippen-curriculum.yml, tippen-progress.json and the
cache all live in the same directory on the device and have no counterpart here.
Preflight checks ssh, sizes the transfer and refuses to fill the SD card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:58:50 +02:00
274085b92e Stop tracking the library cache
python-backend/.gitignore lists /.musicmouse-cache, but index.json and seven
cover JPGs were committed before that ignore existed, so the ignore never
applied to them. index.json is a 2 MB machine-specific blob holding 6606
absolute /home/martin/... paths, rewritten on every app start - it showed up as
modified in git status permanently, and a deploy checkout would have shipped a
dev machine's index to the device.

The files stay on disk; only the tracking goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:26:45 +02:00
f3337975c2 Celebrate a media unlock with a treasure chest that opens
The reward pipeline worked end to end already, but it had nothing to show for
itself: the unlock was one more line at the bottom of the result sheet, a 52px
thumbnail with the generic 520ms pop, below the stars, the stats, the progress
bar, the animal ladder and three other badges. The sound was `playFanfare`, the
same chirp used for a new lesson and a new aquarium pet. It fired correctly and
was impossible to notice.

This is the only reward that reaches outside the game, so it now gets the whole
screen. A chest drops in shut and rattles, the lid swings open on a burst of
light and confetti, the cover art rises out of it, and the tune is a real melody
- two seconds landing on a held major chord - rather than another blip. It is
dismissed by hand, so she can look at what she won for as long as she likes.

On the map, the 15px 🎁 becomes a drawn chest, shut while the reward is unwon
and open with the cover inside once it has been. It is rendered as a sibling of
the lesson node rather than a child, because a locked node is dimmed to 45% and
the chest that most needs to be bright is the one three worlds away.

One real bug behind the missing badge state: `progressFromApi` dropped the
`earned` flag the backend already sends, so the map could not tell a claimed
reward from an unclaimed one. Added, with a test that names it - a hand-written
field mapping loses fields without failing a type check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:10:11 +02:00
e6f6b15cc7 Make the lesson map the floor screen of the typing tab
The aquarium was a separate landing screen in front of the map, showing the pets
collected so far and a "Weiter üben" button. But the pets already swim behind
every screen in the tab, so the screen mostly restated what was visible anyway,
at the cost of one extra step between opening the tab and typing.

The map absorbs what was worth keeping: the "Weiter üben" button now floats over
it, and the pets, streak and best animal move into the header. One Escape from
the map leaves the tab entirely, where it used to go back a screen first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:08:11 +02:00
f3f7082fb8 Draw locked covers as artwork instead of a question mark
A reward-gated album showed a bare  over the generated gradient, which reads as
"something is broken" rather than "something is waiting". It is now a proper
placeholder cover, with a separate one for audiobooks and podcasts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:19 +02:00
36e93be79b Let a lesson list review keys beside the two it introduces
A lesson's `keys` was capped at two outright, which made it impossible to write
"K, with the four keys before it still in play". The cap now counts only what is
*new*; keys already taught can be listed freely, and the spotlight falls on the
new ones alone, so a lesson titled "K" drills K instead of spreading itself
evenly over all five keys it names. A round that introduces nothing new still
spotlights its whole list - that is the "mixed" replay, unchanged.

Also documents `unlocks:` paths as relative to the library root. Absolute and
"~" paths still resolve, so an existing file keeps working, but a relative path
survives moving the library.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:19 +02:00
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
fd6283718c Analyse tracks in a pool of worker processes
A first pass over an unanalysed library is hours of librosa, and there was no
reason for a desktop to spend them one core at a time. Analysis now runs in a
process pool sized by `general.library.analysis_workers`, defaulting to one per
core bar one (capped at 8) when the key is absent.

Two consequences worth knowing before touching this: whatever an `Analyzer`
returns has to be picklable, and an analyzer that keeps state on its own
instance - a test double counting calls - only behaves as written with
`analysis_workers=1`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:04:01 +02:00
75b0eed080 Remove the Quallenalarm (jellyfish) typing minigame
Letters-round lessons only had bubbles and jellyfish as eligible arcade
modes; with jellyfish gone, bubbles is now always used instead of the
two alternating - no lessons need to be dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:38:19 +02:00
a69db98241 Use a plain text note glyph for the music tab, not the emoji
Setting color explicitly wasn't enough: the 🎵 emoji has its own
baked-in colour (a muted grey-blue on at least one real platform) and
simply ignores the CSS color property, unlike a plain Unicode symbol.
Swapped to the bare eighth-note character (♪), which renders as text
and finally responds to the color already set on the button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:29:18 +02:00
48 changed files with 1876 additions and 578 deletions

View File

@@ -123,3 +123,7 @@ reported at once.
and size, so **a change to how the scanner derives a title, artist or series is and size, so **a change to how the scanner derives a title, artist or series is
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
`musicmouse/library/cache.py` when you touch that logic. `musicmouse/library/cache.py` when you touch that logic.
- Track analysis (librosa) runs in a pool of worker *processes* - see
`musicmouse/library/workers.py`. Anything an `Analyzer` returns therefore has to be
picklable, and an analyzer that records state in its own instance (a test double
counting calls) only behaves as written with `analysis_workers=1`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 KiB

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,12 @@ general:
# is rebuilt on the next start. Deleting it does throw away track analysis, which # is rebuilt on the next start. Deleting it does throw away track analysis, which
# is expensive to recompute. # is expensive to recompute.
cache: .musicmouse-cache cache: .musicmouse-cache
# How many tracks the background analyzer may work on at once, each in its own
# worker process. Omitted means one per core bar one (capped at 8), which is what
# turns a first-time pass over a whole library from an overnight job into a coffee
# break on a desktop. Set it to 1 on a machine that has better things to do, or to
# a specific number to cap how much of it analysis may take.
# analysis_workers: 4
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal. # Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
# Required - use "simulate" to run without the mouse attached, which is a complete # Required - use "simulate" to run without the mouse attached, which is a complete

View File

@@ -32,7 +32,7 @@ from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.null_transport import NullTransport from musicmouse.devices.null_transport import NullTransport
from musicmouse.devices.player import Player, VlcPlayer from musicmouse.devices.player import Player, VlcPlayer
from musicmouse.devices.serial_link import SerialLink from musicmouse.devices.serial_link import SerialLink
from musicmouse.library import MusicLibrary from musicmouse.library import MusicLibrary, default_worker_count
from musicmouse.library.analysis import build_analyzer from musicmouse.library.analysis import build_analyzer
from musicmouse.reactions import register_all from musicmouse.reactions import register_all
from musicmouse.services.base import Service from musicmouse.services.base import Service
@@ -283,11 +283,16 @@ def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) ->
async def build_library(config: Config) -> MusicLibrary: async def build_library(config: Config) -> MusicLibrary:
library_config = config.general.library library_config = config.general.library
workers = library_config.analysis_workers
return await MusicLibrary.build( return await MusicLibrary.build(
library_config.root, library_config.root,
library_config.cache, library_config.cache,
frozenset(config.general.audio_extensions), frozenset(config.general.audio_extensions),
analyzer=build_analyzer(), analyzer=build_analyzer(),
# Unset in the config means "use the machine": a first pass over an unanalyzed
# library is hours of DSP, and there is no reason for a desktop to do it one
# core at a time. `MusicLibrary` itself defaults to 1 - see its docstring.
analysis_workers=default_worker_count() if workers is None else workers,
figure_kinds=config.figure_kinds, figure_kinds=config.figure_kinds,
) )

View File

@@ -172,6 +172,11 @@ class LibraryConfig(_Strict):
root: Path root: Path
#: Scan results, extracted cover art and track analysis. Relative to this file. #: Scan results, extracted cover art and track analysis. Relative to this file.
cache: Path = Path(".musicmouse-cache") cache: Path = Path(".musicmouse-cache")
#: How many tracks background analysis may work on at once, each in its own worker
#: process. Omit for one per core bar one (see
#: :func:`musicmouse.library.workers.default_worker_count`); set it to 1 to keep
#: analysis to a single process on a machine that has other work to do.
analysis_workers: int | None = Field(default=None, ge=1)
@field_validator("root") @field_validator("root")
@classmethod @classmethod

View File

@@ -8,14 +8,14 @@ plain immutable data.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
import logging import logging
import os
import time import time
from collections import deque
from collections.abc import Awaitable, Callable, Collection, Mapping from collections.abc import Awaitable, Callable, Collection, Mapping
from concurrent.futures import BrokenExecutor, Executor
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
from typing import Final from typing import Any, Final
from musicmouse.library.analysis import ( from musicmouse.library.analysis import (
ANALYZER_VERSION, ANALYZER_VERSION,
@@ -29,6 +29,7 @@ from musicmouse.library.cache import Fingerprint, LibraryCache
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
from musicmouse.library.scanner import scan_library from musicmouse.library.scanner import scan_library
from musicmouse.library.sections import SECTIONS, AlbumKind from musicmouse.library.sections import SECTIONS, AlbumKind
from musicmouse.library.workers import analysis_pool, analyze_one, default_worker_count
from musicmouse.media import Playlist from musicmouse.media import Playlist
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -45,6 +46,7 @@ __all__ = [
"NullAnalyzer", "NullAnalyzer",
"TrackCurves", "TrackCurves",
"album_id", "album_id",
"default_worker_count",
"track_key", "track_key",
] ]
@@ -69,19 +71,12 @@ _PUBLISH_BATCH_SIZE: Final = 25
_PROGRESS_INTERVAL_SECONDS: Final = 5.0 _PROGRESS_INTERVAL_SECONDS: Final = 5.0
def _analyze_one( def _discard(future: asyncio.Future[Any]) -> None:
analyzer: Analyzer, path: Path """Drop a result nobody is going to read, without leaving a warning behind."""
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]: if not future.done():
"""Runs in a worker thread. Lowers this thread's own scheduling priority first. future.cancel()
elif not future.cancelled():
On Linux, ``os.nice`` affects only the calling thread, not the whole process - so future.exception()
this makes idle-time analysis yield CPU to anything else without touching threads
used for other work. Niceness only ever increases and clamps at the OS maximum
(19), so calling this repeatedly on a reused pool thread is harmless.
"""
with contextlib.suppress(OSError):
os.nice(1)
return analyzer.analyze(path)
class MusicLibrary: class MusicLibrary:
@@ -94,12 +89,19 @@ class MusicLibrary:
extensions: frozenset[str], extensions: frozenset[str],
*, *,
analyzer: Analyzer | None = None, analyzer: Analyzer | None = None,
analysis_workers: int = 1,
figure_kinds: Mapping[str, AlbumKind] | None = None, figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> None: ) -> None:
self.root = root self.root = root
self.cache = cache self.cache = cache
self.extensions = extensions self.extensions = extensions
self.analyzer: Analyzer = analyzer or NullAnalyzer() self.analyzer: Analyzer = analyzer or NullAnalyzer()
#: How many tracks background analysis may work on at once. The default of 1
#: keeps the analyzer in this process, where an analyzer that holds state
#: still behaves as written; the app passes `default_worker_count()`, which is
#: what makes a first-time pass finish in hours rather than days on a desktop.
#: See `musicmouse.library.workers`.
self.analysis_workers = analysis_workers
#: What each figure holds. The only thing a folder name cannot say. #: What each figure holds. The only thing a folder name cannot say.
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {} self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
self._entries: dict[str, tuple[Album, Fingerprint]] = {} self._entries: dict[str, tuple[Album, Fingerprint]] = {}
@@ -146,9 +148,7 @@ class MusicLibrary:
] ]
if not candidates: if not candidates:
return None return None
return max( return max(candidates, key=lambda album: album.tracks[0].path.name if album.tracks else "")
candidates, key=lambda album: album.tracks[0].path.name if album.tracks else ""
)
def beats(self, identifier: str, index: int) -> BeatGrid | None: def beats(self, identifier: str, index: int) -> BeatGrid | None:
album = self.get(identifier) album = self.get(identifier)
@@ -219,10 +219,18 @@ class MusicLibrary:
extensions: frozenset[str], extensions: frozenset[str],
*, *,
analyzer: Analyzer | None = None, analyzer: Analyzer | None = None,
analysis_workers: int = 1,
figure_kinds: Mapping[str, AlbumKind] | None = None, figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> MusicLibrary: ) -> MusicLibrary:
cache = LibraryCache(cache_dir) cache = LibraryCache(cache_dir)
library = cls(root, cache, extensions, analyzer=analyzer, figure_kinds=figure_kinds) library = cls(
root,
cache,
extensions,
analyzer=analyzer,
analysis_workers=analysis_workers,
figure_kinds=figure_kinds,
)
library._entries = await asyncio.to_thread(cache.load_index) library._entries = await asyncio.to_thread(cache.load_index)
await library.refresh() await library.refresh()
return library return library
@@ -261,64 +269,145 @@ class MusicLibrary:
is_busy: Callable[[], bool] = lambda: False, is_busy: Callable[[], bool] = lambda: False,
on_batch: Callable[[], Awaitable[None]] | None = None, on_batch: Callable[[], Awaitable[None]] | None = None,
batch_size: int = _PUBLISH_BATCH_SIZE, batch_size: int = _PUBLISH_BATCH_SIZE,
workers: int | None = None,
) -> int: ) -> int:
"""Run the analyzer over tracks of `kinds` that have no current result. """Run the analyzer over tracks of `kinds` that have no current result.
Restricted to music by default - see `_ANALYZED_KINDS`. Checked before every Restricted to music by default - see `_ANALYZED_KINDS`. Up to `workers` tracks
track, `is_busy()` pauses the whole pass rather than one file: analysis must are analyzed at once (`self.analysis_workers` when not given), each in its own
never compete with audio decoding for CPU, and a children's player is idle most worker process - see `musicmouse.library.workers` for why processes and how the
of the day, so the pass simply resumes next time it is. A track the analyzer machine is kept usable while they run. Checked before every track is handed out,
fails on (corrupt file, DRM, zero length) is still recorded as attempted - with `is_busy()` pauses the whole pass rather than one file: analysis must never
every scalar left `None` - so it is never retried forever and the frontend falls compete with audio decoding for CPU, and a children's player is idle most of the
back to the un-analyzed baseline for it. Results are folded into the live index day, so the pass simply resumes next time it is. Tracks already in flight when
and persisted every `batch_size` tracks, so a long first run is visible in open it goes busy are allowed to finish - their results are already paid for - and
browser tabs as it goes rather than only once it finishes. the workers are then shut down for the duration rather than sitting idle with
a librosa apiece resident on a machine that is now playing music.
A track the analyzer fails on (corrupt file, DRM, zero length) is still recorded
as attempted - with every scalar left `None` - so it is never retried forever and
the frontend falls back to the un-analyzed baseline for it. A worker *process*
dying, though, says nothing about the track it was on, so that ends the pass
without recording anything: the next one picks the same tracks up again.
Results are folded into the live index and persisted every `batch_size` tracks,
so a long first run is visible in open browser tabs as it goes rather than only
once it finishes.
""" """
analyzer = self.analyzer analyzer = self.analyzer
if analyzer.version < ANALYZER_VERSION: if analyzer.version < ANALYZER_VERSION:
return 0 return 0
pending = deque(self._pending_tracks(kinds, analyzer.version))
if not pending:
return 0
workers = self.analysis_workers if workers is None else max(1, workers)
total = len(pending)
_log.info("Analyzing %d tracks with %d worker(s)", total, workers)
loop = asyncio.get_running_loop()
in_flight: dict[asyncio.Future[Any], tuple[str, Path]] = {}
done = 0 done = 0
last_report = time.monotonic() last_report = time.monotonic()
for album in self.albums: try:
if album.kind not in kinds: while pending:
continue
for track in album.tracks:
while is_busy(): while is_busy():
# Waited out *between* pools, so a half-hour album is not played
# with a houseful of idle worker processes holding onto librosa.
await asyncio.sleep(_BUSY_POLL_SECONDS) await asyncio.sleep(_BUSY_POLL_SECONDS)
key = track_key(track.path) with analysis_pool(workers) as pool:
cached = self.cache.load_analysis(key) try:
if cached is not None and cached.version >= analyzer.version: while in_flight or (pending and not is_busy()):
continue while pending and len(in_flight) < workers and not is_busy():
now = time.monotonic() key, path = pending.popleft()
if now - last_report >= _PROGRESS_INTERVAL_SECONDS: in_flight[self._submit(pool, loop, path)] = (key, path)
_log.info("Analyzing library: %d tracks done so far, now on %s", done, track.path) if not in_flight:
last_report = now break # gone busy: drop the pool and wait above
try: finished, _ = await asyncio.wait(
analysis, grid, curve = await asyncio.to_thread( in_flight, return_when=asyncio.FIRST_COMPLETED
_analyze_one, analyzer, track.path )
) for future in finished:
except Exception: key, path = in_flight.pop(future)
_log.warning( self._store_result(key, path, future)
"Analyzer raised on %s; marking it attempted so it is not retried forever", done += 1
track.path, if done % batch_size == 0:
exc_info=True, await self._publish_analysis(kinds, on_batch)
) now = time.monotonic()
analysis, grid, curve = TrackAnalysis(version=analyzer.version), None, None if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
if grid is not None: _log.info("Analyzing library: %d/%d tracks done", done, total)
self.cache.store_beats(key, grid) last_report = now
if curve is not None: finally:
self.cache.store_curve(key, curve) # Nothing is left to read these - without this, a pass that ends
self.cache.store_analysis(key, analysis) # early (cancelled at shutdown, or a dead pool) leaves "exception
done += 1 # was never retrieved" behind for every track still in flight.
if done % batch_size == 0: for future in in_flight:
await self._publish_analysis(kinds, on_batch) _discard(future)
in_flight.clear()
except BrokenExecutor:
_log.error(
"An analysis worker process died (out of memory?) after %d of %d tracks; "
"stopping this pass. The rest are retried on the next one.",
done,
total,
)
if done % batch_size: if done % batch_size:
await self._publish_analysis(kinds, on_batch) await self._publish_analysis(kinds, on_batch)
if done: if done:
_log.info("Analyzed %d tracks", done) _log.info("Analyzed %d tracks", done)
return done return done
def _pending_tracks(self, kinds: Collection[AlbumKind], version: int) -> list[tuple[str, Path]]:
"""The whole pass's worklist, as (cache key, path), worked out up front.
Up front rather than per track, because a pool has to have the next file ready
the moment a worker frees up, and because it is what lets the progress line say
"37/412". Deduplicated by cache key: the same file can sit in two albums, and
two workers analyzing it at once would be pure waste.
"""
worklist: list[tuple[str, Path]] = []
seen: set[str] = set()
for album in self.albums:
if album.kind not in kinds:
continue
for track in album.tracks:
key = track_key(track.path)
if key in seen:
continue
seen.add(key)
cached = self.cache.load_analysis(key)
if cached is not None and cached.version >= version:
continue
worklist.append((key, track.path))
return worklist
def _submit(
self, pool: Executor | None, loop: asyncio.AbstractEventLoop, path: Path
) -> asyncio.Future[Any]:
"""Start one track, in a worker process or - with no pool - on a thread here."""
if pool is None:
return asyncio.ensure_future(asyncio.to_thread(analyze_one, self.analyzer, path))
return loop.run_in_executor(pool, analyze_one, self.analyzer, path)
def _store_result(self, key: str, path: Path, future: asyncio.Future[Any]) -> None:
"""Persist one finished track. Re-raises only what ends the whole pass."""
error = future.exception()
if isinstance(error, BrokenExecutor):
raise error
if error is not None:
_log.warning(
"Analyzer raised on %s; marking it attempted so it is not retried forever",
path,
exc_info=error,
)
analysis, grid, curve = TrackAnalysis(version=self.analyzer.version), None, None
else:
analysis, grid, curve = future.result()
if grid is not None:
self.cache.store_beats(key, grid)
if curve is not None:
self.cache.store_curve(key, curve)
self.cache.store_analysis(key, analysis)
async def _publish_analysis( async def _publish_analysis(
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
) -> None: ) -> None:

View File

@@ -0,0 +1,143 @@
"""Where analysis actually burns CPU, and how it is kept from taking the machine over.
Analyzing one track is a few seconds of single-threaded DSP that the GIL will not let
another Python thread overlap with - librosa's work is numba-jitted and numpy glue, not
long C calls that release it. So a library-sized pass parallelizes across *processes*:
:func:`analysis_pool` hands :meth:`~musicmouse.library.MusicLibrary.analyze_pending` an
executor whose workers are separate interpreters, and a 12-core desktop chews through a
first-time scan roughly an order of magnitude faster than the Raspberry Pi this also
has to stay polite on.
Polite means three things, all of them set here rather than at the call site:
* **One core stays free** (:func:`default_worker_count`), so the audio thread and the
web server never have to fight a full house of analyzers for a timeslice.
* **Workers run niced**, so even the cores they do own yield to playback instantly.
* **Each worker stays single-threaded** - numpy's BLAS and numba would each happily
start one thread per core *inside* every worker, and N x N threads on a 4-core Pi is
slower than N, not faster.
Whatever an analyzer returns has to survive the trip back from a worker process, which
is what keeps :class:`~musicmouse.library.analysis.TrackAnalysis` and friends plain
frozen dataclasses of floats. An analyzer that records state in its own instance -
a test double counting calls, say - only sees that state in the worker, so such an
analyzer must be run with ``workers=1``, where everything stays in this process on a
thread.
"""
from __future__ import annotations
import contextlib
import logging
import multiprocessing
import os
from collections.abc import Iterator
from concurrent.futures import Executor, ProcessPoolExecutor
from pathlib import Path
from typing import Final
from musicmouse.library.analysis import Analyzer, BeatGrid, TrackAnalysis, TrackCurves
_log = logging.getLogger(__name__)
__all__ = ["analysis_pool", "analyze_one", "default_worker_count"]
#: Ceiling on the automatic worker count. Every worker is a fresh interpreter with its
#: own librosa, numpy and a decoded track in memory - a few hundred MB each - so on a
#: big machine the limit that bites first is RAM, not cores. An explicit
#: ``analysis_workers`` in the config overrides this; the default stays conservative.
_MAX_AUTO_WORKERS: Final = 8
#: How much worse than everything else analysis schedules. Niceness clamps at the OS
#: maximum (19) and only ever increases, so re-applying it to a reused worker is
#: harmless.
_NICENESS: Final = 5
#: Forced into every worker *before* it imports numpy, which reads these once at import
#: time. Without them each worker opens its own BLAS thread pool sized for the whole
#: machine and the pool oversubscribes every core several times over.
_SINGLE_THREADED: Final = {
"OMP_NUM_THREADS": "1",
"OPENBLAS_NUM_THREADS": "1",
"MKL_NUM_THREADS": "1",
"NUMEXPR_NUM_THREADS": "1",
"NUMBA_NUM_THREADS": "1",
}
def default_worker_count() -> int:
"""One worker per core bar one, capped at :data:`_MAX_AUTO_WORKERS`.
The core left over is for the rest of the app: audio decoding, the web server and
the serial link all have to stay responsive while a first-time pass runs for hours.
Single-core machines get 1, which :func:`analysis_pool` turns into the in-process
path rather than a pool of one.
"""
return max(1, min(_MAX_AUTO_WORKERS, (os.cpu_count() or 1) - 1))
def analyze_one(
analyzer: Analyzer, path: Path
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
"""Analyze one file off the event loop - in a worker process, or on a thread here.
Lowers the caller's own scheduling priority first. On Linux ``os.nice`` affects only
the calling *thread*, so on the in-process path this makes idle-time analysis yield
CPU without touching threads doing other work; in a worker process there is nothing
else in the process to slow down anyway.
"""
with contextlib.suppress(OSError):
os.nice(_NICENESS)
return analyzer.analyze(path)
def _init_worker() -> None:
"""Runs once per worker process, before it imports librosa or numpy.
A spawned worker starts from a bare interpreter and pulls the analyzer in when it
unpickles its first task, so setting the thread-count variables here still lands
ahead of numpy reading them.
"""
os.environ.update(_SINGLE_THREADED)
with contextlib.suppress(OSError):
os.nice(_NICENESS)
@contextlib.contextmanager
def analysis_pool(workers: int) -> Iterator[Executor | None]:
"""A pool of `workers` analyzer processes, or ``None`` for "stay in this process".
``None`` - for ``workers <= 1``, and as the fallback when a pool cannot be started
at all - means the caller should run each track on a thread instead. That path is
what the tests and single-core devices use, and it is the only one where an
analyzer holding state in its own instance behaves as written.
Workers are *spawned*, never forked: this process has an asyncio loop, a serial
reader and libVLC's own threads running, and forking that is a well-known way to
inherit a held lock and deadlock in a child. The price is one librosa import per
worker, a few seconds paid once per pass - nothing next to the hours of DSP a
first-time pass over a real library costs.
"""
if workers <= 1:
yield None
return
try:
executor = ProcessPoolExecutor(
max_workers=workers,
mp_context=multiprocessing.get_context("spawn"),
initializer=_init_worker,
)
except (OSError, ValueError):
_log.warning(
"Could not start %d analysis workers; analyzing in this process instead",
workers,
exc_info=True,
)
yield None
return
try:
yield executor
finally:
# `wait=False`: shutdown happens on cancellation too (the app is stopping), and
# waiting there would hold it up for however long the tracks in flight take.
executor.shutdown(wait=False, cancel_futures=True)

View File

@@ -372,7 +372,6 @@ class TippenSettingsIn(BaseModel):
class TippenProgressOut(BaseModel): class TippenProgressOut(BaseModel):
lessons: dict[str, TippenLessonProgressOut] lessons: dict[str, TippenLessonProgressOut]
key_stats: dict[str, TippenKeyStatOut] key_stats: dict[str, TippenKeyStatOut]
pearls: int
aquarium: list[str] aquarium: list[str]
streak: TippenStreakOut streak: TippenStreakOut
settings: TippenSettingsOut settings: TippenSettingsOut
@@ -395,7 +394,6 @@ class TippenRunIn(BaseModel):
animal: AnimalId animal: AnimalId
points: float points: float
passed: bool passed: bool
pearls: int = Field(ge=0)
strokes: list[TippenStrokeIn] = Field(default_factory=list) strokes: list[TippenStrokeIn] = Field(default_factory=list)

View File

@@ -102,7 +102,6 @@ def progress_out(progress: TypingProgress) -> TippenProgressOut:
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors) key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
for key, stat in progress.key_stats.items() for key, stat in progress.key_stats.items()
}, },
pearls=progress.pearls,
aquarium=list(progress.aquarium), aquarium=list(progress.aquarium),
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played), streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
settings=TippenSettingsOut( settings=TippenSettingsOut(
@@ -159,7 +158,6 @@ def record_tippen_run(app: App, body: TippenRunIn) -> TippenRunOut:
animal=body.animal, animal=body.animal,
points=body.points, points=body.points,
passed=body.passed, passed=body.passed,
pearls=body.pearls,
strokes=tuple( strokes=tuple(
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
), ),

View File

@@ -38,7 +38,7 @@ __all__ = [
] ]
type LessonKind = Literal["letters", "fragments", "words", "sentences"] type LessonKind = Literal["letters", "fragments", "words", "sentences"]
type ModeId = Literal["dive", "bubbles", "jellyfish", "feed", "race"] type ModeId = Literal["dive", "bubbles", "feed", "race"]
type CreatureId = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"] type CreatureId = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
#: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is #: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is
@@ -52,9 +52,9 @@ CREATURE_IDS: Final[tuple[CreatureId, ...]] = (
) )
#: Which modes make sense for a kind - letters rounds are single keys, so only the #: Which modes make sense for a kind - letters rounds are single keys, so only the
#: arcade modes fit; only words and sentences are long enough for a race. #: arcade mode fits; only words and sentences are long enough for a race.
ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = { ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = {
"letters": ("bubbles", "jellyfish"), "letters": ("bubbles",),
"fragments": ("dive", "feed"), "fragments": ("dive", "feed"),
"words": ("dive", "feed", "race"), "words": ("dive", "feed", "race"),
"sentences": ("dive", "race"), "sentences": ("dive", "race"),
@@ -104,6 +104,10 @@ class _YamlRoot(_Strict):
problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}") problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}")
seen_rewards: set[CreatureId] = set() seen_rewards: set[CreatureId] = set()
# A lesson's `keys` is its spotlight, not only what is new: a round may list
# every key learned so far to drill them evenly. What must stay small is how
# much is *new* in one round - two keys, the mirrored finger pair.
seen_keys: set[str] = set()
for world in self.worlds: for world in self.worlds:
if world.reward in seen_rewards: if world.reward in seen_rewards:
problems.append(f"world {world.number} reuses reward {world.reward!r}") problems.append(f"world {world.number} reuses reward {world.reward!r}")
@@ -121,8 +125,10 @@ class _YamlRoot(_Strict):
problems.append(f'{where}: kind "{kind}" needs a non-empty words list') problems.append(f'{where}: kind "{kind}" needs a non-empty words list')
if lesson.drill and lesson.keys: if lesson.drill and lesson.keys:
problems.append(f"{where}: a drill cannot also introduce keys") problems.append(f"{where}: a drill cannot also introduce keys")
if lesson.keys and len(lesson.keys) > 2: new_keys = [key for key in (lesson.keys or ()) if key not in seen_keys]
problems.append(f"{where}: at most two keys per lesson") if len(new_keys) > 2:
problems.append(f"{where}: at most two new keys per lesson")
seen_keys.update(lesson.keys or ())
if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]: if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]:
problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"') problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"')
@@ -230,9 +236,6 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
lessons: list[Lesson] = [] lessons: list[Lesson] = []
active: set[str] = set() active: set[str] = set()
seen_before: set[str] = set() seen_before: set[str] = set()
# Letters rounds alternate bubbles/jellyfish across the whole course, so the arcade
# game never repeats twice in a row even across a fragments/words lesson in between.
last_arcade: ModeId = "jellyfish" # so lesson 1 opens on bubbles
for world in worlds: for world in worlds:
for entry in world.lessons: for entry in world.lessons:
@@ -260,16 +263,10 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
else: else:
emphasis = "mixed" emphasis = "mixed"
primary_mode: ModeId primary_mode: ModeId = entry.mode or ("bubbles" if kind == "letters" else "dive")
if kind == "letters":
default_arcade: ModeId = "jellyfish" if last_arcade == "bubbles" else "bubbles"
primary_mode = entry.mode or default_arcade
last_arcade = primary_mode
else:
primary_mode = entry.mode or "dive"
# Bonus replays are only ever feed/race - dive is the plain default, and # Bonus replays are only ever feed/race - dive is the plain default, and
# bubbles/jellyfish already alternate on their own. # bubbles is the only letters-round arcade mode.
bonus_modes = tuple( bonus_modes = tuple(
mode mode
for mode in ELIGIBLE_MODES[kind] for mode in ELIGIBLE_MODES[kind]
@@ -287,7 +284,13 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
subtitle=entry.subtitle, subtitle=entry.subtitle,
kind=kind, kind=kind,
new_keys=new_keys, new_keys=new_keys,
spotlight_keys=keys if kind == "letters" and not is_drill else (), # A round that lists every key learned so far still spotlights only
# what it introduces - a lesson titled "K" must drill K, not spread
# itself evenly over all five keys it happens to name. With nothing
# new, the whole list is the spotlight: that is the "mixed" replay.
spotlight_keys=(
(new_keys or keys) if kind == "letters" and not is_drill else ()
),
emphasis=emphasis, emphasis=emphasis,
active_keys=active_keys, active_keys=active_keys,
primary_mode=primary_mode, primary_mode=primary_mode,

View File

@@ -90,7 +90,6 @@ class RunResult:
animal: AnimalId animal: AnimalId
points: float points: float
passed: bool passed: bool
pearls: int
strokes: tuple[Stroke, ...] strokes: tuple[Stroke, ...]
@@ -143,7 +142,6 @@ class TypingProgress(BaseModel):
version: Literal[1] = 1 version: Literal[1] = 1
lessons: dict[str, LessonProgress] = Field(default_factory=dict) lessons: dict[str, LessonProgress] = Field(default_factory=dict)
key_stats: dict[str, KeyStat] = Field(default_factory=dict) key_stats: dict[str, KeyStat] = Field(default_factory=dict)
pearls: int = 0
#: Pets that have moved into the aquarium, in the order they arrived. #: Pets that have moved into the aquarium, in the order they arrived.
aquarium: list[CreatureId] = Field(default_factory=list) aquarium: list[CreatureId] = Field(default_factory=list)
streak: Streak = Field(default_factory=Streak) streak: Streak = Field(default_factory=Streak)
@@ -265,7 +263,7 @@ def record_run(
curriculum: Curriculum, curriculum: Curriculum,
day: str | None = None, day: str | None = None,
) -> RecordOutcome: ) -> RecordOutcome:
"""Record a finished run: stars, animal, pearls, key stats, streak, and the unlock. """Record a finished run: stars, animal, key stats, streak, and the unlock.
The unlock rule, in one place: two stars unlocks the next lesson, and so does the The unlock rule, in one place: two stars unlocks the next lesson, and so does the
fifth attempt whatever the score. Speed is nowhere in it. fifth attempt whatever the score. Speed is nowhere in it.
@@ -317,7 +315,6 @@ def record_run(
"lessons": lessons, "lessons": lessons,
"aquarium": aquarium, "aquarium": aquarium,
"key_stats": _fold_key_stats(progress.key_stats, result.strokes), "key_stats": _fold_key_stats(progress.key_stats, result.strokes),
"pearls": progress.pearls + result.pearls,
"streak": _bump_streak(progress.streak, day), "streak": _bump_streak(progress.streak, day),
} }
) )

View File

@@ -5,14 +5,16 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import json import json
import os
import threading import threading
from collections.abc import Callable from collections.abc import Callable
from dataclasses import replace
from pathlib import Path from pathlib import Path
import pytest import pytest
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
from musicmouse.library import Album, Analyzer, MusicLibrary from musicmouse.library import Album, Analyzer, MusicLibrary, default_worker_count
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
from musicmouse.library.cache import LibraryCache from musicmouse.library.cache import LibraryCache
from musicmouse.library.colors import colors_from_id from musicmouse.library.colors import colors_from_id
@@ -52,6 +54,19 @@ class FakeAnalyzer:
return analysis, BeatGrid((0.1,), (1.0,)), curves return analysis, BeatGrid((0.1,), (1.0,)), curves
class PidAnalyzer(FakeAnalyzer):
"""Reports *which process* analyzed each track, as `tempo`.
The point of a pool is that this is never the process running the test - and a
class at module scope is also the only kind of analyzer a worker can be handed,
since it has to survive being pickled over to one.
"""
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
analysis, grid, curves = super().analyze(path)
return replace(analysis, tempo=float(os.getpid())), grid, curves
def album_named(library: MusicLibrary, title: str) -> Album: def album_named(library: MusicLibrary, title: str) -> Album:
return next(album for album in library.albums if album.title == title) return next(album for album in library.albums if album.title == title)
@@ -641,3 +656,73 @@ async def test_requests_raised_during_a_pass_coalesce_into_one_more_pass(
assert pass_count == 2 assert pass_count == 2
assert len(analyzer.calls) == 5 assert len(analyzer.calls) == 5
# ------------------------------------------------------------------ worker processes
async def test_a_parallel_pass_analyzes_every_track_in_worker_processes(
config_dir: Path,
) -> None:
"""The whole point of `analysis_workers`: a machine with cores gets to use them.
Two workers rather than the real default, because what is being checked is that
the work left this process at all - not how fast a five-track fixture goes.
"""
library = await build(config_dir, analyzer=PidAnalyzer(fails={"00 - lied.mp3"}))
done = await library.analyze_pending(workers=2)
assert done == 5
analyzed = [
track.analysis
for album in library.albums
if album.kind == "music"
for track in album.tracks
]
assert all(a is not None and a.version == ANALYZER_VERSION for a in analyzed)
# The one file the analyzer chokes on is recorded as attempted, exactly as on the
# in-process path - a raise in a worker must not abort the other four.
failed = [a for a in analyzed if a is not None and a.tempo is None]
assert len(failed) == 1
workers = {a.tempo for a in analyzed if a is not None and a.tempo is not None}
assert workers
assert os.getpid() not in workers
async def test_a_parallel_pass_starts_no_workers_while_playback_is_busy(
config_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Being busy holds the pass *before* the pool exists, so a device playing an album
is not also hosting a set of idle analyzer processes."""
import musicmouse.library as library_module
monkeypatch.setattr(library_module, "_BUSY_POLL_SECONDS", 0.01)
library = await build(config_dir, analyzer=PidAnalyzer())
busy = True
task = asyncio.create_task(library.analyze_pending(workers=2, is_busy=lambda: busy))
try:
await asyncio.sleep(0.2)
assert list(library.cache.analysis.glob("*.json")) == []
busy = False
assert await asyncio.wait_for(task, timeout=30) == 5
finally:
if not task.done():
await _cancel(task)
async def test_a_parallel_pass_is_cancellable_mid_flight(config_dir: Path) -> None:
"""Shutdown cancels this task like any other, and it must not hang waiting for
worker processes to finish the tracks they are on."""
library = await build(config_dir, analyzer=PidAnalyzer())
task = asyncio.create_task(library.analyze_pending(workers=2))
await asyncio.sleep(0.1) # long enough to have workers starting up
await asyncio.wait_for(_cancel(task), timeout=10)
def test_the_default_worker_count_leaves_a_core_for_everything_else() -> None:
count = default_worker_count()
assert 1 <= count <= max(1, (os.cpu_count() or 1) - 1)

View File

@@ -56,7 +56,6 @@ def _run_body(lesson_id: str, *, stars: int = 3, passed: bool = True) -> dict:
"animal": "fish", "animal": "fish",
"points": 42.0, "points": 42.0,
"passed": passed, "passed": passed,
"pearls": 5,
"strokes": [{"key": "a", "expected": "a", "correct": True, "at": 0.0}], "strokes": [{"key": "a", "expected": "a", "correct": True, "at": 0.0}],
} }
@@ -171,7 +170,6 @@ async def test_get_progress_is_fresh_with_only_the_first_lesson_unlocked(
body = (await client.get("/api/tippen/progress")).json() body = (await client.get("/api/tippen/progress")).json()
assert body["lessons"]["l01"]["unlocked"] is True assert body["lessons"]["l01"]["unlocked"] is True
assert body["lessons"]["l02"]["unlocked"] is False assert body["lessons"]["l02"]["unlocked"] is False
assert body["pearls"] == 0
async def test_put_settings_round_trips(client: httpx2.AsyncClient) -> None: async def test_put_settings_round_trips(client: httpx2.AsyncClient) -> None:

View File

@@ -117,19 +117,21 @@ def test_wrong_world_count_is_one_problem(tmp_path: Path) -> None:
def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None: def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None:
worlds = [dict(w) for w in VALID["worlds"]] worlds = [dict(w) for w in VALID["worlds"]]
# Two independent problems in one file: a reused reward, and a lesson with too many # Two independent problems in one file: a reused reward, and a lesson introducing
# keys - both should show up in a single error, not just the first one found. # too many keys at once - both should show up in a single error, not just the first
# one found. ("a" and "s" are already taught in world 1, so only the three unseen
# keys count against the cap.)
worlds[1] = {**worlds[1], "reward": "clownfish"} worlds[1] = {**worlds[1], "reward": "clownfish"}
worlds[2] = { worlds[2] = {
**worlds[2], **worlds[2],
"lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "d"]}], "lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "q", "w", "e"]}],
} }
with pytest.raises(CurriculumError) as excinfo: with pytest.raises(CurriculumError) as excinfo:
load_curriculum(_write(tmp_path, {"worlds": worlds})) load_curriculum(_write(tmp_path, {"worlds": worlds}))
message = str(excinfo.value) message = str(excinfo.value)
assert "2 problems" in message assert "2 problems" in message
assert "reuses reward" in message assert "reuses reward" in message
assert "at most two keys" in message assert "at most two new keys" in message
def test_letters_kind_cannot_have_words(tmp_path: Path) -> None: def test_letters_kind_cannot_have_words(tmp_path: Path) -> None:
@@ -244,11 +246,10 @@ def test_plays_a_mode_that_fits_its_kind(curriculum) -> None:
assert mode in ELIGIBLE_MODES[lesson.kind], where assert mode in ELIGIBLE_MODES[lesson.kind], where
def test_never_plays_the_same_arcade_game_twice_in_a_row(curriculum) -> None: def test_letters_rounds_always_play_bubbles(curriculum) -> None:
arcade = [lesson for lesson in curriculum.lessons if lesson.kind == "letters"] for lesson in curriculum.lessons:
for i in range(1, len(arcade)): if lesson.kind == "letters":
where = f"{arcade[i].number}: {arcade[i].title}" assert lesson.primary_mode == "bubbles", f"{lesson.number}: {lesson.title}"
assert arcade[i].primary_mode != arcade[i - 1].primary_mode, where
def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None: def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None:

View File

@@ -53,13 +53,12 @@ def _curriculum() -> Curriculum:
return Curriculum(worlds=worlds, lessons=lessons) return Curriculum(worlds=worlds, lessons=lessons)
def _result(*, stars: int, passed: bool, points: float = 10.0, pearls: int = 3) -> RunResult: def _result(*, stars: int, passed: bool, points: float = 10.0) -> RunResult:
return RunResult( return RunResult(
stars=stars, # type: ignore[arg-type] stars=stars, # type: ignore[arg-type]
animal="fish", animal="fish",
points=points, points=points,
passed=passed, passed=passed,
pearls=pearls,
strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),), strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),),
) )
@@ -99,7 +98,6 @@ def test_save_then_load_round_trips(tmp_path: Path) -> None:
assert reloaded.lessons["l01"].best_stars == 3 assert reloaded.lessons["l01"].best_stars == 3
assert reloaded.lessons["l02"].unlocked is True assert reloaded.lessons["l02"].unlocked is True
assert reloaded.pearls == 3
# Atomic write leaves no temp file behind. # Atomic write leaves no temp file behind.
assert list(tmp_path.glob("*.tmp*")) == [] assert list(tmp_path.glob("*.tmp*")) == []
@@ -209,16 +207,15 @@ def test_finishing_a_world_awards_its_creature_exactly_once() -> None:
assert step3.progress.aquarium == ["clownfish"] assert step3.progress.aquarium == ["clownfish"]
def test_pearls_and_streak_accumulate() -> None: def test_streak_accumulates() -> None:
curriculum = _curriculum() curriculum = _curriculum()
progress = fresh_progress(curriculum) progress = fresh_progress(curriculum)
day1 = record_run( day1 = record_run(
progress, "l01", _result(stars=1, passed=False, pearls=3), curriculum, day="2026-01-01" progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-01"
) )
day2 = record_run( day2 = record_run(
day1.progress, "l01", _result(stars=1, passed=False, pearls=4), curriculum, day="2026-01-02" day1.progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-02"
) )
assert day2.progress.pearls == 7
assert day2.progress.streak.days == 2 assert day2.progress.streak.days == 2
assert day2.progress.streak.last_played == "2026-01-02" assert day2.progress.streak.last_played == "2026-01-02"

View File

@@ -1,11 +1,14 @@
# The typing game's lesson plan, referenced from config.yml's general.tippen.curriculum_file. # The typing game's lesson plan, referenced from config.yml's general.tippen.curriculum_file.
# #
# Every lesson: title, subtitle (read aloud). Then either: # Every lesson: title, subtitle (read aloud). Then either:
# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were # - keys: the letters-kind keys this round has on the table. At most two of them may
# already active: the first time a key appears its lesson is "isolated" (heavy # be new; the rest are keys already taught, written out to say "these are in play
# weight, alone); the second (identical) appearance is "mixed" (lighter weight, # too". The loader tracks which keys were already active and spotlights only what is
# blended with everything learned so far). Two lessons with the same `keys` back to # new, so `keys: [f, j, a, d, k]` right after `[f, j, a, d]` is a K lesson with the
# back is exactly how you write "isolated, then mixed" - no separate flag needed. # other four as review ("isolated" - heavy weight on K). A round that introduces
# nothing new spotlights its whole list instead ("mixed" - lighter weight, blended
# with everything learned so far), so two lessons with the same `keys` back to back
# is exactly how you write "isolated, then mixed" - no separate flag needed.
# - drill: true - a pure review round, no new content, whatever is active so far. # - drill: true - a pure review round, no new content, whatever is active so far.
# - kind + words - a fragments/words/sentences consolidation round (dive mode by # - kind + words - a fragments/words/sentences consolidation round (dive mode by
# default; `mode:` overrides it - see the backend's `ELIGIBLE_MODES` for which # default; `mode:` overrides it - see the backend's `ELIGIBLE_MODES` for which
@@ -18,8 +21,11 @@
# #
# unlocks: <path> # unlocks: <path>
# #
# A path into the music library (see config.yml's general.library.root - absolute, or # A path into the music library, written relative to config.yml's
# relative to it; "~" is expanded). Passing this lesson (two stars, or five attempts # general.library.root - "Musik/Klaviersammlung/Lied.mp3", not a path from "/" or "~".
# (An absolute path, or one starting with "~", still resolves, so an old file keeps
# working - but relative is the form to write, since it survives moving the library.)
# Passing this lesson (two stars, or five attempts
# regardless of score) unlocks that track and everything before it in the same album, # regardless of score) unlocks that track and everything before it in the same album,
# inclusive - so pointing at an album's last track unlocks the whole album, and # inclusive - so pointing at an album's last track unlocks the whole album, and
# pointing at track 5 of an audiobook unlocks chapters 1 through 5. The web front-end # pointing at track 5 of an audiobook unlocks chapters 1 through 5. The web front-end
@@ -59,7 +65,7 @@ worlds:
subtitle: "Die Ringfinger" subtitle: "Die Ringfinger"
keys: [s, l] keys: [s, l]
# Example - unlocks tracks 1 through 5 (inclusive) of this album: # Example - unlocks tracks 1 through 5 (inclusive) of this album:
# unlocks: ~/Music/Musik/Kinderparty - Kinderparty Lieder/05 - Lied.mp3 # unlocks: "Musik/Kinderparty - Kinderparty Lieder/05 - Lied.mp3"
- title: "S und L üben" - title: "S und L üben"
subtitle: "Die neuen Tasten festigen" subtitle: "Die neuen Tasten festigen"
keys: [s, l] keys: [s, l]
@@ -195,7 +201,7 @@ worlds:
drill: true drill: true
# Example - unlocks a whole audiobook (its last chapter, inclusive of every # Example - unlocks a whole audiobook (its last chapter, inclusive of every
# chapter before it): # chapter before it):
# unlocks: ~/Music/Hörbücher/Conni - Conni in den Bergen/06 - Teil.mp3 # unlocks: "Hörbücher/Conni - Conni in den Bergen/06 - Teil.mp3"
- number: 3 - number: 3
title: "Nach unten" title: "Nach unten"
@@ -295,7 +301,7 @@ worlds:
drill: true drill: true
# Example - unlocks a whole podcast show (its most recent episode, and every # Example - unlocks a whole podcast show (its most recent episode, and every
# earlier one of the same show, chronologically): # earlier one of the same show, chronologically):
# unlocks: ~/Music/Kinderpodcasts/Wissen macht Ah/20260101 - Neu.mp3 # unlocks: "Kinderpodcasts/Wissen macht Ah/20260101 - Neu.mp3"
- number: 4 - number: 4
title: "Große Buchstaben" title: "Große Buchstaben"

241
scripts/sync-library.sh Executable file
View File

@@ -0,0 +1,241 @@
#!/usr/bin/env bash
#
# Copy a local music library, and the expensive parts of its scan cache, onto a
# MusicMouse device.
#
# scripts/sync-library.sh --host musicdolphin
#
# The cache is the reason this is a script rather than one rsync line. Its three kinds
# of content behave completely differently when they move between machines (see
# python-backend/musicmouse/library/cache.py):
#
# covers/<album_id>.jpg album_id = sha1(folder path relative to the library
# root). Portable, as long as the shelf layout under the
# target root matches the source - which it does, because
# this script syncs the shelves verbatim.
#
# analysis/<track_key>.json track_key = sha1("<name>:<size>:<int mtime>"). Portable
# *only if mtimes survive the copy*. That is why every
# rsync here is -a and why you must never reach for scp
# without -p: losing mtimes silently invalidates every
# analysis file, each of which costs minutes of DSP to
# rebuild.
#
# index.json NOT copied. It holds absolute source-machine paths, and
# scan_library() reuses a cached album whenever its
# (name, size, mtime) fingerprint matches, without
# re-checking the path. Copying it makes every album come
# back pointing at /home/<you>/Music/..., and playback
# fails on files that are sitting right there. It is the
# cheap part of the cache - the device rebuilds it on its
# first scan, reusing every cover and analysis file we did
# copy.
set -euo pipefail
HOST="musicdolphin"
SSH_USER="root"
SRC="${HOME}/Music"
CACHE=""
DEST="/media/musicmouse"
DRY_RUN=0
COPY_CACHE=1
# Shelves the scanner knows about (musicmouse/library/sections.py). Syncing these by
# name rather than the whole directory is what makes --delete safe: config.yml,
# tippen-curriculum.yml, tippen-progress.json and .musicmouse-cache all live in $DEST
# on the device and must survive.
SECTIONS=("Figuren" "Musik" "Hörbücher" "Kinderpodcasts")
# Leave this much room on the target after the copy. These devices are SD cards with
# little to spare, and a full root filesystem breaks far more than the music player.
HEADROOM_BYTES=$((1024 * 1024 * 1024))
usage() {
cat <<'USAGE'
Usage: scripts/sync-library.sh [options]
--host HOST Target device (default: musicdolphin)
--user USER SSH user (default: root)
--src DIR Local library (default: ~/Music)
--dest DIR Library root on target (default: /media/musicmouse)
--cache DIR Local cache directory (default: <src>/.musicmouse-cache, falling
back to python-backend/.musicmouse-cache next to this script)
--no-cache Skip the cache; the device recomputes everything from scratch
--dry-run Show what would be transferred, change nothing
-h, --help This message
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--host) HOST="$2"; shift 2 ;;
--user) SSH_USER="$2"; shift 2 ;;
--src) SRC="$2"; shift 2 ;;
--dest) DEST="$2"; shift 2 ;;
--cache) CACHE="$2"; shift 2 ;;
--no-cache) COPY_CACHE=0; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
SSH_TARGET="${SSH_USER}@${HOST}"
SRC="${SRC%/}"
DEST="${DEST%/}"
die() { echo "error: $*" >&2; exit 1; }
step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
human() {
# Bytes -> something a person can judge at a glance.
numfmt --to=iec --suffix=B "$1" 2>/dev/null || echo "$1 bytes"
}
# ---------------------------------------------------------------------- cache location
if [[ -z "$CACHE" ]]; then
if [[ -d "${SRC}/.musicmouse-cache" ]]; then
CACHE="${SRC}/.musicmouse-cache"
else
# The dev setup keeps config.yml (and therefore the cache, which resolves
# relative to it) in python-backend/ rather than beside the music.
CACHE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/python-backend/.musicmouse-cache"
fi
fi
# ------------------------------------------------------------------------- preflight
step "Preflight"
[[ -d "$SRC" ]] || die "source library not found: $SRC"
present_sections=()
for section in "${SECTIONS[@]}"; do
[[ -d "${SRC}/${section}" ]] && present_sections+=("$section")
done
[[ ${#present_sections[@]} -gt 0 ]] \
|| die "no known shelves under $SRC (looked for: ${SECTIONS[*]})"
echo "source: $SRC"
echo "shelves: ${present_sections[*]}"
if [[ $COPY_CACHE -eq 1 ]]; then
if [[ -d "$CACHE" ]]; then
echo "cache: $CACHE"
else
echo "cache: none found at $CACHE - continuing without it"
COPY_CACHE=0
fi
else
echo "cache: skipped (--no-cache)"
fi
ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_TARGET" true 2>/dev/null \
|| die "cannot ssh to $SSH_TARGET (needs key auth, no password prompt)"
echo "target: ${SSH_TARGET}:${DEST}"
# Size the transfer. This is an upper bound: rsync skips files already present and
# identical, so a repeat run moves far less than this.
src_bytes=0
for section in "${present_sections[@]}"; do
section_bytes=$(du -sb "${SRC}/${section}" | cut -f1)
src_bytes=$((src_bytes + section_bytes))
printf ' %-18s %s\n' "$section" "$(human "$section_bytes")"
done
if [[ $COPY_CACHE -eq 1 ]]; then
for part in covers analysis; do
if [[ -d "${CACHE}/${part}" ]]; then
part_bytes=$(du -sb "${CACHE}/${part}" | cut -f1)
src_bytes=$((src_bytes + part_bytes))
printf ' %-18s %s\n' "cache/${part}" "$(human "$part_bytes")"
fi
done
fi
# df on the parent: $DEST may not exist yet on a first run.
avail_kb=$(ssh "$SSH_TARGET" "df -Pk '$(dirname "$DEST")' | awk 'NR==2 {print \$4}'")
[[ -n "$avail_kb" ]] || die "could not read free space on $HOST"
avail_bytes=$((avail_kb * 1024))
echo
echo "to transfer (upper bound): $(human "$src_bytes")"
echo "free on target: $(human "$avail_bytes")"
if (( src_bytes + HEADROOM_BYTES > avail_bytes )); then
# Already-synced content counts against src_bytes but costs nothing, so this is a
# warning on a repeat run and a hard stop only when nothing is there yet.
remote_used=$(ssh "$SSH_TARGET" "du -sb '$DEST' 2>/dev/null | cut -f1" || echo 0)
remote_used=${remote_used:-0}
if (( remote_used > 0 )); then
echo
echo "warning: the full library would not fit in the free space, but $(human "$remote_used")"
echo " is already at ${DEST}. rsync only moves the difference; watch the"
echo " free space as it runs."
else
die "not enough space: need $(human $((src_bytes + HEADROOM_BYTES))) including $(human "$HEADROOM_BYTES") headroom, have $(human "$avail_bytes")"
fi
fi
# --------------------------------------------------------------------------- transfer
RSYNC_OPTS=(-aH --info=progress2 --human-readable)
[[ $DRY_RUN -eq 1 ]] && RSYNC_OPTS+=(-n)
if [[ $DRY_RUN -eq 1 ]]; then
echo
echo "(dry run - nothing will be written)"
else
step "Creating ${DEST} on ${HOST}"
ssh "$SSH_TARGET" "mkdir -p '$DEST'"
fi
step "Library"
for section in "${present_sections[@]}"; do
echo "--- ${section}"
# Per-shelf, with --delete scoped to that shelf. A --delete on $DEST as a whole
# would take out config.yml and the cache, which live in the same directory on the
# device but have no counterpart here.
rsync "${RSYNC_OPTS[@]}" --delete \
"${SRC}/${section}/" "${SSH_TARGET}:${DEST}/${section}/"
done
if [[ $COPY_CACHE -eq 1 ]]; then
step "Cache (covers and analysis; index.json deliberately not copied)"
for part in covers analysis; do
[[ -d "${CACHE}/${part}" ]] || continue
echo "--- ${part}"
# No --delete: the device may have analysed tracks this machine never saw.
rsync "${RSYNC_OPTS[@]}" \
"${CACHE}/${part}/" "${SSH_TARGET}:${DEST}/.musicmouse-cache/${part}/"
done
fi
# ----------------------------------------------------------------------------- report
step "Done"
if [[ $DRY_RUN -eq 1 ]]; then
echo "Dry run only - nothing was written to ${HOST}."
exit 0
fi
ssh "$SSH_TARGET" "
echo 'library: '\$(du -sh '$DEST' 2>/dev/null | cut -f1)
echo 'free: '\$(df -Ph '$DEST' | awk 'NR==2 {print \$4}')
if [ -d '${DEST}/.musicmouse-cache' ]; then
echo 'covers: '\$(find '${DEST}/.musicmouse-cache/covers' -type f 2>/dev/null | wc -l)' files'
echo 'analysis: '\$(find '${DEST}/.musicmouse-cache/analysis' -type f 2>/dev/null | wc -l)' files'
fi
"
cat <<EOF
The backend rescans on its next start - a few minutes for a large library - and writes
a fresh index.json with this device's own paths. Every cover and analysis file copied
above is reused, so the expensive work does not happen again.
ssh ${SSH_TARGET} systemctl restart musicmouse
ssh ${SSH_TARGET} journalctl -u musicmouse -f
EOF

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
web/public/hiddenalbum.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -158,7 +158,7 @@ export interface LircConfig {
// Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`. // Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`.
export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences"; export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences";
export type TippenModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race"; export type TippenModeId = "dive" | "bubbles" | "feed" | "race";
/** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the /** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the
* configured path matches nothing in the current library. */ * configured path matches nothing in the current library. */
@@ -236,7 +236,6 @@ export interface TippenSettings {
export interface TippenProgress { export interface TippenProgress {
lessons: Record<string, TippenLessonProgress>; lessons: Record<string, TippenLessonProgress>;
key_stats: Record<string, TippenKeyStat>; key_stats: Record<string, TippenKeyStat>;
pearls: number;
aquarium: string[]; aquarium: string[];
streak: TippenStreak; streak: TippenStreak;
settings: TippenSettings; settings: TippenSettings;
@@ -257,7 +256,6 @@ export interface TippenRunInput {
animal: string; animal: string;
points: number; points: number;
passed: boolean; passed: boolean;
pearls: number;
strokes: TippenStroke[]; strokes: TippenStroke[];
} }

View File

@@ -26,8 +26,8 @@ interface Props {
style?: CSSProperties; style?: CSSProperties;
/** Print the title over generated art. Off for thumbnails, where it would not fit. */ /** Print the title over generated art. Off for thumbnails, where it would not fit. */
label?: boolean; label?: boolean;
/** A reward-gated album/track not yet earned - shows a question mark instead of the /** A reward-gated album/track not yet earned - shows a placeholder cover instead of
* real art or title, regardless of `has_cover`. */ * the real art or title, regardless of `has_cover`. */
locked?: boolean; locked?: boolean;
} }
@@ -53,20 +53,18 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
}} }}
> >
{locked ? ( {locked ? (
<div <img
src={book ? "/hidden_audiobook_or_podcast.png" : "/hiddenalbum.png"}
alt=""
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
display: "flex", width: "100%",
alignItems: "center", height: "100%",
justifyContent: "center", objectFit: "cover",
fontSize: Math.max(22, Math.round(size / 2.2)), clipPath: book ? "inset(0 5% 0 0)" : undefined,
color: "oklch(99% 0 0 / .85)",
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
}} }}
> />
</div>
) : ( ) : (
<> <>
{!album.has_cover && label && ( {!album.has_cover && label && (

View File

@@ -1,12 +1,13 @@
/** The typing game, as a tab of the music player rather than its own app. /** The typing game, as a tab of the music player rather than its own app.
* *
* Ported from the standalone tippen app's App.tsx - same shape (one state object, one * Ported from the standalone tippen app's App.tsx - same shape (one state object, one
* keydown listener for navigation, four screens) - with two changes: curriculum and * keydown listener for navigation, screens) - with two changes: curriculum and
* progress are now fetched from the backend instead of a build-time YAML import and * progress are now fetched from the backend instead of a build-time YAML import and
* localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and * localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and
* `onExit` is the new base case for "back" - Escape/the map's own navigation peel one * `onExit` is the new base case for "back". The lesson map is the floor screen - the
* layer at a time, same as before, but the aquarium screen is no longer the floor: one * swimming aquarium creatures already show behind it, so there is no separate landing
* more Escape leaves the tab entirely, back to the music player. * screen in front of it - and one Escape from the map leaves the tab entirely, back to
* the music player.
* *
* The rule that still matters most: **while a run is going, every key belongs to the * The rule that still matters most: **while a run is going, every key belongs to the
* run**. This component's own keydown listener only ever intercepts Escape and F1 - the * run**. This component's own keydown listener only ever intercepts Escape and F1 - the
@@ -18,36 +19,37 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTippenCurriculum } from "../hooks/useTippenCurriculum"; import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
import { useTippenProgress } from "../hooks/useTippenProgress"; import { useTippenProgress } from "../hooks/useTippenProgress";
import { Aquarium } from "./tippen/Aquarium";
import { AppHeader } from "./tippen/AppHeader"; import { AppHeader } from "./tippen/AppHeader";
import { HelpOverlay } from "./tippen/HelpOverlay"; import { HelpOverlay } from "./tippen/HelpOverlay";
import { LessonMap } from "./tippen/LessonMap"; import { LessonMap } from "./tippen/LessonMap";
import { ResultSheet } from "./tippen/ResultSheet"; import { ResultSheet } from "./tippen/ResultSheet";
import { RewardUnlockOverlay } from "./tippen/RewardUnlockOverlay";
import { Stage } from "./tippen/Stage"; import { Stage } from "./tippen/Stage";
import { BubblesRun } from "./tippen/modes/BubblesRun"; import { BubblesRun } from "./tippen/modes/BubblesRun";
import { DiveRun } from "./tippen/modes/DiveRun"; import { DiveRun } from "./tippen/modes/DiveRun";
import { FeedRun } from "./tippen/modes/FeedRun"; import { FeedRun } from "./tippen/modes/FeedRun";
import { JellyfishRun } from "./tippen/modes/JellyfishRun";
import { RaceRun } from "./tippen/modes/RaceRun"; import { RaceRun } from "./tippen/modes/RaceRun";
import type { CreatureId } from "../lib/tippen/aquarium"; import type { CreatureId } from "../lib/tippen/aquarium";
import { creatureById } from "../lib/tippen/aquarium";
import type { Lesson, ModeId } from "../lib/tippen/curriculum"; import type { Lesson, ModeId } from "../lib/tippen/curriculum";
import { lessonById, nextLesson } from "../lib/tippen/curriculum"; import { lessonById, nextLesson } from "../lib/tippen/curriculum";
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator"; import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
import { animalById } from "../lib/tippen/grading";
import type { RunResult } from "../lib/tippen/grading"; import type { RunResult } from "../lib/tippen/grading";
import { playFanfare, playPop } from "../lib/tippen/pop"; import { playFanfare, playPop } from "../lib/tippen/pop";
import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress"; import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress";
import type { UnlockedReward } from "../lib/tippen/progress"; import type { UnlockedReward } from "../lib/tippen/progress";
import { bubbleCountFor } from "../lib/tippen/theme"; import { bubbleCountFor } from "../lib/tippen/theme";
type Screen = "aquarium" | "map" | "run"; type Screen = "map" | "run";
/** What a mode is handed to draw - see the original App.tsx for why there are only two /** What a mode is handed to draw - see the original App.tsx for why there are only two
* shapes for five modes. */ * shapes for four modes. */
type RunTarget = type RunTarget =
| { kind: "letters"; letters: readonly string[] } | { kind: "letters"; letters: readonly string[] }
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean }; | { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"]; const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles"];
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 }; const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
@@ -68,10 +70,14 @@ export function TippenApp({ onExit }: Props) {
const configured = curriculum !== null; const configured = curriculum !== null;
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured); const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
const [screen, setScreen] = useState<Screen>("aquarium"); const [screen, setScreen] = useState<Screen>("map");
const [lessonId, setLessonId] = useState<string | null>(null); const [lessonId, setLessonId] = useState<string | null>(null);
const [mode, setMode] = useState<ModeId>("dive"); const [mode, setMode] = useState<ModeId>("dive");
const [outcome, setOutcome] = useState<Outcome | null>(null); const [outcome, setOutcome] = useState<Outcome | null>(null);
/** The unlock celebration, in front of the result sheet - see RewardUnlockOverlay.
* Separate from `outcome.unlockedReward` because it is dismissed on its own, leaving
* the result sheet (and its recap of the same reward) behind it. */
const [celebration, setCelebration] = useState<UnlockedReward | null>(null);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [selected, setSelected] = useState(0); const [selected, setSelected] = useState(0);
/** Bumped to generate a fresh line - a new seed for the same lesson. */ /** Bumped to generate a fresh line - a new seed for the same lesson. */
@@ -118,6 +124,7 @@ export function TippenApp({ onExit }: Props) {
}, [lesson, mode, round]); }, [lesson, mode, round]);
const start = useCallback((lesson: Lesson) => { const start = useCallback((lesson: Lesson) => {
setCelebration(null);
setLessonId(lesson.id); setLessonId(lesson.id);
setMode(lesson.primaryMode); setMode(lesson.primaryMode);
setOutcome(null); setOutcome(null);
@@ -136,10 +143,11 @@ export function TippenApp({ onExit }: Props) {
isNewBest: recorded.isNewBest, isNewBest: recorded.isNewBest,
unlockedReward: recorded.unlockedReward, unlockedReward: recorded.unlockedReward,
}); });
if ( // The unlock celebration brings its own sounds (and is the bigger moment), so
progress.settings.sound && // the generic fanfare would only step on its opening. One or the other.
(recorded.unlockedLessonId || recorded.newCreature || recorded.unlockedReward) if (recorded.unlockedReward) {
) { setCelebration(recorded.unlockedReward);
} else if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) {
playFanfare(); playFanfare();
} }
}); });
@@ -148,6 +156,7 @@ export function TippenApp({ onExit }: Props) {
); );
const retry = useCallback(() => { const retry = useCallback(() => {
setCelebration(null);
setOutcome(null); setOutcome(null);
setRound((r) => r + 1); setRound((r) => r + 1);
}, []); }, []);
@@ -156,6 +165,7 @@ export function TippenApp({ onExit }: Props) {
* unlock: `onFinished` still runs underneath, so a great bonus run can only improve * unlock: `onFinished` still runs underneath, so a great bonus run can only improve
* the best score, not change what is unlocked. */ * the best score, not change what is unlocked. */
const playBonus = useCallback((bonusMode: ModeId) => { const playBonus = useCallback((bonusMode: ModeId) => {
setCelebration(null);
setMode(bonusMode); setMode(bonusMode);
setOutcome(null); setOutcome(null);
setRound((r) => r + 1); setRound((r) => r + 1);
@@ -163,22 +173,23 @@ export function TippenApp({ onExit }: Props) {
const continueAfterResult = useCallback(() => { const continueAfterResult = useCallback(() => {
const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null; const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
setCelebration(null);
setOutcome(null); setOutcome(null);
if (next && progress?.lessons[next.id]?.unlocked) start(next); if (next && progress?.lessons[next.id]?.unlocked) start(next);
else setScreen("map"); else setScreen("map");
}, [lessonId, curriculum, progress, start]); }, [lessonId, curriculum, progress, start]);
const goBack = useCallback(() => { const goBack = useCallback(() => {
if (celebration) return setCelebration(null);
if (outcome) return setOutcome(null); if (outcome) return setOutcome(null);
if (screen === "run") return setScreen("map"); if (screen === "run") return setScreen("map");
if (screen === "map") return setScreen("aquarium"); if (screen === "map") return onExit();
if (screen === "aquarium") return onExit(); }, [celebration, outcome, screen, onExit]);
}, [outcome, screen, onExit]);
// --- navigation keys ----------------------------------------------------- // --- navigation keys -----------------------------------------------------
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start, curriculum }); const latest = useRef({ screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum });
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start, curriculum }; latest.current = { screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum };
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -196,6 +207,17 @@ export function TippenApp({ onExit }: Props) {
return; return;
} }
// Enter dismisses the unlock celebration. It must be handled before the result
// sheet's own Enter below, or finishing a reward run would restart it instantly
// from behind the overlay.
if (current.celebration) {
if (event.key === "Enter") {
event.preventDefault();
setCelebration(null);
}
return;
}
// Enter repeats a finished run; the result sheet's own button has focus, so this // Enter repeats a finished run; the result sheet's own button has focus, so this
// is only a fallback for when focus has been lost. // is only a fallback for when focus has been lost.
if (current.outcome) { if (current.outcome) {
@@ -212,12 +234,8 @@ export function TippenApp({ onExit }: Props) {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
if (current.screen === "aquarium") { const lesson = current.curriculum.lessons[current.selected];
if (current.nextUp) current.start(current.nextUp); if (lesson) current.start(lesson);
} else {
const lesson = current.curriculum.lessons[current.selected];
if (lesson) current.start(lesson);
}
return; return;
} }
@@ -239,6 +257,7 @@ export function TippenApp({ onExit }: Props) {
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (screen === "run" && !outcome) return; if (screen === "run" && !outcome) return;
if (celebration) return;
if (!progress) return; if (!progress) return;
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound }); if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
@@ -251,7 +270,7 @@ export function TippenApp({ onExit }: Props) {
}; };
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, [screen, outcome, progress, saveSettings]); }, [screen, outcome, celebration, progress, saveSettings]);
// --- render -------------------------------------------------------------- // --- render --------------------------------------------------------------
@@ -274,6 +293,7 @@ export function TippenApp({ onExit }: Props) {
} }
const next = lessonId ? nextLesson(curriculum, lessonId) : null; const next = lessonId ? nextLesson(curriculum, lessonId) : null;
const bestAnimal = overallBestAnimal(progress);
// Every mode takes the same bundle; only the drawing differs. // Every mode takes the same bundle; only the drawing differs.
const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished }; const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished };
@@ -292,34 +312,58 @@ export function TippenApp({ onExit }: Props) {
compact={screen === "run"} compact={screen === "run"}
status={ status={
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}> <div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
<span>🦪 {progress.pearls}</span> {screen === "map" && (
<>
<div className="tp-map-creatures">
{curriculum.worlds.map((world) => {
const creature = creatureById(world.reward);
const owned = progress.aquarium.includes(creature.id);
return (
<img
key={creature.id}
src={creature.image}
alt=""
title={owned ? creature.name : `Welt ${world.number}`}
style={{
width: 22,
height: 22,
objectFit: "contain",
filter: owned ? "none" : "brightness(0) invert(1)",
opacity: owned ? 1 : 0.3,
}}
/>
);
})}
</div>
<span title="Tage am Stück">🔥 {progress.streak.days}</span>
{bestAnimal && (
<span title="Schnellstes Tier">
{animalById(bestAnimal).emoji} {animalById(bestAnimal).name}
</span>
)}
</>
)}
{!progress.settings.sound && <span title="Ton aus">🔇</span>} {!progress.settings.sound && <span title="Ton aus">🔇</span>}
</div> </div>
} }
/> />
{screen === "aquarium" && ( {screen === "map" && (
<Aquarium <LessonMap
worlds={curriculum.worlds} worlds={curriculum.worlds}
lessons={curriculum.lessons}
progress={progress} progress={progress}
selected={selected}
onPick={start}
nextLesson={nextUp} nextLesson={nextUp}
onContinue={() => nextUp && start(nextUp)} onContinue={() => nextUp && start(nextUp)}
onOpenMap={() => setScreen("map")}
/> />
)} )}
{screen === "map" && (
<LessonMap worlds={curriculum.worlds} lessons={curriculum.lessons} progress={progress} selected={selected} onPick={start} />
)}
{screen === "run" && lesson && run && ( {screen === "run" && lesson && run && (
<> <>
{run.kind === "letters" ? ( {run.kind === "letters" ? (
mode === "jellyfish" ? ( <BubblesRun {...letterProps(run.letters)} />
<JellyfishRun {...letterProps(run.letters)} />
) : (
<BubblesRun {...letterProps(run.letters)} />
)
) : mode === "feed" ? ( ) : mode === "feed" ? (
<FeedRun {...textProps(run)} /> <FeedRun {...textProps(run)} />
) : mode === "race" ? ( ) : mode === "race" ? (
@@ -337,7 +381,7 @@ export function TippenApp({ onExit }: Props) {
newCreature={outcome.newCreature} newCreature={outcome.newCreature}
unlockedReward={outcome.unlockedReward} unlockedReward={outcome.unlockedReward}
isNewBest={outcome.isNewBest} isNewBest={outcome.isNewBest}
bestEver={overallBestAnimal(progress)} bestEver={bestAnimal}
bonusModes={outcome.result.passed ? lesson.bonusModes : []} bonusModes={outcome.result.passed ? lesson.bonusModes : []}
onPlayBonus={playBonus} onPlayBonus={playBonus}
onRetry={retry} onRetry={retry}
@@ -346,6 +390,14 @@ export function TippenApp({ onExit }: Props) {
/> />
)} )}
{celebration && (
<RewardUnlockOverlay
reward={celebration}
sound={progress.settings.sound}
onClose={() => setCelebration(null)}
/>
)}
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />} {showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
</Stage> </Stage>
); );

View File

@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
interface Props { interface Props {
title?: string; title?: string;
/** Shown on the right - pearls, streak, a back hint. */ /** Shown on the right - streak, best animal, a back hint. */
status?: ReactNode; status?: ReactNode;
/** Smaller header while a lesson is running, so the target line gets the room. */ /** Smaller header while a lesson is running, so the target line gets the room. */
compact?: boolean; compact?: boolean;

View File

@@ -1,136 +0,0 @@
/** The home screen: what she has collected, before she is asked to do anything.
*
* Deliberately the first thing on opening the app. The reward for finishing a world is a
* pet that moves in for good - it swims behind every screen from then on (see
* AquariumCreatures.tsx) - and a reward you can see before you start is worth more than
* one you are told about afterwards.
*
* The panel shows every pet there is to earn: the ones at home in colour, the rest as
* pale outlines. The same idea as the animal ladder - the next thing has to be visible to
* be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
import { creatureById } from "../../lib/tippen/aquarium";
import type { Lesson, World } from "../../lib/tippen/curriculum";
import { animalById } from "../../lib/tippen/grading";
import { overallBestAnimal } from "../../lib/tippen/progress";
import type { Progress } from "../../lib/tippen/progress";
interface Props {
worlds: readonly World[];
progress: Progress;
/** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
nextLesson: Lesson | null;
onContinue: () => void;
onOpenMap: () => void;
}
export function Aquarium({ worlds, progress, nextLesson, onContinue, onOpenMap }: Props) {
const bestAnimal = overallBestAnimal(progress);
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 22,
padding: "0 32px 32px",
minHeight: 0,
}}
>
<div
className="tp-glass-panel"
style={{ padding: "26px 34px", width: "min(680px, 100%)", textAlign: "center" }}
>
<div style={{ fontSize: 15, fontWeight: 900, color: "var(--paper)", opacity: 0.8 }}>
Dein Aquarium
</div>
<div
aria-label="Deine Tiere"
style={{
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
gap: 18,
margin: "16px 0 6px",
alignItems: "center",
}}
>
{worlds.map((world) => {
const creature = creatureById(world.reward);
const owned = progress.aquarium.includes(creature.id);
return (
<img
key={creature.id}
src={creature.image}
alt={owned ? creature.name : `Noch nicht da - Welt ${world.number}`}
title={owned ? creature.name : `Welt ${world.number}`}
style={{
width: 62,
height: 62,
objectFit: "contain",
// Not yet earned: a pale outline of the shape, no colours given away.
filter: owned ? "none" : "brightness(0) invert(1)",
opacity: owned ? 1 : 0.2,
animation: owned ? `dolphinBob ${3 + world.number * 0.4}s ease-in-out infinite` : undefined,
}}
/>
);
})}
</div>
{progress.aquarium.length === 0 && (
<div style={{ fontSize: 15, fontWeight: 800, color: "var(--paper)", opacity: 0.65 }}>
Schaffe eine ganze Welt und dein erstes Tier zieht ein!
</div>
)}
<div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}>
<Stat label="Perlen" value={`🦪 ${progress.pearls}`} />
<Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} />
<Stat
label="Schnellstes Tier"
value={bestAnimal ? `${animalById(bestAnimal).emoji} ${animalById(bestAnimal).name}` : "—"}
/>
</div>
</div>
<div style={{ display: "flex", gap: 12 }}>
{nextLesson && (
<button
onClick={onContinue}
style={{
border: "none",
borderRadius: 999,
padding: "15px 32px",
fontSize: 19,
fontWeight: 900,
cursor: "pointer",
background: "var(--accent)",
color: "var(--paper)",
boxShadow: "0 8px 24px var(--shadow)",
}}
>
{nextLesson.title}
</button>
)}
<button className="tp-pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}>
🗺 Alle Lektionen
</button>
</div>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>{value}</div>
<div style={{ fontSize: 11, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>{label}</div>
</div>
);
}

View File

@@ -1,15 +1,29 @@
/** The map: a single winding path, one world section at a time. /** The map: a single winding path, one world section at a time.
*
* The floor screen of the tippen tab - the swimming aquarium creatures already show
* behind it, so there is no separate landing screen in front of this one anymore.
* *
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is * Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
* waiting is half the reason to finish the world that is open. Each node carries its own * waiting is half the reason to finish the world that is open. Each node carries its own
* best animal, star count and a badge for which game it plays, so the map doubles as * best animal, star count and a badge for which game it plays, so the map doubles as
* both a path forward and a trophy cabinet. */ * both a path forward and a trophy cabinet.
*
* A lesson that unlocks real music or an audiobook chapter carries a treasure chest,
* drawn *outside* its node so the dimming a locked node gets never touches it: the whole
* value of the chest is that it is visible from far off, on lessons she cannot play yet.
* Shut while the reward is still to be won, open with the cover art inside it once it has
* been - same reasoning as the dimmed-not-hidden lessons, except stronger here, because
* this is the one reward that reaches outside the game. */
import { Fragment } from "react";
import { coverUrl } from "../../api/client";
import type { Lesson, World } from "../../lib/tippen/curriculum"; import type { Lesson, World } from "../../lib/tippen/curriculum";
import { animalById } from "../../lib/tippen/grading"; import { animalById } from "../../lib/tippen/grading";
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath"; import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
import { MODE_INFO } from "../../lib/tippen/modeInfo"; import { MODE_INFO } from "../../lib/tippen/modeInfo";
import type { Progress } from "../../lib/tippen/progress"; import type { Progress } from "../../lib/tippen/progress";
import { TreasureChest } from "./TreasureChest";
interface Props { interface Props {
worlds: readonly World[]; worlds: readonly World[];
@@ -18,9 +32,15 @@ interface Props {
/** Which card the keyboard selection is on. */ /** Which card the keyboard selection is on. */
selected: number; selected: number;
onPick: (lesson: Lesson) => void; onPick: (lesson: Lesson) => void;
/** The lesson the floating "Weiter üben" button jumps to - the first unfinished one. */
nextLesson: Lesson | null;
onContinue: () => void;
} }
const NODE_SIZE = 88; const NODE_SIZE = 88;
/** Big enough to read as a treasure chest at a glance, scrolling past - the 15px 🎁 this
* replaced was invisible in practice. */
const CHEST_SIZE = 46;
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */ /** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
const PATH_HALF_WIDTH = 160; const PATH_HALF_WIDTH = 160;
@@ -32,7 +52,7 @@ const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> =
sentences: "Sätze", sentences: "Sätze",
}; };
export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props) { export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLesson, onContinue }: Props) {
return ( return (
<div <div
className="view-enter" className="view-enter"
@@ -88,78 +108,90 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props
const modeInfo = MODE_INFO[lesson.primaryMode]; const modeInfo = MODE_INFO[lesson.primaryMode];
return ( return (
<button // The node and its chest are two siblings, both positioned against
key={lesson.id} // this world's own box - see RewardChest for why the chest must not
className="card tp-glass-panel" // be a child of the node.
data-selected={index === selected} <Fragment key={lesson.id}>
data-locked={locked} <button
disabled={locked} className="card tp-glass-panel"
onClick={() => onPick(lesson)} data-selected={index === selected}
title={lesson.title} data-locked={locked}
style={{ disabled={locked}
position: "absolute", onClick={() => onPick(lesson)}
left: `calc(50% + ${point.x}px)`, title={lesson.title}
top: point.y,
transform: "translate(-50%, -50%)",
width: NODE_SIZE,
height: NODE_SIZE,
borderRadius: "50%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 0,
textAlign: "center",
}}
>
<span
aria-hidden
style={{ style={{
position: "absolute", position: "absolute",
top: -4, left: `calc(50% + ${point.x}px)`,
right: -4, top: point.y,
fontSize: 15, transform: "translate(-50%, -50%)",
filter: locked ? "grayscale(1)" : "none", width: NODE_SIZE,
opacity: locked ? 0.4 : 0.9, height: NODE_SIZE,
}} borderRadius: "50%",
title={modeInfo.name} display: "flex",
> flexDirection: "column",
{modeInfo.emoji} alignItems: "center",
</span> justifyContent: "center",
gap: 2,
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span> padding: 0,
textAlign: "center",
{/* The keys themselves: for a pre-reader this is the real label, the
title is decoration. A drill has no new keys, so it says so with
a symbol instead of showing an empty line. */}
<span
style={{
fontSize: 11,
fontWeight: 800,
color: "var(--paper)",
opacity: 0.8,
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
lineHeight: 1.1,
}} }}
> >
{lesson.isDrill <span
? "🔁" aria-hidden
: lesson.newKeys.length > 0 style={{
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ") position: "absolute",
: lesson.kind === "letters" top: -4,
? "üben" right: -4,
: CONSOLIDATION_LABEL[lesson.kind]} fontSize: 15,
</span> filter: locked ? "grayscale(1)" : "none",
opacity: locked ? 0.4 : 0.9,
}}
title={modeInfo.name}
>
{modeInfo.emoji}
</span>
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}> <span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
{[1, 2, 3].map((star) => (
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}> {/* The keys themselves: for a pre-reader this is the real label, the
title is decoration. A drill has no new keys, so it says so with
</span> a symbol instead of showing an empty line. */}
))} <span
</span> style={{
</button> fontSize: 11,
fontWeight: 800,
color: "var(--paper)",
opacity: 0.8,
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
lineHeight: 1.1,
}}
>
{lesson.isDrill
? "🔁"
: lesson.newKeys.length > 0
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
: lesson.kind === "letters"
? "üben"
: CONSOLIDATION_LABEL[lesson.kind]}
</span>
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
{[1, 2, 3].map((star) => (
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
</span>
))}
</span>
</button>
{lesson.reward.resolved && (
<RewardChest
point={point}
earned={entry?.earned ?? false}
albumId={lesson.reward.hasCover ? lesson.reward.albumId : null}
/>
)}
</Fragment>
); );
})} })}
</div> </div>
@@ -167,6 +199,100 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props
); );
})} })}
</div> </div>
{nextLesson && (
<button
onClick={onContinue}
style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
border: "none",
borderRadius: 999,
padding: "15px 32px",
fontSize: 19,
fontWeight: 900,
cursor: "pointer",
background: "var(--accent)",
color: "var(--paper)",
boxShadow: "0 8px 24px var(--shadow)",
}}
>
{nextLesson.title}
</button>
)}
</div>
);
}
/** A lesson's treasure chest, sitting on the rim of its node.
*
* A sibling of the node button rather than a child, for one reason that is easy to lose
* again later: `.card[data-locked="true"]` dims the whole button to 45% opacity, and the
* chest that most needs to be bright is exactly the one on a lesson three worlds away.
* `pointerEvents: none` hands clicks straight through to the node underneath, so the
* chest never becomes a second, dead target beside the real one.
*
* Open, with the cover art of what it gave inside it, once the lesson is earned: it stops
* being a promise and becomes the trophy, which makes scrolling the map also a way of
* seeing everything she has won. */
function RewardChest({
point,
earned,
albumId,
}: {
point: { x: number; y: number };
earned: boolean;
albumId: string | null;
}) {
return (
<div
style={{
position: "absolute",
// On the node's lower-right rim, overlapping it a little so the two read as one
// object rather than as a badge parked nearby.
left: `calc(50% + ${point.x + NODE_SIZE / 2 + 2}px)`,
top: point.y + NODE_SIZE / 2 - 6,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
zIndex: 1,
filter: "drop-shadow(0 3px 6px oklch(15% 0.05 175 / 0.55))",
}}
title={earned ? "Freigespielt! Im Musik-Player zu hören" : "Hier gibt es neue Musik zu gewinnen!"}
>
{/* Shut, the chest is drawn in one piece, because a shut lid has to cover the body's
top edge. Won, it is split around the cover art so the cover sits *in* the chest -
behind the front wall, under the thrown-back lid - rather than on top of it. See
TreasureChest's `layer`. */}
{earned && albumId ? (
<div style={{ position: "relative", width: CHEST_SIZE, height: CHEST_SIZE }}>
<TreasureChest size={CHEST_SIZE} open layer="back" style={{ position: "absolute", inset: 0 }} />
<img
src={coverUrl(albumId)}
alt=""
// A cover that fails to load must not leave a broken-image glyph in the chest:
// the open chest on its own still says "won", which is the part that matters.
onError={(event) => {
event.currentTarget.style.display = "none";
}}
style={{
position: "absolute",
left: "50%",
top: "34%",
transform: "translate(-50%, -50%)",
width: CHEST_SIZE * 0.46,
height: CHEST_SIZE * 0.46,
objectFit: "cover",
borderRadius: 4,
border: "1.5px solid oklch(92% 0.13 92 / 0.9)",
}}
/>
<TreasureChest size={CHEST_SIZE} open layer="front" style={{ position: "absolute", inset: 0 }} />
</div>
) : (
<TreasureChest size={CHEST_SIZE} open={earned} muted={!earned} />
)}
</div> </div>
); );
} }

View File

@@ -93,7 +93,6 @@ export function ResultSheet({
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}> <div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
<Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} /> <Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
<Stat label="Zeichen/Min" value={String(Math.round(result.speed))} /> <Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
<Stat label="Perlen" value={`+${result.pearls}`} />
</div> </div>
{/* How close the next animal is. A near miss is the strongest reason to press {/* How close the next animal is. A near miss is the strongest reason to press
@@ -149,34 +148,42 @@ export function ResultSheet({
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen! {creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
</div> </div>
)} )}
{/* The recap, not the reveal. RewardUnlockOverlay has already given this its own
full-screen celebration by the time this sheet is visible, so here it only has
to stay on the page as a reminder of what she just won - which is also why it
is the one badge on this sheet that gets a tinted row of its own rather than a
line of text. */}
{unlockedReward && ( {unlockedReward && (
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", gap: 12,
gap: 10, marginTop: 12,
color: "var(--ink)", padding: "10px 14px",
fontWeight: 900, borderRadius: 16,
marginTop: 8, textAlign: "left",
background: "oklch(93% 0.06 92)",
border: "1px solid oklch(80% 0.12 90)",
}} }}
> >
{unlockedReward.hasCover ? ( {unlockedReward.hasCover ? (
<img <img
src={coverUrl(unlockedReward.albumId)} src={coverUrl(unlockedReward.albumId)}
alt="" alt=""
style={{ style={{ width: 52, height: 52, objectFit: "cover", borderRadius: 8, flexShrink: 0 }}
width: 52,
height: 52,
objectFit: "cover",
borderRadius: 8,
animation: "tierEnter 520ms ease-out",
}}
/> />
) : ( ) : (
<span style={{ fontSize: 40, animation: "tierEnter 520ms ease-out" }}>🎁</span> <span style={{ fontSize: 40, flexShrink: 0 }}>🎵</span>
)} )}
🎁 Neu zum Anhören: {unlockedReward.title} <div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 800, color: "var(--ink)", opacity: 0.7 }}>
Freigespielt ab jetzt im Musik-Player
</div>
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--ink)" }}>
{unlockedReward.title}
</div>
</div>
</div> </div>
)} )}
{!result.passed && !unlockedTitle && ( {!result.passed && !unlockedTitle && (

View File

@@ -0,0 +1,409 @@
/** The unlock celebration: a treasure chest that opens and hands back real music.
*
* This is the one moment the typing game exists for. Everything else it gives out -
* stars, animals, aquarium creatures - lives inside the game; this is the only reward
* that reaches outside it, into the music player. So it gets the whole screen, its own
* tune (`playRewardJingle`), and a sequence that has to be waited out, rather than being
* one more badge on the result sheet. The result sheet stays mounted underneath and is
* revealed when this closes, so nothing is lost by putting this in front of it.
*
* The staging, and why each beat is there:
*
* 0ms "drop" the chest falls in, shut, and rattles. The rattle is the beat that
* earns the opening - something inside wants out.
* 1150ms "open" the lid swings, light bursts, confetti starts, the tune starts.
* 1500ms "reveal" the cover art rises out of the chest; the title and the button
* arrive under it.
*
* Dismissal is deliberately not automatic. A six-year-old should get to look at the
* cover of the song she just won for as long as she likes, and pressing the button is
* itself part of the reward. It is, however, dismissible from the first frame: the
* sequence can always be cut short with Enter, Escape or a click. */
import { useEffect, useMemo, useRef, useState } from "react";
import { coverUrl } from "../../api/client";
import {
playChestOpen,
playChestThud,
playRewardJingle,
} from "../../lib/tippen/pop";
import type { UnlockedReward } from "../../lib/tippen/progress";
import { TreasureChest } from "./TreasureChest";
type Phase = "drop" | "open" | "reveal";
const OPEN_AT = 1150;
const REVEAL_AT = 1500;
/** The chest is drawn wider than the cover on purpose: the lid swings up across the
* middle, so a cover as wide as the chest hides it completely and the open chest reads as
* a shut one. Keeping the cover to roughly three-quarters of the chest's width leaves the
* lid standing clear beside it. */
const CHEST_SIZE = 196;
const COVER_SIZE = 148;
/** Headline and fallback glyph per reward kind. In German, and phrased as a gift rather
* than as a transaction: "freigeschaltet" is what the map's badge says, "für dich" is
* what this screen says. */
const COPY: Record<
UnlockedReward["kind"],
{ heading: string; lead: string; glyph: string }
> = {
album: {
heading: "Ein neues Lied für dich!",
lead: "Du hast Musik freigespielt",
glyph: "🎵",
},
book: {
heading: "Ein neues Hörbuch für dich!",
lead: "Du hast eine Geschichte freigespielt",
glyph: "📖",
},
podcast_episode: {
heading: "Eine neue Folge für dich!",
lead: "Du hast eine Folge freigespielt",
glyph: "🎙️",
},
};
const CONFETTI_COLOURS = [
"oklch(85% 0.17 88)",
"oklch(78% 0.16 340)",
"oklch(82% 0.15 150)",
"oklch(80% 0.14 220)",
"oklch(88% 0.13 60)",
"oklch(75% 0.17 300)",
];
const CONFETTI_COUNT = 34;
interface Props {
reward: UnlockedReward;
/** Honours her own sound setting - the same flag that silences every other sound in
* the app. A silent celebration still celebrates. */
sound: boolean;
onClose: () => void;
}
export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
const reducedMotion = usePrefersReducedMotion();
const [phase, setPhase] = useState<Phase>(reducedMotion ? "reveal" : "drop");
const button = useRef<HTMLButtonElement>(null);
const copy = COPY[reward.kind];
// Fixed per mount, so a re-render (the button focusing, say) never reshuffles the
// confetti mid-fall.
const confetti = useMemo(
() => (reducedMotion ? [] : makeConfetti()),
[reducedMotion],
);
useEffect(() => {
if (reducedMotion) {
if (sound) playRewardJingle();
return;
}
if (sound) playChestThud();
const timers = [
window.setTimeout(() => {
setPhase("open");
if (sound) {
playChestOpen();
playRewardJingle();
}
}, OPEN_AT),
window.setTimeout(() => setPhase("reveal"), REVEAL_AT),
];
return () => timers.forEach(window.clearTimeout);
}, [reducedMotion, sound]);
// The button only exists from "reveal" on, so this focuses it as it appears. Taking
// focus matters: the result sheet underneath grabbed it for its own "Nochmal" button
// when it mounted, and Enter must not restart the run from behind this screen.
useEffect(() => {
if (phase === "reveal") button.current?.focus();
}, [phase]);
const open = phase !== "drop";
const revealed = phase === "reveal";
return (
<div
className="tp-reward-overlay"
role="dialog"
aria-label={`${copy.heading} ${reward.title}`}
onClick={onClose}
>
{confetti.map((piece, i) => (
<span
key={i}
className="tp-confetti-piece"
style={{
left: piece.left,
width: piece.width,
height: piece.height,
background: piece.colour,
borderRadius: piece.round ? "50%" : 2,
animationDelay: `${OPEN_AT + piece.delay}ms`,
animationDuration: `${piece.duration}ms`,
["--tp-drift" as string]: piece.drift,
["--tp-spin" as string]: piece.spin,
}}
/>
))}
<div
style={{
fontSize: 15,
fontWeight: 800,
color: "var(--paper)",
opacity: revealed ? 0.75 : 0,
transition: "opacity 300ms",
}}
>
{copy.lead}
</div>
{/* The chest and the cover share one stacking box, and the cover is sandwiched
between the chest's two halves - see TreasureChest's `layer`. That is what makes
the cover come *out of* the chest rather than float in front of a picture of
one: it rises from behind the chest's front wall, under the thrown-back lid. */}
<div
style={{
position: "relative",
width: CHEST_SIZE,
height: CHEST_SIZE,
// Room above for the cover, which overhangs this box by most of its height.
marginTop: COVER_SIZE * 0.78,
animation: open
? undefined
: "chestDrop 520ms cubic-bezier(0.34, 1.4, 0.64, 1), chestRattle 420ms ease-in-out 540ms 2",
}}
>
<TreasureChest size={CHEST_SIZE} open={open} layer="back" style={{ position: "absolute", inset: 0 }} />
{open && !reducedMotion && <Rays />}
{revealed && (
<div
style={{
position: "absolute",
left: "50%",
// Deep enough that the cover's foot is hidden behind the front wall.
bottom: CHEST_SIZE * 0.5,
transform: "translateX(-50%)",
}}
>
<RewardArt reward={reward} glyph={copy.glyph} />
</div>
)}
<TreasureChest size={CHEST_SIZE} open={open} layer="front" style={{ position: "absolute", inset: 0 }} />
</div>
<div
style={{
fontSize: 27,
fontWeight: 900,
color: "var(--paper)",
marginTop: 16,
opacity: revealed ? 1 : 0,
animation: revealed
? "rewardTextEnter 360ms ease-out 80ms both"
: undefined,
}}
>
{copy.heading}
</div>
<div
style={{
fontSize: 19,
fontWeight: 800,
color: "oklch(90% 0.13 92)",
maxWidth: 460,
lineHeight: 1.3,
marginTop: 4,
opacity: revealed ? 1 : 0,
animation: revealed
? "rewardTextEnter 360ms ease-out 200ms both"
: undefined,
}}
>
{reward.title}
</div>
<div
style={{
fontSize: 14,
fontWeight: 700,
color: "var(--paper)",
opacity: revealed ? 0.7 : 0,
marginTop: 8,
transition: "opacity 300ms 400ms",
}}
>
Ab jetzt im Musik-Player 🎧
</div>
{revealed && (
<button
ref={button}
onClick={(event) => {
event.stopPropagation();
onClose();
}}
style={{
border: "none",
borderRadius: 999,
padding: "14px 30px",
fontSize: 18,
fontWeight: 900,
cursor: "pointer",
background: "var(--accent)",
color: "var(--paper)",
boxShadow: "0 8px 24px var(--shadow)",
marginTop: 22,
animation: "rewardTextEnter 360ms ease-out 320ms both",
}}
>
Toll!
</button>
)}
</div>
);
}
/** The cover art, or a glyph when the album has none. */
function RewardArt({
reward,
glyph,
}: {
reward: UnlockedReward;
glyph: string;
}) {
const [failed, setFailed] = useState(false);
const shared: React.CSSProperties = {
width: COVER_SIZE,
height: COVER_SIZE,
borderRadius: 18,
border: "3px solid oklch(90% 0.13 92 / 0.9)",
animation:
"coverRise 760ms cubic-bezier(0.22, 1.2, 0.36, 1) both, coverHalo 2.6s ease-in-out 760ms infinite",
};
// A cover the backend said exists but that fails to load would otherwise leave a
// broken-image box as the centrepiece of the celebration.
if (!reward.hasCover || failed) {
return (
<div
style={{
...shared,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 92,
background:
"linear-gradient(160deg, oklch(70% 0.09 175), oklch(45% 0.08 175))",
}}
>
{glyph}
</div>
);
}
return (
<img
src={coverUrl(reward.albumId)}
alt=""
onError={() => setFailed(true)}
style={{
...shared,
objectFit: "cover",
background: "oklch(45% 0.08 175)",
}}
/>
);
}
/** The burst of light at the moment the lid lets go. Two counter-rotating stars of rays,
* so the burst reads as light rather than as a spinning shape. */
function Rays() {
return (
<div
aria-hidden
style={{
position: "absolute",
left: "50%",
top: "18%",
width: 260,
height: 260,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
}}
>
{[0, 1].map((layer) => (
<div
key={layer}
style={{
position: "absolute",
inset: 0,
animation: `rayBurst ${layer === 0 ? 900 : 1150}ms ease-out ${layer * 90}ms both`,
background: `repeating-conic-gradient(from ${layer * 11}deg, oklch(95% 0.14 92 / 0.55) 0deg 5deg, transparent 5deg 22deg)`,
maskImage:
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
WebkitMaskImage:
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
}}
/>
))}
</div>
);
}
interface ConfettiPiece {
left: string;
width: number;
height: number;
colour: string;
round: boolean;
delay: number;
duration: number;
drift: string;
spin: string;
}
function makeConfetti(): ConfettiPiece[] {
return Array.from({ length: CONFETTI_COUNT }, (_, i) => {
const round = i % 3 === 0;
const size = 7 + Math.random() * 8;
return {
left: `${Math.random() * 100}%`,
width: round ? size : size * 0.55,
height: size,
colour: CONFETTI_COLOURS[i % CONFETTI_COLOURS.length]!,
round,
// Spread over a second and a half, so it falls as a shower rather than as a line.
delay: Math.random() * 1500,
duration: 2400 + Math.random() * 2200,
drift: `${(Math.random() - 0.5) * 220}px`,
spin: `${(Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 720)}deg`,
};
});
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false,
);
useEffect(() => {
const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!query) return;
const onChange = () => setReduced(query.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return reduced;
}

View File

@@ -0,0 +1,149 @@
/** A treasure chest whose lid actually opens.
*
* Drawn rather than set as an emoji for one reason: the lid has to be a separate group
* so it can swing on a hinge. A 🎁 can only ever pop and scale, which is exactly the
* animation the old reward badge had and exactly why nobody noticed it. It is also
* sharp at every size, which matters because the same component draws both the 46px
* badge on the lesson map and the 168px chest in the unlock overlay.
*
* `open` is a plain prop, not an animation trigger: the lid transitions to its open
* angle through CSS (`.tp-chest-lid`, tippen.css), so the map can render a permanently
* open chest for a claimed reward with no animation bookkeeping at all, and the overlay
* gets its swing for free just by flipping the prop mid-sequence.
*
* `layer` splits the drawing in two so that something can be put *inside* the chest. The
* lid swings up across the space above the chest, which is exactly where a rising cover
* wants to be; drawn as one piece, the lid crosses in front of the cover and the whole
* thing reads as a z-order bug. Drawn as "back" (lid, mouth, glow), then the cover, then
* "front" (the body), the cover emerges from behind the chest's own front wall - which is
* what coming out of a chest actually looks like. The map, which puts nothing inside, and
* whose shut chests need the lid painted over the body's top edge, takes the default. */
/** Instance-unique gradient ids. Two chests on one page (the map has many) must not share
* `<defs>` ids, or the second one silently paints with the first one's stops. */
let nextId = 0;
interface Props {
size: number;
open: boolean;
/** Which half to draw - see the header. "all" is the whole chest, in one element. */
layer?: "all" | "back" | "front";
/** Softened - a reward whose lesson is still ahead of her. Muted rather than greyed
* out: a shut chest still has to look like treasure, or the badge promises nothing. */
muted?: boolean;
className?: string;
style?: React.CSSProperties;
}
export function TreasureChest({ size, open, layer = "all", muted = false, className, style }: Props) {
const id = `tp-chest-${(nextId += 1)}`;
return (
<svg
width={size}
height={size}
viewBox="0 0 64 62"
className={className}
aria-hidden
style={{
overflow: "visible",
filter: muted ? "grayscale(0.35) brightness(0.9)" : undefined,
opacity: muted ? 0.9 : 1,
...style,
}}
>
<defs>
<linearGradient id={`${id}-wood`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="oklch(58% 0.11 55)" />
<stop offset="100%" stopColor="oklch(38% 0.09 45)" />
</linearGradient>
<linearGradient id={`${id}-lid`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="oklch(66% 0.11 58)" />
<stop offset="100%" stopColor="oklch(46% 0.1 48)" />
</linearGradient>
<linearGradient id={`${id}-gold`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="oklch(92% 0.14 95)" />
<stop offset="45%" stopColor="oklch(82% 0.17 88)" />
<stop offset="100%" stopColor="oklch(66% 0.15 80)" />
</linearGradient>
<radialGradient id={`${id}-glow`}>
<stop offset="0%" stopColor="oklch(97% 0.14 95 / 0.95)" />
<stop offset="55%" stopColor="oklch(90% 0.16 92 / 0.35)" />
<stop offset="100%" stopColor="oklch(90% 0.16 92 / 0)" />
</radialGradient>
</defs>
{/* What makes it read as *open* rather than as a chest with its lid drawn beside
it: a dark mouth, with light coming out of it. Both are painted before the body,
so the body's front wall covers their lower half and the opening looks like a
hole in the chest rather than a shape on it. */}
{layer !== "front" && open && (
<>
<ellipse cx="32" cy="27" rx="24" ry="7" fill="oklch(22% 0.03 45)" />
<circle cx="32" cy="25" r="24" fill={`url(#${id}-glow)`} />
</>
)}
{layer === "back" && <Lid id={id} open={open} />}
{layer !== "back" && (
<>
<rect
x="6"
y="26"
width="52"
height="30"
rx="5"
fill={`url(#${id}-wood)`}
stroke="oklch(28% 0.06 45)"
strokeWidth="2"
/>
{/* Two gold straps down the body, and the band along its foot. */}
<rect x="13" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
<rect x="46.5" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
<rect x="6" y="47" width="52" height="4" fill={`url(#${id}-gold)`} opacity="0.7" />
{/* Lock plate. It stays on the body - a real chest's hasp swings with the lid,
but a lock that leaves with the lid loses the "this was shut" read. */}
<rect
x="26"
y="30"
width="12"
height="14"
rx="2.5"
fill={`url(#${id}-gold)`}
stroke="oklch(45% 0.1 75)"
strokeWidth="1.2"
/>
<circle cx="32" cy="36" r="2.2" fill="oklch(32% 0.05 60)" />
<rect x="31" y="36" width="2" height="5" rx="1" fill="oklch(32% 0.05 60)" />
</>
)}
{/* In one piece, the lid goes last: shut, it has to cover the body's top edge. */}
{layer === "all" && <Lid id={id} open={open} />}
</svg>
);
}
/** A half-round lid, hinged at its left end - the cartoon flip-open, and the only hinge
* that works in a flat, front-on view. */
function Lid({ id, open }: { id: string; open: boolean }) {
return (
<g className="tp-chest-lid" data-open={open || undefined} style={{ transformOrigin: "8px 27px" }}>
<path d="M8 27 A 24 21 0 0 1 56 27 Z" fill={`url(#${id}-lid)`} stroke="oklch(28% 0.06 45)" strokeWidth="2" />
<rect
x="7"
y="22.5"
width="50"
height="5"
rx="2"
fill={`url(#${id}-gold)`}
stroke="oklch(45% 0.1 75)"
strokeWidth="0.8"
/>
<path d="M13 27 A 24 21 0 0 1 15.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
<path d="M51 27 A 24 21 0 0 0 48.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
</g>
);
}

View File

@@ -1,144 +0,0 @@
/** Jellyfish mode - pure key location, nothing else.
*
* Six jellyfish drift in the water, each showing a letter. One of them glows: that is
* the one to zap. There is no line to read and no word to spell, so the only thing
* being exercised is "where does this letter live" - which is exactly the skill dive
* mode hides behind reading.
*
* The decoys matter. Showing only the target turns this into the bubble mode; showing
* five wrong letters next to it means she has to find *her* letter before she can type
* it, which is the searching step that eventually goes away. */
import { useMemo } from "react";
import { currentChar } from "../../../lib/tippen/engine";
import { fingerOf } from "../../../lib/tippen/fingers";
import { mulberry32 } from "../../../lib/tippen/generator";
import type { RunResult } from "../../../lib/tippen/grading";
import type { Progress } from "../../../lib/tippen/progress";
import { useRun } from "../../../hooks/useTippenRun";
import { Keyboard } from "../Keyboard";
interface Props {
letters: readonly string[];
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
/** How many jellyfish are in the water at once, target included. */
const JELLYFISH_COUNT = 6;
interface Jellyfish {
left: number;
top: number;
drift: number;
size: number;
}
export function JellyfishRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
const text = letters.join("");
const { state, wrong } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
});
const next = currentChar(state);
// Fixed positions, seeded once: jellyfish that jump to a new spot on every keystroke
// would make the searching step impossible rather than merely hard.
const spots = useMemo<Jellyfish[]>(() => {
const rng = mulberry32(text.length * 31 + 7);
return Array.from({ length: JELLYFISH_COUNT }, () => ({
left: 10 + rng() * 76,
top: 6 + rng() * 66,
drift: 3 + rng() * 3,
size: 74 + rng() * 26,
}));
}, [text]);
/** The decoys shown alongside the target: other active keys, never the target itself,
* and stable for as long as the target is. */
const decoys = useMemo(() => {
if (!next) return [];
const rng = mulberry32(state.index * 101 + 13);
const others = activeKeys.filter((key) => key !== next && key !== " ");
const shuffled = [...others].sort(() => rng() - 0.5);
return shuffled.slice(0, JELLYFISH_COUNT - 1);
}, [next, state.index, activeKeys]);
// Which jellyfish carries the target. Moves around so it is not always the same one.
const targetSlot = next ? state.index % JELLYFISH_COUNT : -1;
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
padding: "0 32px 8px",
minHeight: 0,
}}
>
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
{spots.map((spot, i) => {
const isTarget = i === targetSlot;
const letter = isTarget ? next : decoys[i > targetSlot ? i - 1 : i];
if (!letter) return null;
const finger = fingerOf(letter);
return (
<div
key={i}
className="tp-jellyfish"
style={{
position: "absolute",
left: `${spot.left}%`,
top: `${spot.top}%`,
width: spot.size,
height: spot.size,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: spot.size * (isTarget ? 0.4 : 0.3),
fontWeight: 900,
borderRadius: "50% 50% 42% 42%",
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
background: isTarget
? "var(--paper)"
: `linear-gradient(160deg, oklch(70% 0.13 ${finger?.hue ?? 175} / .38), oklch(45% 0.09 ${finger?.hue ?? 175} / .16))`,
border: `2px solid oklch(88% 0.07 ${finger?.hue ?? 175} / ${isTarget ? 0.9 : 0.3})`,
boxShadow: isTarget
? "0 0 34px oklch(97% 0.01 175 / .55), 0 10px 26px var(--shadow)"
: "0 4px 14px var(--shadow)",
opacity: isTarget ? 1 : 0.55,
transform: isTarget ? "scale(1.12)" : "scale(1)",
animation: `dolphinBob ${spot.drift}s ease-in-out infinite`,
transition: "opacity 200ms ease, transform 200ms ease, background 200ms ease",
}}
>
{letter === " " ? "␣" : letter}
{isTarget && wrong && (
<div style={{ position: "absolute", inset: -6, borderRadius: "50%", animation: "wrongShake 260ms ease" }} />
)}
</div>
);
})}
</div>
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={36}
/>
</div>
);
}

View File

@@ -85,7 +85,10 @@ export const TAB_RAIL_CLEARANCE = 88;
* smarthome tab only renders when Home Assistant is configured (see `App.tsx`); the * smarthome tab only renders when Home Assistant is configured (see `App.tsx`); the
* other two always show. */ * other two always show. */
export const PAGE_ICON: Record<UiState["page"], string> = { export const PAGE_ICON: Record<UiState["page"], string> = {
music: "🎵", // A plain Unicode symbol, not the 🎵 emoji: that glyph's own built-in colour is a
// muted grey-blue on at least one real platform, which is illegible against the tab
// rail's own glass background and does not respond to `color` the way text does.
music: "♪",
room: "💡", room: "💡",
typing: "⌨️", typing: "⌨️",
}; };

View File

@@ -67,9 +67,6 @@ describe("grade", () => {
expect(grade(state).errors).toBe(1); expect(grade(state).errors).toBe(1);
}); });
it("awards pearls even for a bad run", () => {
expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).pearls).toBeGreaterThan(0);
});
}); });
describe("stars", () => { describe("stars", () => {

View File

@@ -6,14 +6,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { focusKeyFor, mastery } from "../progress"; import { focusKeyFor, mastery, progressFromApi } from "../progress";
import type { Progress } from "../progress"; import type { Progress } from "../progress";
import type { TippenProgress as ApiProgress } from "../../../api/types";
function basicProgress(over: Partial<Progress> = {}): Progress { function basicProgress(over: Partial<Progress> = {}): Progress {
return { return {
lessons: {}, lessons: {},
keyStats: {}, keyStats: {},
pearls: 0,
aquarium: [], aquarium: [],
streak: { days: 0, lastPlayed: null }, streak: { days: 0, lastPlayed: null },
settings: { sound: true, keyboardHint: "auto" }, settings: { sound: true, keyboardHint: "auto" },
@@ -65,3 +65,26 @@ describe("mastery", () => {
expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5); expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5);
}); });
}); });
describe("progressFromApi", () => {
/** `earned` was silently dropped here once, which is not a visible bug anywhere except
* on the lesson map, where it decides whether a reward's treasure chest is drawn open.
* A dropped field fails no type check - the mapping is written out by hand - so it
* needs a test that names it. */
it("keeps the backend's derived `earned` flag", () => {
const api = {
lessons: {
won: { unlocked: true, runs: 3, best_stars: 3, best_animal: "delfin", best_points: 9, earned: true, ghost: null },
open: { unlocked: true, runs: 1, best_stars: 1, best_animal: "krabbe", best_points: 2, earned: false, ghost: null },
},
key_stats: {},
aquarium: [],
streak: { days: 2, last_played: null },
settings: { sound: true, keyboard_hint: "auto" },
} as unknown as ApiProgress;
const progress = progressFromApi(api);
expect(progress.lessons.won?.earned).toBe(true);
expect(progress.lessons.open?.earned).toBe(false);
});
});

View File

@@ -13,7 +13,7 @@ import type {
import type { CreatureId } from "./aquarium"; import type { CreatureId } from "./aquarium";
export type LessonKind = "letters" | "fragments" | "words" | "sentences"; export type LessonKind = "letters" | "fragments" | "words" | "sentences";
export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race"; export type ModeId = "dive" | "bubbles" | "feed" | "race";
/** What this lesson unlocks in the music library, if anything - see `rewards.py`. /** What this lesson unlocks in the music library, if anything - see `rewards.py`.
* `resolved: false` means the curriculum names a path that matches nothing right now. */ * `resolved: false` means the curriculum names a path that matches nothing right now. */

View File

@@ -117,7 +117,7 @@ export function press(state: RunState, key: string, now: number): [RunState, Run
} }
/** Give up on the rest of the line - what Escape does. The run is still graded on what /** Give up on the rest of the line - what Escape does. The run is still graded on what
* was typed, so a half-finished bubbles round still earns its pearls. */ * was typed, so a half-finished bubbles round still earns its stars. */
export function abandonRun(state: RunState, now: number): RunState { export function abandonRun(state: RunState, now: number): RunState {
if (isFinished(state) || state.startedAt === null) return state; if (isFinished(state) || state.startedAt === null) return state;
return { ...state, finishedAt: now }; return { ...state, finishedAt: now };

View File

@@ -189,7 +189,7 @@ export function wordChunks(
} }
/** The line a lesson should show, given what it can spell. Word lessons alternate: /** The line a lesson should show, given what it can spell. Word lessons alternate:
* `preferWords` lets a mode ask for letters even in a late lesson (jellyfish mode is * `preferWords` lets a mode ask for letters even in a late lesson (bubbles mode is
* always single letters) or for words wherever they exist (feed mode). */ * always single letters) or for words wherever they exist (feed mode). */
export function lineFor( export function lineFor(
lesson: { activeKeys: readonly string[]; words: readonly string[]; newKeys?: readonly string[] }, lesson: { activeKeys: readonly string[]; words: readonly string[]; newKeys?: readonly string[] },
@@ -222,8 +222,8 @@ export function chunkOffsets(chunks: readonly string[], spaceActive: boolean): n
return offsets; return offsets;
} }
/** Single letters for the bubbles and jellyfish modes: one key per bubble, drawn from /** Single letters for bubbles mode: one key per bubble, drawn from the same bag, so the
* the same bag, so the arcade modes drill the same spread as the dive mode. */ * arcade mode drills the same spread as the dive mode. */
export function letterStream( export function letterStream(
activeKeys: readonly string[], activeKeys: readonly string[],
rng: Rng, rng: Rng,

View File

@@ -35,8 +35,6 @@ export interface RunResult {
animal: AnimalId; animal: AnimalId;
/** Whether this run unlocks the next lesson on its own. */ /** Whether this run unlocks the next lesson on its own. */
passed: boolean; passed: boolean;
/** Pearls earned - the aquarium currency. */
pearls: number;
/** Kept for the race-mode ghost and the per-key stats. */ /** Kept for the race-mode ghost and the per-key stats. */
strokes: readonly Stroke[]; strokes: readonly Stroke[];
} }
@@ -162,12 +160,6 @@ export function isPassed(accuracy: number): boolean {
return accuracy >= STAR_THRESHOLDS.two; return accuracy >= STAR_THRESHOLDS.two;
} }
/** One pearl per five correct keys, plus a bonus per star. Small numbers that go up
* every single run, including a bad one - the aquarium should never stall. */
function pearlsFor(characters: number, stars: number): number {
return Math.floor(characters / 5) + stars * 2;
}
export function grade(state: RunState): RunResult { export function grade(state: RunState): RunResult {
const characters = state.strokes.filter((stroke) => stroke.correct).length; const characters = state.strokes.filter((stroke) => stroke.correct).length;
const errors = state.missed.size; const errors = state.missed.size;
@@ -196,7 +188,6 @@ export function grade(state: RunState): RunResult {
stars, stars,
animal: animalFor(points).id, animal: animalFor(points).id,
passed: isPassed(accuracy), passed: isPassed(accuracy),
pearls: pearlsFor(characters, stars),
strokes: state.strokes, strokes: state.strokes,
}; };
} }

View File

@@ -6,7 +6,6 @@ import type { ModeId } from "./curriculum";
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = { export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
dive: { emoji: "🤿", name: "Tauchgang" }, dive: { emoji: "🤿", name: "Tauchgang" },
bubbles: { emoji: "🫧", name: "Blasenplatzen" }, bubbles: { emoji: "🫧", name: "Blasenplatzen" },
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
feed: { emoji: "🐟", name: "Fütterungszeit" }, feed: { emoji: "🐟", name: "Fütterungszeit" },
race: { emoji: "🐬", name: "Delfinrennen" }, race: { emoji: "🐬", name: "Delfinrennen" },
}; };

View File

@@ -54,3 +54,114 @@ export function playFanfare(): void {
window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110); window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110);
}); });
} }
/** One scheduled note of a tune. `at`/`duration` are seconds relative to the tune's
* own start, so a melody reads as a score rather than as nested `setTimeout`s - which
* also keeps the notes sample-accurate against each other instead of drifting by
* however late the event loop happened to be. */
interface Note {
/** Hz. */
hz: number;
at: number;
duration: number;
gain?: number;
type?: OscillatorType;
}
function playTune(notes: readonly Note[]): void {
try {
context ??= new AudioContext();
// Resuming matters here specifically: this tune plays at the end of a run, and on
// some browsers the context created during the run's first keystroke is suspended
// again by the time the result screen opens.
void context.resume?.();
const start = context.currentTime + 0.02;
for (const note of notes) {
const oscillator = context.createOscillator();
const gain = context.createGain();
const peak = note.gain ?? 0.12;
const from = start + note.at;
oscillator.type = note.type ?? "triangle";
oscillator.frequency.setValueAtTime(note.hz, from);
// A short attack rather than an instant one: a hard start on a triangle wave
// clicks, and a click in a reward jingle sounds like a fault.
gain.gain.setValueAtTime(0.0001, from);
gain.gain.exponentialRampToValueAtTime(peak, from + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, from + note.duration);
oscillator.connect(gain).connect(context.destination);
oscillator.start(from);
oscillator.stop(from + note.duration + 0.02);
}
} catch {
// Same as `blip`: no audio context, no sound, never an exception.
}
}
const C5 = 523.25;
const D5 = 587.33;
const E5 = 659.25;
const F5 = 698.46;
const G5 = 783.99;
const A5 = 880;
const C6 = 1046.5;
const E6 = 1318.5;
const G6 = 1568;
const C4 = 261.63;
const E4 = 329.63;
const G4 = 392;
const G3 = 196;
/** The unlock tune: about two seconds of unambiguous "you got something".
*
* Deliberately a whole melody rather than one more blip. `playFanfare` already marks
* every smaller win in this app - a new lesson, a new animal, a new aquarium creature -
* so if opening real music sounded like that too, the biggest reward in the game would
* be the one thing she could not hear coming. This one is longer, has a bass line under
* it and lands on a held major chord, which is what makes it read as an arrival.
*
* C major throughout: a rising C-E-G-C run, a little D-E-F-G turn over it, then the
* tonic triad plus its octave held together over a low C. */
export function playRewardJingle(): void {
playTune([
// The run up.
{ hz: C5, at: 0, duration: 0.16 },
{ hz: E5, at: 0.12, duration: 0.16 },
{ hz: G5, at: 0.24, duration: 0.16 },
{ hz: C6, at: 0.36, duration: 0.26 },
// The turn - the bit that makes it a tune instead of an arpeggio.
{ hz: A5, at: 0.62, duration: 0.13 },
{ hz: G5, at: 0.74, duration: 0.13 },
{ hz: A5, at: 0.86, duration: 0.13 },
{ hz: C6, at: 0.98, duration: 0.22 },
// The arrival: a held triad, with the bass under it.
{ hz: C5, at: 1.24, duration: 0.95, gain: 0.1 },
{ hz: E5, at: 1.24, duration: 0.95, gain: 0.09 },
{ hz: G5, at: 1.24, duration: 0.95, gain: 0.09 },
{ hz: C6, at: 1.24, duration: 0.95, gain: 0.08 },
{ hz: C4, at: 1.24, duration: 1, gain: 0.09, type: "sine" },
{ hz: G3, at: 1.24, duration: 1, gain: 0.07, type: "sine" },
// Sparkles over the held chord, as the cover comes out of the chest.
{ hz: E6, at: 1.4, duration: 0.2, gain: 0.05, type: "sine" },
{ hz: G6, at: 1.56, duration: 0.2, gain: 0.045, type: "sine" },
{ hz: C6 * 2, at: 1.72, duration: 0.3, gain: 0.04, type: "sine" },
]);
}
/** The chest landing on the screen, just before its lid opens: two low thuds. Pitched
* well below the jingle so it reads as a thing arriving, not as a note. */
export function playChestThud(): void {
playTune([
{ hz: 150, at: 0, duration: 0.12, gain: 0.1, type: "sine" },
{ hz: 110, at: 0.14, duration: 0.16, gain: 0.09, type: "sine" },
]);
}
/** The lid coming open: a bright upward creak-and-pop. */
export function playChestOpen(): void {
playTune([
{ hz: G4, at: 0, duration: 0.1, gain: 0.07 },
{ hz: E4 * 2, at: 0.06, duration: 0.12, gain: 0.07 },
{ hz: D5 * 2, at: 0.13, duration: 0.14, gain: 0.06 },
{ hz: F5 * 2, at: 0.2, duration: 0.2, gain: 0.05, type: "sine" },
]);
}

View File

@@ -19,6 +19,11 @@ export interface LessonProgress {
bestStars: 0 | 1 | 2 | 3; bestStars: 0 | 1 | 2 | 3;
bestAnimal: AnimalId | null; bestAnimal: AnimalId | null;
bestPoints: number; bestPoints: number;
/** Passed on its own merits, or given up on gracefully after enough tries - derived
* server-side from `bestStars`/`runs`, see `progress.py`. This, not `bestStars`, is
* what decides whether a lesson's `unlocks:` reward has actually been claimed, so the
* map draws its treasure chest from this. */
earned: boolean;
/** Best-run keystrokes, replayed as the opponent in race mode. */ /** Best-run keystrokes, replayed as the opponent in race mode. */
ghost: { key: string; at: number }[] | null; ghost: { key: string; at: number }[] | null;
} }
@@ -37,7 +42,6 @@ export interface Settings {
export interface Progress { export interface Progress {
lessons: Record<string, LessonProgress>; lessons: Record<string, LessonProgress>;
keyStats: Record<string, KeyStat>; keyStats: Record<string, KeyStat>;
pearls: number;
/** Pets that have moved into the aquarium, in the order they arrived. */ /** Pets that have moved into the aquarium, in the order they arrived. */
aquarium: CreatureId[]; aquarium: CreatureId[];
streak: { days: number; lastPlayed: string | null }; streak: { days: number; lastPlayed: string | null };
@@ -53,6 +57,7 @@ export function progressFromApi(progress: ApiProgress): Progress {
bestStars: entry.best_stars, bestStars: entry.best_stars,
bestAnimal: entry.best_animal as AnimalId | null, bestAnimal: entry.best_animal as AnimalId | null,
bestPoints: entry.best_points, bestPoints: entry.best_points,
earned: entry.earned,
ghost: entry.ghost, ghost: entry.ghost,
}; };
} }
@@ -63,7 +68,6 @@ export function progressFromApi(progress: ApiProgress): Progress {
return { return {
lessons, lessons,
keyStats, keyStats,
pearls: progress.pearls,
aquarium: progress.aquarium as CreatureId[], aquarium: progress.aquarium as CreatureId[],
streak: { days: progress.streak.days, lastPlayed: progress.streak.last_played }, streak: { days: progress.streak.days, lastPlayed: progress.streak.last_played },
settings: { sound: progress.settings.sound, keyboardHint: progress.settings.keyboard_hint }, settings: { sound: progress.settings.sound, keyboardHint: progress.settings.keyboard_hint },
@@ -83,7 +87,6 @@ export function toRunInput(lessonId: string, result: RunResult): TippenRunInput
animal: result.animal, animal: result.animal,
points: result.points, points: result.points,
passed: result.passed, passed: result.passed,
pearls: result.pearls,
strokes, strokes,
}; };
} }

View File

@@ -31,9 +31,9 @@ export const SPEECH_DEFAULT = true;
export const SOUND_DEFAULT = true; export const SOUND_DEFAULT = true;
/** How many letters one bubbles or jellyfish round sends up - matched to the dive /** How many letters one bubbles round sends up - matched to the dive mode's length so a
* mode's length so a mode swap is not also a difficulty swap. Dive-mode line length * mode swap is not also a difficulty swap. Dive-mode line length lives on the lesson
* lives on the lesson itself (`lengthFor` in lib/curriculum.ts). */ * itself (`lengthFor` in lib/curriculum.ts). */
export function bubbleCountFor(world: number): number { export function bubbleCountFor(world: number): number {
return world === 1 ? 50 : 100; return world === 1 ? 50 : 100;
} }

View File

@@ -295,19 +295,156 @@
min-width: 0.9em; min-width: 0.9em;
} }
/* A dome plus three trailing tentacles. Without them the jellyfish read as plain /* The world-reward pets, shown small in the map's header: one per world, in colour once
circles, and jellyfish mode stops being a picture of anything. Drawn in CSS rather it has moved in and as a white silhouette until then. The same "the next thing has to
than as an emoji so the letter stays centred and legible inside the dome. */ be visible to be worth aiming at" rule as the animal ladder on the result sheet. */
.tp-jellyfish::after { .tp-map-creatures {
content: ""; display: flex;
position: absolute; align-items: center;
bottom: -11px; gap: 4px;
left: 26%; }
right: 26%;
height: 16px; @media (max-width: 520px) {
opacity: 0.55; /* The streak and the best animal say more per pixel on a narrow screen. */
background: .tp-map-creatures {
linear-gradient(currentColor, transparent) left / 3px 100% no-repeat, display: none;
linear-gradient(currentColor, transparent) center / 3px 100% no-repeat, }
linear-gradient(currentColor, transparent) right / 3px 100% no-repeat; }
/* ------------------------------------------------------- the unlock celebration
What happens when a lesson opens real music (RewardUnlockOverlay.tsx). Its own
section because it is the only thing in this app that deliberately interrupts: it
sits above the result sheet and has to be waited out, since the whole point of the
typing game - for a six-year-old - is that it hands back songs. Everything here is
staged rather than simultaneous; the sequence is what makes it read as a chest being
opened rather than as a dialog appearing. */
/* Above .tp-overlay's own layer: the result sheet is already on screen underneath, and
is meant to be revealed when this closes rather than replaced by it. */
.tp-reward-overlay {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 24px;
text-align: center;
overflow: hidden;
background: radial-gradient(
circle at 50% 42%,
oklch(45% 0.07 175 / 0.82) 0%,
oklch(16% 0.04 175 / 0.94) 70%
);
animation: backdropEnter 260ms ease-out;
/* Thrown right back, out of the column the cover rises through - see the note on
.tp-chest-lid. Alone this would look like a lid coming off; with the cover standing
in the middle it reads as the lid flung open to make room for it. */
--tp-chest-open: -108deg;
}
/* The lid's two states. Transitioned rather than keyframed so the map can draw a
permanently open chest with no animation at all - see TreasureChest.tsx. */
.tp-chest-lid {
transform-box: view-box;
transform: rotate(0deg);
transition: transform 620ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* How far the lid swings. The default is for a chest standing on its own (the lesson
map): far enough back to be unmistakably open, but not so far that it stops looking
attached to its hinge - a lid much past -90deg, alone, reads as a flap flying off.
The overlay overrides it above, because there the lid has a cover rising through the
middle of the chest and has to clear out of its way. */
.tp-chest-lid[data-open] {
transform: rotate(var(--tp-chest-open, -62deg));
}
/* The chest arriving: dropped in, with a squash on landing. */
@keyframes chestDrop {
0% { opacity: 0; transform: translateY(-120px) scale(0.7); }
55% { opacity: 1; transform: translateY(0) scale(1.06, 0.9); }
75% { transform: translateY(-6px) scale(0.98, 1.04); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
/* The wait before the lid goes: the chest rattling, because something inside wants out.
This is the beat that earns the opening - a lid that opens the instant the chest lands
is just a transition, not a reveal. */
@keyframes chestRattle {
0%, 100% { transform: translateX(0) rotate(0deg); }
15% { transform: translateX(-3px) rotate(-2.5deg); }
30% { transform: translateX(3px) rotate(2.5deg); }
45% { transform: translateX(-2px) rotate(-1.5deg); }
60% { transform: translateX(2px) rotate(1.5deg); }
80% { transform: translateX(-1px) rotate(-0.5deg); }
}
/* The burst of light at the moment the lid lets go. */
@keyframes rayBurst {
0% { opacity: 0; transform: scale(0.2) rotate(0deg); }
25% { opacity: 0.9; }
100% { opacity: 0; transform: scale(2.4) rotate(140deg); }
}
/* The cover art coming up out of the chest and settling where it can be looked at.
Overshoots on the way up - a reward that rises and stops dead looks placed, not
thrown. */
@keyframes coverRise {
0% { opacity: 0; transform: translateY(46px) scale(0.28) rotate(-14deg); }
45% { opacity: 1; transform: translateY(-26px) scale(1.12) rotate(5deg); }
70% { transform: translateY(2px) scale(0.97) rotate(-2deg); }
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0deg); }
}
/* The cover's own halo, once it has settled - a slow pulse, so the thing she just won
keeps drawing the eye while the text and the button arrive under it. */
@keyframes coverHalo {
0%, 100% { box-shadow: 0 10px 34px var(--shadow), 0 0 0 0 oklch(92% 0.15 92 / 0.55); }
50% { box-shadow: 0 10px 34px var(--shadow), 0 0 42px 10px oklch(92% 0.15 92 / 0.28); }
}
@keyframes rewardTextEnter {
0% { opacity: 0; transform: translateY(12px); }
100% { opacity: 1; transform: translateY(0); }
}
/* Confetti. Each piece gets its own left/delay/duration/colour/spin from inline style -
the keyframe only owns the fall. `--tp-drift` is how far sideways it wanders, so no
two pieces trace the same line down the screen. */
@keyframes confettiFall {
0% { opacity: 0; transform: translate(0, -8vh) rotate(0deg); }
8% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; transform: translate(var(--tp-drift, 0px), 104vh) rotate(var(--tp-spin, 540deg)); }
}
.tp-confetti-piece {
position: absolute;
top: 0;
will-change: transform;
animation-name: confettiFall;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: both;
}
@media (prefers-reduced-motion: reduce) {
/* The reveal still happens - it is information, not decoration - but nothing travels:
the chest is simply open, the cover simply there, and the confetti does not fall.
RewardUnlockOverlay also skips straight to its final step when this is set, so the
staging never leaves her waiting on animations that were turned off. */
.tp-reward-overlay,
.tp-reward-overlay * {
animation: none !important;
}
.tp-chest-lid {
transition: none;
}
.tp-confetti-piece {
display: none;
}
} }