From 75b0eed0807e564cfd5d8641b93807222f94399f Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Sun, 13 Sep 2026 00:38:19 +0200 Subject: [PATCH] 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 --- .../musicmouse/tippen/curriculum.py | 19 +-- .../tests/test_tippen_curriculum.py | 9 +- web/src/api/types.ts | 2 +- web/src/components/TippenApp.tsx | 11 +- .../components/tippen/modes/JellyfishRun.tsx | 144 ------------------ web/src/lib/tippen/curriculum.ts | 2 +- web/src/lib/tippen/generator.ts | 6 +- web/src/lib/tippen/modeInfo.ts | 1 - web/src/lib/tippen/theme.ts | 6 +- web/src/styles/tippen.css | 17 --- 10 files changed, 20 insertions(+), 197 deletions(-) delete mode 100644 web/src/components/tippen/modes/JellyfishRun.tsx diff --git a/python-backend/musicmouse/tippen/curriculum.py b/python-backend/musicmouse/tippen/curriculum.py index a852cd1..bcfa26e 100644 --- a/python-backend/musicmouse/tippen/curriculum.py +++ b/python-backend/musicmouse/tippen/curriculum.py @@ -38,7 +38,7 @@ __all__ = [ ] 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"] #: 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 -#: 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, ...]]] = { - "letters": ("bubbles", "jellyfish"), + "letters": ("bubbles",), "fragments": ("dive", "feed"), "words": ("dive", "feed", "race"), "sentences": ("dive", "race"), @@ -230,9 +230,6 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]: lessons: list[Lesson] = [] active: 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 entry in world.lessons: @@ -260,16 +257,10 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]: else: emphasis = "mixed" - primary_mode: ModeId - 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" + primary_mode: ModeId = entry.mode or ("bubbles" if kind == "letters" else "dive") # 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( mode for mode in ELIGIBLE_MODES[kind] diff --git a/python-backend/tests/test_tippen_curriculum.py b/python-backend/tests/test_tippen_curriculum.py index fd18862..94c7eb5 100644 --- a/python-backend/tests/test_tippen_curriculum.py +++ b/python-backend/tests/test_tippen_curriculum.py @@ -244,11 +244,10 @@ def test_plays_a_mode_that_fits_its_kind(curriculum) -> None: assert mode in ELIGIBLE_MODES[lesson.kind], where -def test_never_plays_the_same_arcade_game_twice_in_a_row(curriculum) -> None: - arcade = [lesson for lesson in curriculum.lessons if lesson.kind == "letters"] - for i in range(1, len(arcade)): - where = f"{arcade[i].number}: {arcade[i].title}" - assert arcade[i].primary_mode != arcade[i - 1].primary_mode, where +def test_letters_rounds_always_play_bubbles(curriculum) -> None: + for lesson in curriculum.lessons: + if lesson.kind == "letters": + assert lesson.primary_mode == "bubbles", f"{lesson.number}: {lesson.title}" def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None: diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 2e8b660..a0d605d 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -158,7 +158,7 @@ export interface LircConfig { // Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`. 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 * configured path matches nothing in the current library. */ diff --git a/web/src/components/TippenApp.tsx b/web/src/components/TippenApp.tsx index f061a46..24b06c1 100644 --- a/web/src/components/TippenApp.tsx +++ b/web/src/components/TippenApp.tsx @@ -27,7 +27,6 @@ import { Stage } from "./tippen/Stage"; import { BubblesRun } from "./tippen/modes/BubblesRun"; import { DiveRun } from "./tippen/modes/DiveRun"; import { FeedRun } from "./tippen/modes/FeedRun"; -import { JellyfishRun } from "./tippen/modes/JellyfishRun"; import { RaceRun } from "./tippen/modes/RaceRun"; import type { CreatureId } from "../lib/tippen/aquarium"; import type { Lesson, ModeId } from "../lib/tippen/curriculum"; @@ -42,12 +41,12 @@ import { bubbleCountFor } from "../lib/tippen/theme"; type Screen = "aquarium" | "map" | "run"; /** 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 = | { kind: "letters"; letters: readonly string[] } | { 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 }; @@ -315,11 +314,7 @@ export function TippenApp({ onExit }: Props) { {screen === "run" && lesson && run && ( <> {run.kind === "letters" ? ( - mode === "jellyfish" ? ( - - ) : ( - - ) + ) : mode === "feed" ? ( ) : mode === "race" ? ( diff --git a/web/src/components/tippen/modes/JellyfishRun.tsx b/web/src/components/tippen/modes/JellyfishRun.tsx deleted file mode 100644 index 59dc7f7..0000000 --- a/web/src/components/tippen/modes/JellyfishRun.tsx +++ /dev/null @@ -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(() => { - 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 ( -
-
- {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 ( -
- {letter === " " ? "␣" : letter} - {isTarget && wrong && ( -
- )} -
- ); - })} -
- - -
- ); -} diff --git a/web/src/lib/tippen/curriculum.ts b/web/src/lib/tippen/curriculum.ts index c1bbf7e..a344075 100644 --- a/web/src/lib/tippen/curriculum.ts +++ b/web/src/lib/tippen/curriculum.ts @@ -13,7 +13,7 @@ import type { import type { CreatureId } from "./aquarium"; 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`. * `resolved: false` means the curriculum names a path that matches nothing right now. */ diff --git a/web/src/lib/tippen/generator.ts b/web/src/lib/tippen/generator.ts index aa8797d..d2584ab 100644 --- a/web/src/lib/tippen/generator.ts +++ b/web/src/lib/tippen/generator.ts @@ -189,7 +189,7 @@ export function wordChunks( } /** 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). */ export function lineFor( 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; } -/** Single letters for the bubbles and jellyfish modes: one key per bubble, drawn from - * the same bag, so the arcade modes drill the same spread as the dive mode. */ +/** Single letters for bubbles mode: one key per bubble, drawn from the same bag, so the + * arcade mode drills the same spread as the dive mode. */ export function letterStream( activeKeys: readonly string[], rng: Rng, diff --git a/web/src/lib/tippen/modeInfo.ts b/web/src/lib/tippen/modeInfo.ts index 9c1e8bf..32c0d72 100644 --- a/web/src/lib/tippen/modeInfo.ts +++ b/web/src/lib/tippen/modeInfo.ts @@ -6,7 +6,6 @@ import type { ModeId } from "./curriculum"; export const MODE_INFO: Record = { dive: { emoji: "🤿", name: "Tauchgang" }, bubbles: { emoji: "🫧", name: "Blasenplatzen" }, - jellyfish: { emoji: "🦑", name: "Quallenalarm" }, feed: { emoji: "🐟", name: "Fütterungszeit" }, race: { emoji: "🐬", name: "Delfinrennen" }, }; diff --git a/web/src/lib/tippen/theme.ts b/web/src/lib/tippen/theme.ts index f4f7307..001deef 100644 --- a/web/src/lib/tippen/theme.ts +++ b/web/src/lib/tippen/theme.ts @@ -31,9 +31,9 @@ export const SPEECH_DEFAULT = true; export const SOUND_DEFAULT = true; -/** How many letters one bubbles or jellyfish round sends up - matched to the dive - * mode's length so a mode swap is not also a difficulty swap. Dive-mode line length - * lives on the lesson itself (`lengthFor` in lib/curriculum.ts). */ +/** How many letters one bubbles round sends up - matched to the dive mode's length so a + * mode swap is not also a difficulty swap. Dive-mode line length lives on the lesson + * itself (`lengthFor` in lib/curriculum.ts). */ export function bubbleCountFor(world: number): number { return world === 1 ? 50 : 100; } diff --git a/web/src/styles/tippen.css b/web/src/styles/tippen.css index 3300b02..b267485 100644 --- a/web/src/styles/tippen.css +++ b/web/src/styles/tippen.css @@ -294,20 +294,3 @@ .tp-target-char[data-blank="true"] { 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; -}