diff --git a/src/lib/game/tasks.test.ts b/src/lib/game/tasks.test.ts index 7afc602..9a9ab86 100644 --- a/src/lib/game/tasks.test.ts +++ b/src/lib/game/tasks.test.ts @@ -1,21 +1,20 @@ import { describe, it, expect } from 'vitest'; -import { generateTask, TARGETS, type Target } from './tasks'; +import { buildTask, createDeck, drawTask, TARGETS, type Target } from './tasks'; -describe('generateTask', () => { +describe('buildTask', () => { it.each(TARGETS)('produces a valid decomposition for target %i', (target) => { - for (let i = 0; i < 50; i++) { - const task = generateTask(target); + for (let given = 0; given <= target; given++) { + const task = buildTask(target, given); expect(task.target).toBe(target); - expect(task.given).toBeGreaterThanOrEqual(0); - expect(task.given).toBeLessThanOrEqual(target); + expect(task.given).toBe(given); expect(task.given + task.answer).toBe(target); } }); it('always includes the correct answer in choices', () => { for (const target of TARGETS) { - for (let i = 0; i < 30; i++) { - const task = generateTask(target); + for (let given = 0; given <= target; given++) { + const task = buildTask(target, given); expect(task.choices).toContain(task.answer); } } @@ -23,34 +22,75 @@ describe('generateTask', () => { it('produces choices without duplicates', () => { for (const target of TARGETS) { - for (let i = 0; i < 30; i++) { - const task = generateTask(target); - const unique = new Set(task.choices); - expect(unique.size).toBe(task.choices.length); + for (let given = 0; given <= target; given++) { + const task = buildTask(target, given); + expect(new Set(task.choices).size).toBe(task.choices.length); } } }); - it('returns 3 choices for targets >= 5', () => { - for (const target of [5, 6, 7, 8, 9, 10] as Target[]) { - const task = generateTask(target); + it('returns 3 choices for every target', () => { + for (const target of TARGETS) { + const task = buildTask(target, 1); expect(task.choices.length).toBe(3); } }); +}); - it('returns 3 choices for target 4 (3 possible answers total)', () => { - const task = generateTask(4); - expect(task.choices.length).toBe(3); +// Zieht n Aufgaben hintereinander aus einem frischen Deck. +function drawSequence(target: Target, n: number): number[] { + let deck = createDeck(target); + const givens: number[] = []; + for (let i = 0; i < n; i++) { + const res = drawTask(deck); + givens.push(res.task.given); + deck = res.deck; + } + return givens; +} + +describe('drawTask deck', () => { + it('never repeats the same given twice in a row', () => { + for (const target of TARGETS) { + // Lange Reihe über viele Beutel-Zyklen, auch über die Zyklusgrenzen hinweg. + const seq = drawSequence(target, 200); + for (let i = 1; i < seq.length; i++) { + expect(seq[i]).not.toBe(seq[i - 1]); + } + } }); - it('avoids reusing the same given when possible', () => { - const target: Target = 7; - const prev = generateTask(target); - let differed = 0; - for (let i = 0; i < 50; i++) { - const t = generateTask(target, prev); - if (t.given !== prev.given) differed++; + it('distributes givens as evenly as possible (counts differ by at most 1)', () => { + for (const target of TARGETS) { + const poolSize = target + 1; + const seq = drawSequence(target, poolSize * 7 + 3); + const counts = new Map(); + for (const g of seq) counts.set(g, (counts.get(g) ?? 0) + 1); + // Jede Vorgabe muss vorgekommen sein. + expect(counts.size).toBe(poolSize); + const values = [...counts.values()]; + expect(Math.max(...values) - Math.min(...values)).toBeLessThanOrEqual(1); + } + }); + + it('uses each given at most twice over a full 10-task run', () => { + // Ein Spieldurchlauf zieht ~10 Aufgaben; der kleinste Pool (Zielzahl 4) hat 5. + for (const target of TARGETS) { + for (let trial = 0; trial < 50; trial++) { + const seq = drawSequence(target, 10); + const counts = new Map(); + for (const g of seq) counts.set(g, (counts.get(g) ?? 0) + 1); + expect(Math.max(...counts.values())).toBeLessThanOrEqual(2); + } + } + }); + + it('keeps every given within [0..target]', () => { + for (const target of TARGETS) { + for (const g of drawSequence(target, 60)) { + expect(g).toBeGreaterThanOrEqual(0); + expect(g).toBeLessThanOrEqual(target); + } } - expect(differed).toBe(50); }); }); diff --git a/src/lib/game/tasks.ts b/src/lib/game/tasks.ts index e880e69..3984ceb 100644 --- a/src/lib/game/tasks.ts +++ b/src/lib/game/tasks.ts @@ -4,6 +4,11 @@ // given: vorgegebene Zerlegungszahl in [0..T] (inkl. der trivialen 0/T-Zerlegung) // answer: T - given (das Kind muss diese tippen) // choices: 3 große Buttons (richtige Antwort + 2 Distraktoren), gemischt +// +// Aufgabenfolge pro Spieldurchlauf: ein "Beutel" (TaskDeck) zieht alle möglichen +// Vorgaben ohne Zurücklegen, mischt nach dem Leeren neu und verhindert direkte +// Wiederholungen. So kommt nie 2x dieselbe Vorgabe hintereinander und die +// Häufigkeiten bleiben über den Durchlauf maximal gleichmäßig. export type Target = 4 | 5 | 6 | 7 | 8 | 9 | 10; @@ -18,10 +23,6 @@ export const TARGETS: Target[] = [4, 5, 6, 7, 8, 9, 10]; const DESIRED_CHOICES = 3; -function randInt(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - function shuffle(arr: T[]): T[] { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { @@ -31,26 +32,46 @@ function shuffle(arr: T[]): T[] { return a; } -export function generateTask(target: Target, prev?: Task): Task { - // Mögliche given-Werte: 0..target (inkl. 0 und target selbst). - const candidates: number[] = []; - for (let i = 0; i <= target; i++) candidates.push(i); +// Alle möglichen Vorgabe-Zahlen für eine Zielzahl: 0..target. +export function givenPool(target: Target): number[] { + const pool: number[] = []; + for (let i = 0; i <= target; i++) pool.push(i); + return pool; +} - // Vermeide identisches given wie zuletzt, wenn Auswahl groß genug. - const givenPool = - prev && prev.target === target && candidates.length > 1 - ? candidates.filter((g) => g !== prev.given) - : candidates; - - const given = givenPool[randInt(0, givenPool.length - 1)]; +// Baut die Aufgabe zu einer konkreten Vorgabe (richtige Antwort + 2 zufällige Distraktoren). +export function buildTask(target: Target, given: number): Task { const answer = target - given; - - // Distraktoren: alle möglichen Antworten außer der korrekten. - // Mögliche Antworten = 0..target (gleicher Wertebereich). - const distractorPool = candidates.filter((n) => n !== answer); + const distractorPool = givenPool(target).filter((n) => n !== answer); const numDistractors = Math.min(DESIRED_CHOICES - 1, distractorPool.length); const distractors = shuffle(distractorPool).slice(0, numDistractors); - const choices = shuffle([answer, ...distractors]); return { target, given, answer, choices }; } + +// Ziehbeutel pro Spieldurchlauf. `bag` = noch nicht gezogene Vorgaben des aktuellen +// Zyklus, `lastGiven` = zuletzt gezogene Vorgabe (für den Schutz an der Zyklusgrenze). +export type TaskDeck = { + target: Target; + bag: number[]; + lastGiven: number | null; +}; + +export function createDeck(target: Target): TaskDeck { + return { target, bag: [], lastGiven: null }; +} + +// Zieht die nächste Aufgabe und liefert das fortgeschriebene Deck zurück (immutable). +export function drawTask(deck: TaskDeck): { task: Task; deck: TaskDeck } { + let bag = deck.bag.slice(); + if (bag.length === 0) { + bag = shuffle(givenPool(deck.target)); + // Zyklusgrenze: erste Ziehung darf nicht der letzten des alten Beutels gleichen. + if (deck.lastGiven !== null && bag.length > 1 && bag[0] === deck.lastGiven) { + [bag[0], bag[1]] = [bag[1], bag[0]]; + } + } + const given = bag.shift() as number; + const task = buildTask(deck.target, given); + return { task, deck: { target: deck.target, bag, lastGiven: given } }; +} diff --git a/src/lib/stores/game.ts b/src/lib/stores/game.ts index 361833b..c8e0a7c 100644 --- a/src/lib/stores/game.ts +++ b/src/lib/stores/game.ts @@ -1,5 +1,5 @@ import { get, writable, derived } from 'svelte/store'; -import { generateTask, type Target, type Task } from '../game/tasks'; +import { createDeck, drawTask, type Target, type Task, type TaskDeck } from '../game/tasks'; import { isWin, stageFor } from '../game/stages'; import { settings } from './settings'; import { recordResult } from './progress'; @@ -16,6 +16,7 @@ export type GameState = { totalMs: number; lastWasCorrect: boolean | null; stageBumpKey: number; // monoton wachsend → triggert FX-Animation + deck: TaskDeck | null; // Ziehbeutel für abwechslungsreiche Aufgabenfolge }; const initial: GameState = { @@ -28,6 +29,7 @@ const initial: GameState = { totalMs: 0, lastWasCorrect: null, stageBumpKey: 0, + deck: null, }; export const game = writable(initial); @@ -75,7 +77,7 @@ function finalize() { export function startGame(target: Target): void { clearTick(); const totalMs = get(settings).roundSeconds * 1000; - const firstTask = generateTask(target); + const { task: firstTask, deck } = drawTask(createDeck(target)); game.set({ status: 'running', target, @@ -86,6 +88,7 @@ export function startGame(target: Target): void { totalMs, lastWasCorrect: null, stageBumpKey: 0, + deck, }); lastTickAt = performance.now(); tickHandle = requestAnimationFrame(tick); @@ -112,12 +115,13 @@ export function answer(value: number): void { finalize(); return; } - const nextTask = generateTask(g.target, g.task); + const { task: nextTask, deck } = drawTask(g.deck ?? createDeck(g.target)); game.update((s) => ({ ...s, correctCount: nextCount, prevTask: s.task, task: nextTask, + deck, lastWasCorrect: true, stageBumpKey: stageBump ? s.stageBumpKey + 1 : s.stageBumpKey, })); @@ -126,13 +130,6 @@ export function answer(value: number): void { } } -export function nextTaskAfterWrong(): void { - const g = get(game); - if (g.status !== 'running' || !g.task || g.target === null) return; - const nextTask = generateTask(g.target, g.task); - game.update((s) => ({ ...s, prevTask: s.task, task: nextTask, lastWasCorrect: null })); -} - export function abortGame(): void { clearTick(); game.set(initial);