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 { 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);
|
// Zieht n Aufgaben hintereinander aus einem frischen Deck.
|
||||||
expect(task.choices.length).toBe(3);
|
function drawSequence(target: Target, n: number): number[] {
|
||||||
});
|
let deck = createDeck(target);
|
||||||
|
const givens: number[] = [];
|
||||||
it('avoids reusing the same given when possible', () => {
|
for (let i = 0; i < n; i++) {
|
||||||
const target: Target = 7;
|
const res = drawTask(deck);
|
||||||
const prev = generateTask(target);
|
givens.push(res.task.given);
|
||||||
let differed = 0;
|
deck = res.deck;
|
||||||
for (let i = 0; i < 50; i++) {
|
}
|
||||||
const t = generateTask(target, prev);
|
return givens;
|
||||||
if (t.given !== prev.given) differed++;
|
}
|
||||||
}
|
|
||||||
expect(differed).toBe(50);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 } };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user