Aufgaben pro Durchlauf abwechslungsreich ziehen — keine direkte Wiederholung, max. 2x

This commit is contained in:
schmop 2026-05-31 17:13:52 +02:00
parent aadbd6a231
commit 564f5876b3
3 changed files with 114 additions and 56 deletions

View File

@ -1,21 +1,20 @@
import { describe, it, expect } from 'vitest'; 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) => { it.each(TARGETS)('produces a valid decomposition for target %i', (target) => {
for (let i = 0; i < 50; i++) { for (let given = 0; given <= target; given++) {
const task = generateTask(target); const task = buildTask(target, given);
expect(task.target).toBe(target); expect(task.target).toBe(target);
expect(task.given).toBeGreaterThanOrEqual(0); expect(task.given).toBe(given);
expect(task.given).toBeLessThanOrEqual(target);
expect(task.given + task.answer).toBe(target); expect(task.given + task.answer).toBe(target);
} }
}); });
it('always includes the correct answer in choices', () => { it('always includes the correct answer in choices', () => {
for (const target of TARGETS) { for (const target of TARGETS) {
for (let i = 0; i < 30; i++) { for (let given = 0; given <= target; given++) {
const task = generateTask(target); const task = buildTask(target, given);
expect(task.choices).toContain(task.answer); expect(task.choices).toContain(task.answer);
} }
} }
@ -23,34 +22,75 @@ describe('generateTask', () => {
it('produces choices without duplicates', () => { it('produces choices without duplicates', () => {
for (const target of TARGETS) { for (const target of TARGETS) {
for (let i = 0; i < 30; i++) { for (let given = 0; given <= target; given++) {
const task = generateTask(target); const task = buildTask(target, given);
const unique = new Set(task.choices); expect(new Set(task.choices).size).toBe(task.choices.length);
expect(unique.size).toBe(task.choices.length);
} }
} }
}); });
it('returns 3 choices for targets >= 5', () => { it('returns 3 choices for every target', () => {
for (const target of [5, 6, 7, 8, 9, 10] as Target[]) { for (const target of TARGETS) {
const task = generateTask(target); const task = buildTask(target, 1);
expect(task.choices.length).toBe(3); 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);
}); });
it('avoids reusing the same given when possible', () => { // Zieht n Aufgaben hintereinander aus einem frischen Deck.
const target: Target = 7; function drawSequence(target: Target, n: number): number[] {
const prev = generateTask(target); let deck = createDeck(target);
let differed = 0; const givens: number[] = [];
for (let i = 0; i < 50; i++) { for (let i = 0; i < n; i++) {
const t = generateTask(target, prev); const res = drawTask(deck);
if (t.given !== prev.given) differed++; 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('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<number, number>();
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<number, number>();
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);
}); });
}); });

View File

@ -4,6 +4,11 @@
// given: vorgegebene Zerlegungszahl in [0..T] (inkl. der trivialen 0/T-Zerlegung) // given: vorgegebene Zerlegungszahl in [0..T] (inkl. der trivialen 0/T-Zerlegung)
// answer: T - given (das Kind muss diese tippen) // answer: T - given (das Kind muss diese tippen)
// choices: 3 große Buttons (richtige Antwort + 2 Distraktoren), gemischt // 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; 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; const DESIRED_CHOICES = 3;
function randInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function shuffle<T>(arr: T[]): T[] { function shuffle<T>(arr: T[]): T[] {
const a = arr.slice(); const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) { for (let i = a.length - 1; i > 0; i--) {
@ -31,26 +32,46 @@ function shuffle<T>(arr: T[]): T[] {
return a; return a;
} }
export function generateTask(target: Target, prev?: Task): Task { // Alle möglichen Vorgabe-Zahlen für eine Zielzahl: 0..target.
// Mögliche given-Werte: 0..target (inkl. 0 und target selbst). export function givenPool(target: Target): number[] {
const candidates: number[] = []; const pool: number[] = [];
for (let i = 0; i <= target; i++) candidates.push(i); for (let i = 0; i <= target; i++) pool.push(i);
return pool;
}
// Vermeide identisches given wie zuletzt, wenn Auswahl groß genug. // Baut die Aufgabe zu einer konkreten Vorgabe (richtige Antwort + 2 zufällige Distraktoren).
const givenPool = export function buildTask(target: Target, given: number): Task {
prev && prev.target === target && candidates.length > 1
? candidates.filter((g) => g !== prev.given)
: candidates;
const given = givenPool[randInt(0, givenPool.length - 1)];
const answer = target - given; const answer = target - given;
const distractorPool = givenPool(target).filter((n) => n !== answer);
// Distraktoren: alle möglichen Antworten außer der korrekten.
// Mögliche Antworten = 0..target (gleicher Wertebereich).
const distractorPool = candidates.filter((n) => n !== answer);
const numDistractors = Math.min(DESIRED_CHOICES - 1, distractorPool.length); const numDistractors = Math.min(DESIRED_CHOICES - 1, distractorPool.length);
const distractors = shuffle(distractorPool).slice(0, numDistractors); const distractors = shuffle(distractorPool).slice(0, numDistractors);
const choices = shuffle([answer, ...distractors]); const choices = shuffle([answer, ...distractors]);
return { target, given, answer, choices }; 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 } };
}

View File

@ -1,5 +1,5 @@
import { get, writable, derived } from 'svelte/store'; 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 { isWin, stageFor } from '../game/stages';
import { settings } from './settings'; import { settings } from './settings';
import { recordResult } from './progress'; import { recordResult } from './progress';
@ -16,6 +16,7 @@ export type GameState = {
totalMs: number; totalMs: number;
lastWasCorrect: boolean | null; lastWasCorrect: boolean | null;
stageBumpKey: number; // monoton wachsend → triggert FX-Animation stageBumpKey: number; // monoton wachsend → triggert FX-Animation
deck: TaskDeck | null; // Ziehbeutel für abwechslungsreiche Aufgabenfolge
}; };
const initial: GameState = { const initial: GameState = {
@ -28,6 +29,7 @@ const initial: GameState = {
totalMs: 0, totalMs: 0,
lastWasCorrect: null, lastWasCorrect: null,
stageBumpKey: 0, stageBumpKey: 0,
deck: null,
}; };
export const game = writable<GameState>(initial); export const game = writable<GameState>(initial);
@ -75,7 +77,7 @@ function finalize() {
export function startGame(target: Target): void { export function startGame(target: Target): void {
clearTick(); clearTick();
const totalMs = get(settings).roundSeconds * 1000; const totalMs = get(settings).roundSeconds * 1000;
const firstTask = generateTask(target); const { task: firstTask, deck } = drawTask(createDeck(target));
game.set({ game.set({
status: 'running', status: 'running',
target, target,
@ -86,6 +88,7 @@ export function startGame(target: Target): void {
totalMs, totalMs,
lastWasCorrect: null, lastWasCorrect: null,
stageBumpKey: 0, stageBumpKey: 0,
deck,
}); });
lastTickAt = performance.now(); lastTickAt = performance.now();
tickHandle = requestAnimationFrame(tick); tickHandle = requestAnimationFrame(tick);
@ -112,12 +115,13 @@ export function answer(value: number): void {
finalize(); finalize();
return; return;
} }
const nextTask = generateTask(g.target, g.task); const { task: nextTask, deck } = drawTask(g.deck ?? createDeck(g.target));
game.update((s) => ({ game.update((s) => ({
...s, ...s,
correctCount: nextCount, correctCount: nextCount,
prevTask: s.task, prevTask: s.task,
task: nextTask, task: nextTask,
deck,
lastWasCorrect: true, lastWasCorrect: true,
stageBumpKey: stageBump ? s.stageBumpKey + 1 : s.stageBumpKey, 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 { export function abortGame(): void {
clearTick(); clearTick();
game.set(initial); game.set(initial);