diff --git a/CLAUDE.md b/CLAUDE.md index 512eac1..aafaae5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,8 @@ Technology stack not yet documented. Will populate after codebase mapping or fir ## Conventions - Immer deutsche Umlaute (ä, ö, ü, Ä, Ö, Ü, ß) in UI-Texten verwenden, niemals ae/oe/ue Transliterierung. +- Kein No-Op Code: Keine leeren Funktionen, keine Stubs, keine auskommentierten Blöcke. Wenn etwas entfernt wird, komplett entfernen — inklusive Aufrufe, Imports, Typen und Tests. +- Never predict the future: Kein Code "für später", keine Abstraktionen für hypothetische Anforderungen, keine Platzhalter-Interfaces. Nur implementieren was jetzt gebraucht wird. diff --git a/src/api/gemini.test.ts b/src/api/gemini.test.ts index d855988..16e8f5a 100644 --- a/src/api/gemini.test.ts +++ b/src/api/gemini.test.ts @@ -1,11 +1,6 @@ -import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GeminiConfig } from "./config"; -import { - checkRateLimit, - generateImage, - generateText, -} from "./gemini"; +import { generateImage, generateText } from "./gemini"; const TEST_CONFIG: GeminiConfig = { geminiApiKey: "test-api-key", @@ -13,8 +8,6 @@ const TEST_CONFIG: GeminiConfig = { imageModel: "gemini-3.1-flash-preview-image", }; -let testCounter = 0; - function makeGeminiTextResponse(text: string) { return { candidates: [ @@ -206,12 +199,4 @@ describe("generateImage", () => { const result = await generateImage("a tree", [], TEST_CONFIG); expect(result).toBeNull(); }); -}); - -describe("Rate limiting (disabled)", () => { - it("checkRateLimit always returns true (no limits)", async () => { - const db = {} as IDBDatabase; - expect(await checkRateLimit(db, "text")).toBe(true); - expect(await checkRateLimit(db, "image")).toBe(true); - }); -}); +}); \ No newline at end of file diff --git a/src/api/gemini.ts b/src/api/gemini.ts index b7a206b..b577c0e 100644 --- a/src/api/gemini.ts +++ b/src/api/gemini.ts @@ -112,18 +112,3 @@ export async function generateImage( return null; } } - - -export async function checkRateLimit( - _db: IDBDatabase, - _type: "text" | "image", -): Promise { - return true; -} - -export async function incrementApiCall( - _db: IDBDatabase, - _type: "text" | "image", -): Promise { - // No-op: rate limits removed -} diff --git a/src/app.ts b/src/app.ts index dd1ff54..29c4547 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,7 @@ import { getForestElements, getProgress, openDB, saveProgress } from "./storage/ import type { ScreenName } from "./types"; import { initParentScreen, showParentCodePrompt } from "./ui/parent"; import { initLessonScreen, initWelcomeScreen } from "./ui/screens"; +import { levels } from "./game/levels"; let db: IDBDatabase; let lessonCleanup: (() => void) | null = null; @@ -26,8 +27,6 @@ export function getDB(): IDBDatabase { return db; } -const MAX_LEVEL = 6; - /** * Ensure progress object has all fields (migration for older saved data). */ @@ -39,6 +38,16 @@ function migrateProgress( if (progress.totalCorrect === undefined) progress.totalCorrect = 0; if (progress.totalErrors === undefined) progress.totalErrors = 0; if (progress.currentForestPage === undefined) progress.currentForestPage = 0; + + // Migration: advance currentLevel if it was previously capped at a completed level. + // This fixes saved data from before levels 7-16 existed, where MAX_LEVEL was 6. + const maxLevel = levels[levels.length - 1]?.level ?? 16; + if ( + progress.completedLevels.includes(progress.currentLevel) && + progress.currentLevel < maxLevel + ) { + progress.currentLevel = progress.currentLevel + 1; + } } async function handleLessonComplete( @@ -74,10 +83,11 @@ async function handleLessonComplete( } // Advance to next level if this was the current level (LEVL-02, LEVL-03) - // Cap at MAX_LEVEL so we don't go beyond defined levels + // Cap at the total number of defined levels so we don't go beyond + const maxLevel = levels[levels.length - 1]?.level ?? 16; if ( completedLevel === progress.currentLevel && - completedLevel < MAX_LEVEL + completedLevel < maxLevel ) { progress.currentLevel = completedLevel + 1; } @@ -114,9 +124,7 @@ async function handleLessonComplete( progress.selectedCharacter, () => { // "Weiter üben" -- start next lesson - startLessonFromForest( - Math.min(progress.currentLevel, MAX_LEVEL), - ); + startLessonFromForest(progress.currentLevel); }, async () => { // "Zurück zum Wald" -- navigate to page containing the new element @@ -214,6 +222,8 @@ export async function initApp(): Promise { const progress = await getProgress(db); if (progress && progress.selectedCharacter) { migrateProgress(progress); + // Persist any migrations (e.g. currentLevel advancement) before rendering + await saveProgress(db, progress); // Returning user -- init forest and go there await initForestScreen(db, startLessonFromForest); @@ -235,7 +245,7 @@ export async function initApp(): Promise { greetingEl.style.display = "flex"; // Fetch greeting async (shows immediately with avatar, text fills in) - getGreeting(progress.selectedCharacter, db).then((text) => { + getGreeting(progress.selectedCharacter).then((text) => { textEl.innerHTML = renderInlineMarkdown(text); speakText(text); }); diff --git a/src/companion/companion.test.ts b/src/companion/companion.test.ts index dce25e7..c802fd9 100644 --- a/src/companion/companion.test.ts +++ b/src/companion/companion.test.ts @@ -1,6 +1,4 @@ -import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { openDB } from "../storage/db"; import type { GeminiConfig } from "../api/config"; // Mock modules before importing the module under test @@ -11,16 +9,10 @@ vi.mock("../api/config", () => ({ vi.mock("../api/gemini", () => ({ generateText: vi.fn(), - checkRateLimit: vi.fn(), - incrementApiCall: vi.fn(), })); import { loadConfig } from "../api/config"; -import { - checkRateLimit, - generateText, - incrementApiCall, -} from "../api/gemini"; +import { generateText } from "../api/gemini"; import { getGreeting, getLetterIntro, getForestComment } from "./companion"; const TEST_CONFIG: GeminiConfig = { @@ -29,13 +21,8 @@ const TEST_CONFIG: GeminiConfig = { imageModel: "gemini-3.1-flash-preview-image", }; -let testCounter = 0; -let db: IDBDatabase; - -beforeEach(async () => { +beforeEach(() => { vi.clearAllMocks(); - testCounter++; - db = await openDB(`companion-test-${testCounter}`); }); afterEach(() => { @@ -43,24 +30,22 @@ afterEach(() => { }); describe("getGreeting", () => { - it("returns AI text when API succeeds and rate limit not exceeded", async () => { + it("returns AI text when API succeeds", async () => { vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); - vi.mocked(checkRateLimit).mockResolvedValue(true); vi.mocked(generateText).mockResolvedValue( "Guten Morgen, kleiner Waldfreund!", ); - const result = await getGreeting("fee", db); + const result = await getGreeting("fee"); expect(result).toBe("Guten Morgen, kleiner Waldfreund!"); expect(generateText).toHaveBeenCalledTimes(1); - expect(incrementApiCall).toHaveBeenCalledWith(db, "text"); }); it("returns fallback text when loadConfig returns null (no API key)", async () => { vi.mocked(loadConfig).mockResolvedValue(null); - const result = await getGreeting("fee", db); + const result = await getGreeting("fee"); expect(result).toBeTruthy(); expect(typeof result).toBe("string"); @@ -69,32 +54,19 @@ describe("getGreeting", () => { it("returns fallback text when generateText returns null (API error)", async () => { vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); - vi.mocked(checkRateLimit).mockResolvedValue(true); vi.mocked(generateText).mockResolvedValue(null); - const result = await getGreeting("fee", db); + const result = await getGreeting("fee"); expect(result).toBeTruthy(); expect(typeof result).toBe("string"); - expect(incrementApiCall).not.toHaveBeenCalled(); - }); - - it("returns fallback text when rate limit exceeded", async () => { - vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); - vi.mocked(checkRateLimit).mockResolvedValue(false); - - const result = await getGreeting("fee", db); - - expect(result).toBeTruthy(); - expect(generateText).not.toHaveBeenCalled(); }); it("passes correct system prompt with companion personality and time of day", async () => { vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); - vi.mocked(checkRateLimit).mockResolvedValue(true); vi.mocked(generateText).mockResolvedValue("Hallo!"); - await getGreeting("fee", db); + await getGreeting("fee"); const systemPrompt = vi.mocked(generateText).mock.calls[0]![1]; expect(systemPrompt).toContain("Lila"); @@ -105,21 +77,11 @@ describe("getGreeting", () => { expect(systemPrompt).toContain("Max. 2 kurze Sätze"); }); - it("calls incrementApiCall after successful API call", async () => { - vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); - vi.mocked(checkRateLimit).mockResolvedValue(true); - vi.mocked(generateText).mockResolvedValue("Hallo Waldkind!"); - - await getGreeting("einhorn", db); - - expect(incrementApiCall).toHaveBeenCalledWith(db, "text"); - }); }); describe("getLetterIntro", () => { it("returns AI text when API succeeds", async () => { vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); - vi.mocked(checkRateLimit).mockResolvedValue(true); vi.mocked(generateText).mockResolvedValue( "Das F ist wie ein Farn im Wald!", ); @@ -128,11 +90,9 @@ describe("getLetterIntro", () => { "fee", "f", "linker Zeigefinger", - db, ); expect(result).toBe("Das F ist wie ein Farn im Wald!"); - expect(incrementApiCall).toHaveBeenCalledWith(db, "text"); }); it("returns fallback LetterIntro text when API unavailable", async () => { @@ -142,7 +102,6 @@ describe("getLetterIntro", () => { "fee", "f", "linker Zeigefinger", - db, ); expect(result).toBeTruthy(); @@ -152,10 +111,9 @@ describe("getLetterIntro", () => { it("system prompt includes letter, finger description, and companion personality", async () => { vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); - vi.mocked(checkRateLimit).mockResolvedValue(true); vi.mocked(generateText).mockResolvedValue("Das D wohnt hier!"); - await getLetterIntro("fuchs", "d", "linker Mittelfinger", db); + await getLetterIntro("fuchs", "d", "linker Mittelfinger"); const systemPrompt = vi.mocked(generateText).mock.calls[0]![1]; expect(systemPrompt).toContain("Finn"); @@ -171,7 +129,6 @@ describe("getLetterIntro", () => { "fee", "@", "linker kleiner Finger", - db, ); expect(result).toBe("Drücke die leuchtende Taste!"); @@ -181,7 +138,6 @@ describe("getLetterIntro", () => { describe("getForestComment", () => { it("returns AI text when API succeeds", async () => { vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); - vi.mocked(checkRateLimit).mockResolvedValue(true); vi.mocked(generateText).mockResolvedValue( "Oh, ein Schmetterling ist erschienen!", ); @@ -189,11 +145,9 @@ describe("getForestComment", () => { const result = await getForestComment( "eule", "ein blauer Schmetterling", - db, ); expect(result).toBe("Oh, ein Schmetterling ist erschienen!"); - expect(incrementApiCall).toHaveBeenCalledWith(db, "text"); }); it("returns fallback text when API unavailable", async () => { @@ -202,7 +156,6 @@ describe("getForestComment", () => { const result = await getForestComment( "eule", "ein blauer Schmetterling", - db, ); expect(result).toBeTruthy(); @@ -212,10 +165,9 @@ describe("getForestComment", () => { it("system prompt includes element description and companion personality", async () => { vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); - vi.mocked(checkRateLimit).mockResolvedValue(true); vi.mocked(generateText).mockResolvedValue("Wie wunderschön!"); - await getForestComment("einhorn", "ein leuchtender Pilz", db); + await getForestComment("einhorn", "ein leuchtender Pilz"); const systemPrompt = vi.mocked(generateText).mock.calls[0]![1]; expect(systemPrompt).toContain("Stella"); diff --git a/src/companion/companion.ts b/src/companion/companion.ts index 2e859f2..f42e807 100644 --- a/src/companion/companion.ts +++ b/src/companion/companion.ts @@ -2,14 +2,14 @@ * Companion text module: AI-generated or fallback text for greetings, * letter introductions, and forest comments. * - * Strategy: Try Gemini API first (with rate-limit check), fall back - * to curated static texts when API is unavailable. + * Strategy: Try Gemini API first, fall back to curated static texts + * when API is unavailable. */ import { companions } from "./characters"; import type { CompanionType } from "../types"; import { loadConfig } from "../api/config"; -import { generateText, checkRateLimit, incrementApiCall } from "../api/gemini"; +import { generateText } from "../api/gemini"; import { type TimeOfDay, getRandomFallbackGreeting, @@ -30,15 +30,11 @@ function getCompanion(type: CompanionType) { export async function getGreeting( characterType: CompanionType, - db: IDBDatabase, ): Promise { const timeOfDay = getTimeOfDay(); const config = await loadConfig(); if (!config) return getRandomFallbackGreeting(timeOfDay); - const canCall = await checkRateLimit(db, "text"); - if (!canCall) return getRandomFallbackGreeting(timeOfDay); - const companion = getCompanion(characterType); const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald. Du sprichst mit einem 7-jährigen Kind, das Tippen lernt. @@ -56,17 +52,13 @@ Regeln: systemPrompt, config, ); - if (!result) return getRandomFallbackGreeting(timeOfDay); - - await incrementApiCall(db, "text"); - return result; + return result ?? getRandomFallbackGreeting(timeOfDay); } export async function getLetterIntro( characterType: CompanionType, letter: string, fingerDescription: string, - db: IDBDatabase, ): Promise { const config = await loadConfig(); if (!config) { @@ -76,14 +68,6 @@ export async function getLetterIntro( ); } - const canCall = await checkRateLimit(db, "text"); - if (!canCall) { - return ( - getFallbackLetterIntro(letter)?.text ?? - "Drücke die leuchtende Taste!" - ); - } - const companion = getCompanion(characterType); const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald. Du stellst einem 7-jährigen Kind den Buchstaben ${letter} vor. @@ -100,28 +84,16 @@ Regeln: systemPrompt, config, ); - if (!result) { - return ( - getFallbackLetterIntro(letter)?.text ?? - "Drücke die leuchtende Taste!" - ); - } - - await incrementApiCall(db, "text"); - return result; + return result ?? getFallbackLetterIntro(letter)?.text ?? "Drücke die leuchtende Taste!"; } export async function getForestComment( characterType: CompanionType, elementDescription: string, - db: IDBDatabase, ): Promise { const config = await loadConfig(); if (!config) return getRandomFallbackForestComment(); - const canCall = await checkRateLimit(db, "text"); - if (!canCall) return getRandomFallbackForestComment(); - const companion = getCompanion(characterType); const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald. Ein Kind hat gerade eine Tippübung abgeschlossen. Im Wald ist ein neues Wesen erschienen: ${elementDescription}. @@ -138,8 +110,5 @@ Regeln: systemPrompt, config, ); - if (!result) return getRandomFallbackForestComment(); - - await incrementApiCall(db, "text"); - return result; + return result ?? getRandomFallbackForestComment(); } diff --git a/src/forest/reward.ts b/src/forest/reward.ts index 7012936..22980f8 100644 --- a/src/forest/reward.ts +++ b/src/forest/reward.ts @@ -7,11 +7,7 @@ */ import { loadConfig } from "../api/config"; -import { - generateImage, - checkRateLimit, - incrementApiCall, -} from "../api/gemini"; +import { generateImage } from "../api/gemini"; import { getStyleReference, saveForestElement, @@ -93,14 +89,6 @@ async function generateForestElement(db: IDBDatabase): Promise<{ description: string; prompt: string; }> { - // Check rate limit - const canGenerate = await checkRateLimit(db, "image"); - if (!canGenerate) { - const fallback = await getRandomFallbackImage(); - return { ...fallback, prompt: "fallback" }; - } - - // Load config const config = await loadConfig(); if (!config) { const fallback = await getRandomFallbackImage(); @@ -128,9 +116,6 @@ async function generateForestElement(db: IDBDatabase): Promise<{ return { ...fallback, prompt }; } - // Track API usage - await incrementApiCall(db, "image"); - return { blob, description: elementType, prompt }; } @@ -170,7 +155,7 @@ export async function initRewardScreen( // 3. Get companion comment let comment: string; try { - comment = await getForestComment(characterType, result.description, db); + comment = await getForestComment(characterType, result.description); } catch { comment = getRandomFallbackForestComment(); } diff --git a/src/main.ts b/src/main.ts index c6be37f..01aa6dd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,8 +43,6 @@ document.addEventListener("DOMContentLoaded", async () => { audioEnabled: false, speechEnabled: false, apiKey: "", - apiCallsToday: { text: 0, image: 0 }, - lastApiCallDate: "", }; updated.audioEnabled = nowEnabled; await saveSettings(db, updated); diff --git a/src/storage/db.test.ts b/src/storage/db.test.ts index 578338a..7bc576d 100644 --- a/src/storage/db.test.ts +++ b/src/storage/db.test.ts @@ -50,8 +50,6 @@ describe("IndexedDB wrapper", () => { audioEnabled: true, speechEnabled: false, apiKey: "test-key", - apiCallsToday: { text: 0, image: 0 }, - lastApiCallDate: "2026-03-29", }; await saveSettings(db, settings); const result = await getSettings(db); diff --git a/src/types.ts b/src/types.ts index 2fd55b5..5e89f2d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,8 +56,6 @@ export interface Settings { audioEnabled: boolean; speechEnabled: boolean; apiKey: string; - apiCallsToday: { text: number; image: number }; - lastApiCallDate: string; // ISO date "YYYY-MM-DD" } // Level definition (used in game/levels.ts) diff --git a/src/ui/parent.ts b/src/ui/parent.ts index 1d061ca..759ee85 100644 --- a/src/ui/parent.ts +++ b/src/ui/parent.ts @@ -198,8 +198,6 @@ async function loadSettings( audioEnabled: false, speechEnabled: false, apiKey: "", - apiCallsToday: { text: 0, image: 0 }, - lastApiCallDate: "", }; currentSettings.audioEnabled = audioCheckbox.checked; await saveSettings(db, currentSettings); @@ -212,8 +210,6 @@ async function loadSettings( audioEnabled: false, speechEnabled: false, apiKey: "", - apiCallsToday: { text: 0, image: 0 }, - lastApiCallDate: "", }; currentSettings.apiKey = apiKeyInput.value; await saveSettings(db, currentSettings); diff --git a/src/ui/screens.ts b/src/ui/screens.ts index 64ee85b..8fb069b 100644 --- a/src/ui/screens.ts +++ b/src/ui/screens.ts @@ -92,19 +92,54 @@ export function initWelcomeScreen( // Finger description for companion letter intros const fingerDescriptions: Record = { + // Level 1: Home row f: "linker Zeigefinger", j: "rechter Zeigefinger", + " ": "Daumen", + // Level 2 d: "linker Mittelfinger", k: "rechter Mittelfinger", + // Level 3 s: "linker Ringfinger", l: "rechter Ringfinger", + // Level 4 a: "linker kleiner Finger", ö: "rechter kleiner Finger", + // Level 5 g: "linker Zeigefinger (Streckung)", h: "rechter Zeigefinger (Streckung)", + // Level 6 e: "linker Mittelfinger (obere Reihe)", i: "rechter Mittelfinger (obere Reihe)", - " ": "Daumen", + // Level 7 + r: "linker Zeigefinger (obere Reihe)", + u: "rechter Zeigefinger (obere Reihe)", + // Level 8 + t: "linker Zeigefinger (obere Reihe, Streckung)", + z: "rechter Zeigefinger (obere Reihe, Streckung)", + // Level 9 + w: "linker Ringfinger (obere Reihe)", + o: "rechter Ringfinger (obere Reihe)", + // Level 10 + q: "linker kleiner Finger (obere Reihe)", + p: "rechter kleiner Finger (obere Reihe)", + // Level 11 + n: "rechter Zeigefinger (untere Reihe)", + b: "linker Zeigefinger (untere Reihe)", + // Level 12 + v: "linker Zeigefinger (untere Reihe)", + m: "rechter Zeigefinger (untere Reihe)", + // Level 13 + c: "linker Mittelfinger (untere Reihe)", + x: "linker Ringfinger (untere Reihe)", + // Level 14 + ä: "rechter kleiner Finger", + ü: "rechter kleiner Finger", + // Level 15 + y: "linker kleiner Finger (untere Reihe)", + ",": "rechter Mittelfinger (untere Reihe)", + // Level 16 + ".": "rechter Ringfinger (untere Reihe)", }; // Encouragement messages for repetition protection @@ -417,7 +452,6 @@ export function initLessonScreen( characterType, key.toUpperCase(), fingerDesc, - db, ); if (cancelled) return;