Aufgaben pro Durchlauf abwechslungsreich ziehen — keine direkte Wiederholung, max. 2x
This commit is contained in:
parent
aadbd6a231
commit
564f5876b3
|
|
@ -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);
|
||||
});
|
||||
|
||||
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++;
|
||||
// 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('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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<T>(arr: T[]): T[] {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
|
|
@ -31,26 +32,46 @@ function shuffle<T>(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 } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GameState>(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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user