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>
This commit is contained in:
2026-09-19 18:10:11 +02:00
parent e6f6b15cc7
commit f3337975c2
9 changed files with 1060 additions and 99 deletions

View File

@@ -6,8 +6,9 @@
import { describe, expect, it } from "vitest";
import { focusKeyFor, mastery } from "../progress";
import { focusKeyFor, mastery, progressFromApi } from "../progress";
import type { Progress } from "../progress";
import type { TippenProgress as ApiProgress } from "../../../api/types";
function basicProgress(over: Partial<Progress> = {}): Progress {
return {
@@ -64,3 +65,26 @@ describe("mastery", () => {
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

@@ -54,3 +54,114 @@ export function playFanfare(): void {
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;
bestAnimal: AnimalId | null;
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. */
ghost: { key: string; at: number }[] | null;
}
@@ -52,6 +57,7 @@ export function progressFromApi(progress: ApiProgress): Progress {
bestStars: entry.best_stars,
bestAnimal: entry.best_animal as AnimalId | null,
bestPoints: entry.best_points,
earned: entry.earned,
ghost: entry.ghost,
};
}