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>
This commit is contained in:
2026-09-13 00:38:19 +02:00
parent a69db98241
commit 75b0eed080
10 changed files with 20 additions and 197 deletions

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"),
@@ -230,9 +230,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 +257,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]

View File

@@ -244,11 +244,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

@@ -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. */

View File

@@ -27,7 +27,6 @@ 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 type { Lesson, ModeId } from "../lib/tippen/curriculum"; import type { Lesson, ModeId } from "../lib/tippen/curriculum";
@@ -42,12 +41,12 @@ import { bubbleCountFor } from "../lib/tippen/theme";
type Screen = "aquarium" | "map" | "run"; type Screen = "aquarium" | "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 };
@@ -315,11 +314,7 @@ export function TippenApp({ onExit }: Props) {
{screen === "run" && lesson && run && ( {screen === "run" && lesson && run && (
<> <>
{run.kind === "letters" ? ( {run.kind === "letters" ? (
mode === "jellyfish" ? (
<JellyfishRun {...letterProps(run.letters)} />
) : (
<BubblesRun {...letterProps(run.letters)} /> <BubblesRun {...letterProps(run.letters)} />
)
) : mode === "feed" ? ( ) : mode === "feed" ? (
<FeedRun {...textProps(run)} /> <FeedRun {...textProps(run)} />
) : mode === "race" ? ( ) : mode === "race" ? (

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -294,20 +294,3 @@
.tp-target-char[data-blank="true"] { .tp-target-char[data-blank="true"] {
min-width: 0.9em; min-width: 0.9em;
} }
/* A dome plus three trailing tentacles. Without them the jellyfish read as plain
circles, and jellyfish mode stops being a picture of anything. Drawn in CSS rather
than as an emoji so the letter stays centred and legible inside the dome. */
.tp-jellyfish::after {
content: "";
position: absolute;
bottom: -11px;
left: 26%;
right: 26%;
height: 16px;
opacity: 0.55;
background:
linear-gradient(currentColor, transparent) left / 3px 100% no-repeat,
linear-gradient(currentColor, transparent) center / 3px 100% no-repeat,
linear-gradient(currentColor, transparent) right / 3px 100% no-repeat;
}