import type { ArithmeticAttemptResult, ArithmeticCategory, ArithmeticCustomConfig, ArithmeticProblem, ArithmeticSessionConfig, ArithmeticStar, AnswerFormat, BigMagnitude, Rng, } from "./types"; const STAR_DIGITS: Record = { 1: [1, 1], 2: [1, 2], 3: [2, 2], 4: [2, 3], 5: [3, 3], }; const BIG_RANGES: Record = { thousands: [1_000, 9_999], millions: [1_000_000, 9_999_999], billions: [1_000_000_000, 9_999_999_999], }; export function createSeededRng(seed: number): Rng { let state = seed >>> 0; return { next() { state = (state + 0x6d2b79f5) >>> 0; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }, }; } export function defaultCustomConfig(): ArithmeticCustomConfig { return { operations: ["addition", "subtraction", "multiplication", "division"], minDigits: 1, maxDigits: 2, allowNegatives: false, allowDecimals: false, allowFractions: true, cleanDivision: true, percentMode: "part", }; } export function generateArithmeticProblem( config: ArithmeticSessionConfig, rng: Rng ): ArithmeticProblem { const category: ArithmeticCategory = config.mode === "custom" ? pick(rng, config.customConfig?.operations ?? ["addition"]) : config.category; const resolvedConfig = { ...config, category }; switch (category) { case "addition": return binaryOpProblem(resolvedConfig, rng, "+", (a, b) => a + b); case "subtraction": return binaryOpProblem(resolvedConfig, rng, "-", (a, b) => a - b); case "multiplication": return binaryOpProblem(resolvedConfig, rng, "x", (a, b) => a * b); case "division": return divisionProblem(resolvedConfig, rng); case "mixed": return mixedProblem(resolvedConfig, rng); case "roots-powers": return rootsPowersProblem(resolvedConfig, rng); case "percents": return percentProblem(resolvedConfig, rng); case "binary": return binaryModeProblem(resolvedConfig, rng); case "big-multiply": return bigMultiplyProblem(resolvedConfig, rng); case "big-divide": return bigDivideProblem(resolvedConfig, rng); case "big-percents": return bigPercentProblem(resolvedConfig, rng); } } export function checkArithmeticAnswer(problem: ArithmeticProblem, userAnswer: string): boolean { if (problem.answerFormat === "binary") { const cleaned = userAnswer.trim().replace(/^0b/i, ""); return /^[01]+$/.test(cleaned) && cleaned.replace(/^0+(?=\d)/, "") === problem.canonicalAnswer; } const expected = parseRational(problem.canonicalAnswer); const actual = parseRational(userAnswer); if (!expected || !actual) { return false; } return expected.num === actual.num && expected.den === actual.den; } export function scoreArithmeticAttempt( attempt: Pick, session: Pick ): number { if (!attempt.correct) { return 0; } const star = attempt.problem.star; const base = 8 + star * 4; const speed = session.runMode === "untimed" ? 1 : Math.max(1, 4 - Math.floor(attempt.elapsedMs / 2500)); const categoryBonus = attempt.problem.category.startsWith("big-") ? 8 : 0; return base + speed + categoryBonus; } function binaryOpProblem( config: ArithmeticSessionConfig, rng: Rng, symbol: string, solve: (a: number, b: number) => number ): ArithmeticProblem { const { a, b } = operandsFor(config, rng); return makeProblem(config, `${a} ${symbol} ${b}`, String(solve(a, b)), "integer"); } function divisionProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { const [minDigits, maxDigits] = digitRange(config); const divisor = randDigits(rng, minDigits, Math.max(minDigits, maxDigits - 1)); const quotient = randDigits(rng, minDigits, maxDigits); const clean = config.mode !== "custom" || config.customConfig?.cleanDivision !== false; if (clean || config.star <= 3) { return makeProblem(config, `${divisor * quotient} / ${divisor}`, String(quotient), "integer"); } const dividend = divisor * quotient + randInt(rng, 1, divisor - 1); return makeProblem(config, `${dividend} / ${divisor}`, reduceFraction(dividend, divisor), "fraction", { acceptsFractions: true, acceptsDecimals: true, }); } function mixedProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { const op = pick(rng, ["addition", "subtraction", "multiplication", "division"] as const); return generateArithmeticProblem({ ...config, category: op }, rng); } function rootsPowersProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { const submode = config.submode ?? "square"; const star = config.star; const maxBase = submode === "cube" ? 5 + star * 5 : 10 + star * 18; const base = randInt(rng, Math.max(2, star), maxBase); if (submode === "sqrt") { return makeProblem( { ...config, submode }, `sqrt(${base * base})`, String(base), "integer" ); } const power = submode === "cube" ? 3 : 2; return makeProblem( { ...config, submode }, `${base}^${power}`, String(Math.pow(base, power)), "integer" ); } function percentProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { const rates = [5, 10, 12.5, 15, 20, 25, 30, 40, 50, 75]; const rate = config.star >= 4 ? randInt(rng, 2, 95) : pick(rng, rates); const base = randInt(rng, 2, 20 + config.star * 30) * 4; const answer = (base * rate) / 100; return makeProblem(config, `${formatNumber(rate)}% of ${base}`, formatNumber(answer), "decimal", { acceptsFractions: true, acceptsDecimals: true, }); } function binaryModeProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { const max = Math.pow(2, 3 + config.star) - 1; const a = randInt(rng, 1, max); const b = randInt(rng, 1, max); const op = pick(rng, ["+", "-", "x"] as const); const left = op === "-" ? Math.max(a, b) : a; const right = op === "-" ? Math.min(a, b) : b; const answer = op === "+" ? left + right : op === "-" ? left - right : left * right; return makeProblem( config, `${left.toString(2)} ${op} ${right.toString(2)}`, answer.toString(2), "binary" ); } function bigMultiplyProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { const magnitude = config.magnitude ?? "thousands"; const [min, max] = BIG_RANGES[magnitude]; const a = randInt(rng, min, max); const b = randDigits(rng, Math.max(1, config.star - 1), Math.min(3, config.star)); return makeProblem(config, `${a} x ${b}`, String(a * b), "integer", { magnitude }); } function bigDivideProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { const magnitude = config.magnitude ?? "thousands"; const [min, max] = BIG_RANGES[magnitude]; const quotient = randInt(rng, min, max); const divisor = randDigits(rng, 1, Math.min(3, config.star)); return makeProblem(config, `${quotient * divisor} / ${divisor}`, String(quotient), "integer", { magnitude, }); } function bigPercentProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem { const magnitude = config.magnitude ?? "thousands"; const [min, max] = BIG_RANGES[magnitude]; const base = randInt(rng, min, max); const rate = config.star <= 2 ? pick(rng, [1, 5, 10, 20, 25, 50]) : randInt(rng, 2, 35); const answer = (base * rate) / 100; return makeProblem(config, `${rate}% of ${base}`, formatNumber(answer), "decimal", { magnitude, acceptsFractions: true, acceptsDecimals: true, }); } function operandsFor(config: ArithmeticSessionConfig, rng: Rng) { const [minDigits, maxDigits] = digitRange(config); let a = randDigits(rng, minDigits, maxDigits); let b = randDigits(rng, minDigits, maxDigits); if (config.mode === "custom" && config.customConfig?.allowNegatives) { a *= rng.next() < 0.3 ? -1 : 1; b *= rng.next() < 0.3 ? -1 : 1; } if (config.category === "subtraction" && config.mode !== "custom" && b > a) { [a, b] = [b, a]; } return { a, b }; } function digitRange(config: ArithmeticSessionConfig): [number, number] { if (config.mode === "custom" && config.customConfig) { return [ clamp(Math.floor(config.customConfig.minDigits), 1, 9), clamp(Math.floor(config.customConfig.maxDigits), 1, 9), ]; } return STAR_DIGITS[config.star]; } function makeProblem( config: ArithmeticSessionConfig, expression: string, canonicalAnswer: string, answerFormat: AnswerFormat, options: Record = {} ): ArithmeticProblem { return { id: crypto.randomUUID(), category: config.category, submode: config.submode, star: config.star, expression, canonicalAnswer, answerFormat, acceptsFractions: Boolean(options.acceptsFractions) || answerFormat === "fraction", acceptsDecimals: Boolean(options.acceptsDecimals) || answerFormat === "decimal", metadata: options, }; } function randDigits(rng: Rng, minDigits: number, maxDigits: number): number { const loDigits = Math.min(minDigits, maxDigits); const hiDigits = Math.max(minDigits, maxDigits); const digits = randInt(rng, loDigits, hiDigits); const min = digits === 1 ? 1 : Math.pow(10, digits - 1); const max = Math.pow(10, digits) - 1; return randInt(rng, min, max); } function randInt(rng: Rng, min: number, max: number): number { return Math.floor(rng.next() * (max - min + 1)) + min; } function pick(rng: Rng, items: readonly T[]): T { return items[Math.floor(rng.next() * items.length)]!; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function formatNumber(value: number): string { return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6))); } type Rational = { num: number; den: number; }; function parseRational(input: string): Rational | null { const value = input.trim().replace(/\s+/g, ""); if (!value) { return null; } if (/^-?\d+\/-?\d+$/.test(value)) { const [rawNum, rawDen] = value.split("/"); const den = Number(rawDen); if (den === 0) { return null; } return normalize({ num: Number(rawNum), den }); } if (/^-?(?:\d+|\d*\.\d+)$/.test(value)) { const negative = value.startsWith("-"); const unsigned = negative ? value.slice(1) : value; const [whole, decimal = ""] = unsigned.split("."); const den = Math.pow(10, decimal.length); const num = Number((whole || "0") + decimal) * (negative ? -1 : 1); return normalize({ num, den }); } return null; } function reduceFraction(num: number, den: number): string { const rational = normalize({ num, den }); return rational.den === 1 ? String(rational.num) : `${rational.num}/${rational.den}`; } function normalize(rational: Rational): Rational { let { num, den } = rational; if (den < 0) { num = -num; den = -den; } const divisor = gcd(abs(num), den); return { num: num / divisor, den: den / divisor, }; } function gcd(a: number, b: number): number { while (b !== 0) { [a, b] = [b, a % b]; } return a || 1; } function abs(value: number): number { return value < 0 ? -value : value; } export const arithmeticCategories: Array<{ id: ArithmeticCategory; label: string; description: string; }> = [ { id: "addition", label: "Addition", description: "Fast integer sums" }, { id: "subtraction", label: "Subtraction", description: "Positive and signed differences" }, { id: "multiplication", label: "Multiplication", description: "Tables through three digits" }, { id: "division", label: "Division", description: "Clean division, then fractions" }, { id: "mixed", label: "Mixed ops", description: "Rotating arithmetic operators" }, { id: "roots-powers", label: "Roots and powers", description: "Square roots, squares, cubes" }, { id: "percents", label: "Percents", description: "Everyday percent calculations" }, { id: "binary", label: "Binary", description: "Base-two arithmetic" }, { id: "big-multiply", label: "Big multiply", description: "Large numbers by smaller factors" }, { id: "big-divide", label: "Big divide", description: "Large clean quotients" }, { id: "big-percents", label: "Big percents", description: "Percent work on large quantities" }, ];