Fraction Add-Sub Sprint
60 seconds of rename-then-compute: like denominators warm you up, unlike denominators test your LCD speed.
How to play
Answer fraction addition and subtraction against the clock. Type fractions like 5/6 or mixed numbers like 1 1/4.
- 1A correct answer scores +10 points. Streaks of 4 or more earn a bonus of +2 per streak level beyond 3 — keep the streak alive.
- 2A wrong answer costs 1 life, resets your streak, and shows a short explanation of why the correct answer is right.
- 3The game ends when the timer hits 0 or you lose all 3 lives. Questions start easy and ramp up.
Best streak 0
/* ===== Fibo elementary core — skill: fractions-add-subtract — pure, node-testable ===== */ (function (root, factory) { 'use strict'; var api = factory(); if (typeof module === 'undefined') { root.FiboCore_fractions_add_subtract = api; } else { module.exports = api; } })(typeof self !== 'undefined' ? self : this, function () { 'use strict'; /* ---------------- seeded PRNG ---------------- */ function mulberry32(a){ return function(){ a |= 0; a = (a + 0x6D2B79F5) | 0; var t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function shuffle(arr, rand){ for (var i = arr.length - 1; i > 0; i--){ var j = Math.floor(rand() * (i + 1)); var t = arr[i]; arr[i] = arr[j]; arr[j] = t; } return arr; } var _rand = Math.random; function setSeed(s){ _rand = mulberry32(s >>> 0); } function ri(lo, hi){ return lo + Math.floor(_rand() * (hi - lo + 1)); } function rpick(arr){ return arr[Math.floor(_rand() * arr.length)]; } function rchance(p){ return _rand() < p; } function rshuffle(a){ for (var i = a.length - 1; i > 0; i--){ var j = Math.floor(_rand() * (i + 1)); var t = a[i]; a[i] = a[j]; a[j] = t; } return a; } /* ---------------- exact integer / rational math ---------------- */ function ggcd(a, b){ a = Math.abs(a); b = Math.abs(b); while (b){ var t = a % b; a = b; b = t; } return a || 1; } function gred(n, d){ if (d === 0) throw new Error('gred: zero denominator'); if (d < 0){ n = -n; d = -d; } if (n === 0) return { n: 0, d: 1 }; var g = ggcd(n, d); return { n: n / g, d: d / g }; } function sameFrac(a, b){ return a.n * b.d === b.n * a.d; } /* exact decimal as {v: integer digits, p: places} */ function decVal(d){ return d.v / Math.pow(10, d.p); } function decNorm(d){ var v = d.v, p = d.p; while (p > 0){ if (v % 10 !== 0) break; v = v / 10; p = p - 1; } return { v: v, p: p }; } function decAdd(a, b){ var p = Math.max(a.p, b.p); var va = a.v * Math.pow(10, p - a.p); var vb = b.v * Math.pow(10, p - b.p); return decNorm({ v: va + vb, p: p }); } function decSub(a, b){ var p = Math.max(a.p, b.p); var va = a.v * Math.pow(10, p - a.p); var vb = b.v * Math.pow(10, p - b.p); return decNorm({ v: va - vb, p: p }); } function decTextFromVP(v, p){ var neg = v < 0; var s = String(Math.abs(v)); while (s.length <= p) s = '0' + s; var out; if (p === 0) out = s; else out = s.slice(0, s.length - p) + '.' + s.slice(s.length - p); if (neg) out = '-' + out; return out; } function decText(v){ var r = Math.round(v * 1e6) / 1e6; var s = String(r); if (s.indexOf('.') < 0) return s; s = s.replace(/0+$/, '').replace(/\.$/, ''); if (s === '-0') s = '0'; return s; } /* ---------------- formatting (HTML) ---------------- */ function fracHTML(n, d){ var r = gred(n, d); var sign = r.n < 0 ? '−' : ''; if (r.d === 1) return '' + sign + Math.abs(r.n) + ''; return sign + '' + Math.abs(r.n) + '' + r.d + ''; } function mixedHTML(w, n, d){ var r = gred(n, d); if (r.n === 0) return '' + w + ''; return '' + w + ' ' + fracHTML(r.n, r.d); } function intHTML(v){ return '' + v + ''; } function qrHTML(q, r){ if (r === 0) return '' + q + ''; return '' + q + ' R ' + r + ''; } function ansHTML(ans){ if (ans.kind === 'int') return intHTML(ans.v); if (ans.kind === 'frac') return fracHTML(ans.n, ans.d); if (ans.kind === 'mixed') return mixedHTML(ans.w, ans.n, ans.d); if (ans.kind === 'dec') return '' + decText(ans.v) + ''; if (ans.kind === 'qr') return qrHTML(ans.q, ans.r); if (ans.kind === 'cmp') return '' + (ans.c === '<' ? '<' : (ans.c === '>' ? '>' : '=')) + ''; if (ans.kind === 'yesno') return '' + (ans.v ? 'Yes' : 'No') + ''; if (ans.kind === 'parts') return '' + ans.parts.join(' + ') + ''; return '?'; } function ansText(ans){ if (ans.kind === 'int') return String(ans.v); if (ans.kind === 'frac'){ var r = gred(ans.n, ans.d); return r.d === 1 ? String(r.n) : r.n + '/' + r.d; } if (ans.kind === 'mixed'){ var g = gred(ans.n, ans.d); if (g.n === 0) return String(ans.w); var f = g.d === 1 ? String(ans.w + g.n) : ans.w + ' ' + g.n + '/' + g.d; return f; } if (ans.kind === 'dec') return decText(ans.v); if (ans.kind === 'qr') return ans.r === 0 ? String(ans.q) : ans.q + ' R ' + ans.r; if (ans.kind === 'cmp') return ans.c; if (ans.kind === 'yesno') return ans.v ? 'yes' : 'no'; if (ans.kind === 'parts') return ans.parts.join(' + '); return ''; } /* ---------------- input parsing ---------------- */ function parseIntInput(raw){ var t = String(raw).trim().replace(/,/g, ''); if (!/^[+-]?\d+$/.test(t)) return { ok: false }; return { ok: true, kind: 'int', v: parseInt(t, 10) }; } function parseFracInput(raw){ var t = String(raw).trim().replace(/\s+/g, ''); var m = t.match(/^([+-]?\d+)\/(\d+)$/); if (m){ var d = parseInt(m[2], 10); if (d === 0) return { ok: false }; var r = gred(parseInt(m[1], 10), d); return { ok: true, kind: 'frac', n: r.n, d: r.d }; } var iv = parseIntInput(raw); if (iv.ok) return { ok: true, kind: 'frac', n: iv.v, d: 1 }; return { ok: false }; } function parseMixedInput(raw){ var t = String(raw).trim().replace(/\s+/g, ' '); var m = t.match(/^(\d+)\s+(\d+)\/(\d+)$/); if (m){ var w = parseInt(m[1], 10), n = parseInt(m[2], 10), d = parseInt(m[3], 10); if (d === 0) return { ok: false }; if (n >= d) return { ok: false }; var r = gred(n, d); return { ok: true, kind: 'mixed', w: w, n: r.n, d: r.d }; } var f = parseFracInput(raw); if (f.ok) return f; return { ok: false }; } function parseDecInput(raw){ var t = String(raw).trim().replace(/,/g, '').replace(/^\$/, ''); if (!/^[+-]?(\d+(\.\d*)?|\.\d+)$/.test(t)) return { ok: false }; return { ok: true, kind: 'dec', v: parseFloat(t) }; } function parseCmpInput(raw){ var t = String(raw).trim().toLowerCase(); if (t === '<' || t === 'less' || t === 'less than' || t === 'smaller') return { ok: true, kind: 'cmp', v: -1 }; if (t === '>' || t === 'greater' || t === 'greater than' || t === 'bigger' || t === 'larger') return { ok: true, kind: 'cmp', v: 1 }; if (t === '=' || t === '==' || t === 'equal' || t === 'equals' || t === 'same') return { ok: true, kind: 'cmp', v: 0 }; return { ok: false }; } function parseYesNo(raw){ var t = String(raw).trim().toLowerCase(); if (t === 'yes' || t === 'y') return { ok: true, kind: 'yesno', v: true }; if (t === 'no' || t === 'n') return { ok: true, kind: 'yesno', v: false }; return { ok: false }; } function parseQRInput(raw){ var t = String(raw).trim().toLowerCase().replace(/remainder/g, 'r'); var parts = t.split(/[^0-9]+/); var nums = []; for (var i = 0; i < parts.length; i++){ if (parts[i] !== '') nums.push(parseInt(parts[i], 10)); } if (nums.length === 1) return { ok: true, kind: 'qr', q: nums[0], r: 0 }; if (nums.length === 2) return { ok: true, kind: 'qr', q: nums[0], r: nums[1] }; return { ok: false }; } function parsePartsInput(raw){ var t = String(raw).trim().toLowerCase().replace(/plus|and/g, '+'); var chunks = t.split(/[^0-9]+/); var parts = []; for (var i = 0; i < chunks.length; i++){ if (chunks[i] !== '') parts.push(parseInt(chunks[i], 10)); } if (parts.length === 0) return { ok: false }; return { ok: true, kind: 'parts', parts: parts }; } function parseByKind(kind, raw){ if (kind === 'int') return parseIntInput(raw); if (kind === 'frac') return parseFracInput(raw); if (kind === 'mixed') return parseMixedInput(raw); if (kind === 'dec') return parseDecInput(raw); if (kind === 'cmp') return parseCmpInput(raw); if (kind === 'yesno') return parseYesNo(raw); if (kind === 'qr') return parseQRInput(raw); if (kind === 'parts') return parsePartsInput(raw); return { ok: false }; } function entryHintFor(kind){ if (kind === 'int') return 'Enter a whole number, e.g. 42.'; if (kind === 'frac') return 'Enter a fraction like 3/4 (a whole number is fine too).'; if (kind === 'mixed') return 'Enter a mixed number like 2 1/3, or an improper fraction like 7/3.'; if (kind === 'dec') return 'Enter a decimal like 0.75.'; if (kind === 'cmp') return 'Enter <, >, or = (you can also type "less", "greater", or "equal").'; if (kind === 'yesno') return 'Enter yes or no.'; if (kind === 'qr') return 'Enter the quotient and remainder like 7 R 2.'; if (kind === 'parts') return 'Enter the partial products separated by +, e.g. 120 + 30.'; return 'Type your answer.'; } /* ---------------- answer checking ---------------- */ function ratOf(ans){ if (ans.kind === 'int') return { n: ans.v, d: 1 }; if (ans.kind === 'frac') return { n: ans.n, d: ans.d }; if (ans.kind === 'mixed') return { n: ans.w * ans.d + ans.n, d: ans.d }; return null; } function checkAnswer(exp, parsed){ if (!parsed.ok) return false; if (exp.kind === 'int') return !(!(parsed.kind === 'int') || !(parsed.v === exp.v)); if (exp.kind === 'qr') return !(!(parsed.kind === 'qr') || !(parsed.q === exp.q) || !(parsed.r === exp.r)); if (exp.kind === 'cmp'){ if (parsed.kind !== 'cmp') return false; var ev = exp.c === '<' ? -1 : (exp.c === '>' ? 1 : 0); return parsed.v === ev; } if (exp.kind === 'yesno') return !(!(parsed.kind === 'yesno') || !(parsed.v === exp.v)); if (exp.kind === 'parts'){ if (parsed.kind !== 'parts') return false; if (parsed.parts.length !== exp.parts.length) return false; var a = exp.parts.slice().sort(function(x, y){ return x - y; }); var b = parsed.parts.slice().sort(function(x, y){ return x - y; }); for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } if (exp.kind === 'dec'){ if (parsed.kind !== 'dec') return false; var dd = parsed.v - exp.v; if (dd < 0) dd = -dd; return dd < 1e-9; } var e = ratOf(exp); if (!e) return false; if (parsed.kind === 'frac' || parsed.kind === 'int' || parsed.kind === 'mixed'){ return sameFrac(e, ratOf(parsed)); } if (parsed.kind === 'dec'){ var dv = parsed.v - e.n / e.d; if (dv < 0) dv = -dv; return dv < 1e-9; } return false; } /* ---------------- per-skill question templates ---------------- */ function _aSteps(items){ var h = '
- ';
for (var i = 0; i < items.length; i++) h += '
- ' + items[i] + '
'; return h + '
'; } function _aWhy(steps, ansHTML){ return _aSteps(steps) + '
'; } function _agcd(a, b){ while (b){ var t = a % b; a = b; b = t; } return a; } function _alcm(a, b){ return a / _agcd(a, b) * b; } function _asimp(n, d){ var g = _agcd(n, d); return { n: n / g, d: d / g }; } function _aop(op){ return op === '+' ? '+' : '\u2212'; } function _aCalc(n1, d1, n2, d2, op){ var L = _alcm(d1, d2); var tn = op === '+' ? (n1 * L / d1 + n2 * L / d2) : (n1 * L / d1 - n2 * L / d2); var s = _asimp(tn, L); var w = Math.floor(s.n / s.d), r = s.n % s.d; var ans = r === 0 ? { kind: 'int', v: w } : (w === 0 ? { kind: 'frac', n: s.n, d: s.d } : { kind: 'mixed', w: w, n: r, d: s.d }); return { L: L, tn: tn, s: s, ans: ans }; } function _aAnsText(ans){ if (ans.kind === 'int') return String(ans.v); if (ans.kind === 'frac') return ans.n + '/' + ans.d; return ans.w + ' ' + ans.n + '/' + ans.d; } var TEMPLATES = [ { id: 'A1', tag: 'Like denominators: add', level: 0, enumerate: function(){ var out = []; for (var d = 3; d <= 10; d++) for (var n1 = 1; n1 < d; n1++) for (var n2 = 1; n2 <= d - n1; n2++) out.push({ key: 'a' + n1 + 'f' + d + '+' + n2, n1: n1, n2: n2, d: d }); return out; }, make: function(p){ var r = _aCalc(p.n1, p.d, p.n2, p.d, '+'); return { promptHTML: 'What is ' + p.n1 + '/' + p.d + ' + ' + p.n2 + '/' + p.d + '?', answer: r.ans, hintHTML: 'Same denominator: add the tops, keep the bottom.', whyHTML: _aWhy([ 'Same-size pieces (' + p.d + 'ths): add the numerators ' + p.n1 + ' + ' + p.n2 + ' = ' + r.tn + '.', 'Result: ' + r.tn + '/' + p.d + (r.tn === r.s.n ? ' (already simplest).' : ' = ' + _aAnsText(r.ans) + ' in simplest form.') ], '' + _aAnsText(r.ans) + '') }; } }, { id: 'A2', tag: 'Like denominators: subtract', level: 0, enumerate: function(){ var out = []; for (var d = 3; d <= 10; d++) for (var n1 = 2; n1 < d; n1++) for (var n2 = 1; n2 < n1; n2++) out.push({ key: 'a' + n1 + 'f' + d + '-' + n2, n1: n1, n2: n2, d: d }); return out; }, make: function(p){ var r = _aCalc(p.n1, p.d, p.n2, p.d, '-'); return { promptHTML: 'What is ' + p.n1 + '/' + p.d + ' \u2212 ' + p.n2 + '/' + p.d + '?', answer: r.ans, hintHTML: 'Same denominator: subtract the tops, keep the bottom.', whyHTML: _aWhy([ 'Same-size pieces (' + p.d + 'ths): subtract the numerators ' + p.n1 + ' \u2212 ' + p.n2 + ' = ' + r.tn + '.', 'Result: ' + r.tn + '/' + p.d + (r.tn === r.s.n ? ' (already simplest).' : ' = ' + _aAnsText(r.ans) + ' in simplest form.') ], '' + _aAnsText(r.ans) + '') }; } }, { id: 'A3', tag: 'Unlike: add', level: 1, enumerate: function(){ var out = []; for (var d1 = 2; d1 <= 6; d1++) for (var d2 = d1 + 1; d2 <= 8; d2++) for (var n1 = 1; n1 < d1; n1++) for (var n2 = 1; n2 < d2; n2++){ var r = _aCalc(n1, d1, n2, d2, '+'); if (r.tn < r.L) out.push({ key: 'u' + n1 + 'f' + d1 + '+' + n2 + 'f' + d2, n1: n1, d1: d1, n2: n2, d2: d2 }); } return out; }, make: function(p){ var r = _aCalc(p.n1, p.d1, p.n2, p.d2, '+'); return { promptHTML: 'What is ' + p.n1 + '/' + p.d1 + ' + ' + p.n2 + '/' + p.d2 + '?', answer: r.ans, hintHTML: 'Rename both with the LCD ' + r.L + ' first.', whyHTML: _aWhy([ 'The LCD of ' + p.d1 + ' and ' + p.d2 + ' is ' + r.L + '.', p.n1 + '/' + p.d1 + ' = ' + (p.n1 * r.L / p.d1) + '/' + r.L + '; ' + p.n2 + '/' + p.d2 + ' = ' + (p.n2 * r.L / p.d2) + '/' + r.L + '.', 'Add the tops: ' + (p.n1 * r.L / p.d1) + ' + ' + (p.n2 * r.L / p.d2) + ' = ' + r.tn + '/' + r.L + ' = ' + _aAnsText(r.ans) + ' in simplest form.' ], '' + _aAnsText(r.ans) + '') }; } }, { id: 'A4', tag: 'Unlike: subtract', level: 1, enumerate: function(){ var out = []; for (var d1 = 2; d1 <= 8; d1++) for (var d2 = 2; d2 <= 8; d2++) if (d1 !== d2) for (var n1 = 1; n1 < d1; n1++) for (var n2 = 1; n2 < d2; n2++){ if (n1 / d1 > n2 / d2) out.push({ key: 'u' + n1 + 'f' + d1 + '-' + n2 + 'f' + d2, n1: n1, d1: d1, n2: n2, d2: d2 }); } return out; }, make: function(p){ var r = _aCalc(p.n1, p.d1, p.n2, p.d2, '-'); return { promptHTML: 'What is ' + p.n1 + '/' + p.d1 + ' \u2212 ' + p.n2 + '/' + p.d2 + '?', answer: r.ans, hintHTML: 'Rename both with the LCD ' + r.L + ' first.', whyHTML: _aWhy([ 'The LCD of ' + p.d1 + ' and ' + p.d2 + ' is ' + r.L + '.', p.n1 + '/' + p.d1 + ' = ' + (p.n1 * r.L / p.d1) + '/' + r.L + '; ' + p.n2 + '/' + p.d2 + ' = ' + (p.n2 * r.L / p.d2) + '/' + r.L + '.', 'Subtract the tops: ' + (p.n1 * r.L / p.d1) + ' \u2212 ' + (p.n2 * r.L / p.d2) + ' = ' + r.tn + '/' + r.L + ' = ' + _aAnsText(r.ans) + ' in simplest form.' ], '' + _aAnsText(r.ans) + '') }; } }, { id: 'A5', tag: 'Improper answers', level: 2, enumerate: function(){ var out = []; for (var d1 = 2; d1 <= 6; d1++) for (var d2 = 2; d2 <= 6; d2++) for (var n1 = 1; n1 < d1; n1++) for (var n2 = 1; n2 < d2; n2++){ var r = _aCalc(n1, d1, n2, d2, '+'); if (!(!(r.tn >= r.L) || !(r.ans.kind !== 'int'))) out.push({ key: 'i' + n1 + 'f' + d1 + '+' + n2 + 'f' + d2, n1: n1, d1: d1, n2: n2, d2: d2 }); } return out; }, make: function(p){ var r = _aCalc(p.n1, p.d1, p.n2, p.d2, '+'); return { promptHTML: 'What is ' + p.n1 + '/' + p.d1 + ' + ' + p.n2 + '/' + p.d2 + '? Give a mixed number.', answer: r.ans, hintHTML: 'Add as usual; the answer will be more than 1 whole. Convert it.', whyHTML: _aWhy([ 'Add: ' + r.tn + '/' + r.L + ' (simplified ' + r.s.n + '/' + r.s.d + ').', r.s.n + ' ÷ ' + r.s.d + ' = ' + r.ans.w + ' R ' + r.ans.n + ' → ' + _aAnsText(r.ans) + '.' ], '' + _aAnsText(r.ans) + '') }; } }, { id: 'A6', tag: 'Word problems', level: 2, enumerate: function(){ var out = []; var ctx = [ { item: 'pizza', a: [1, 2], b: [1, 4] }, { item: 'ribbon', a: [2, 3], b: [1, 6] }, { item: 'juice', a: [1, 3], b: [1, 6] }, { item: 'trail', a: [1, 2], b: [1, 3] } ]; for (var i = 0; i < ctx.length; i++){ var c = ctx[i]; out.push({ key: 'w' + i, ci: i, n1: c.a[0], d1: c.a[1], n2: c.b[0], d2: c.b[1] }); } return out; }, make: function(p){ var items = ['pizza', 'ribbon', 'juice', 'trail']; var item = items[p.ci]; var r = _aCalc(p.n1, p.d1, p.n2, p.d2, '+'); return { promptHTML: 'Mia ate ' + p.n1 + '/' + p.d1 + ' of a ' + item + ' and her brother ate ' + p.n2 + '/' + p.d2 + '. How much of the ' + item + ' did they eat in all?', answer: r.ans, hintHTML: 'Add ' + p.n1 + '/' + p.d1 + ' + ' + p.n2 + '/' + p.d2 + '. Rename with the LCD first.', whyHTML: _aWhy([ p.n1 + '/' + p.d1 + ' + ' + p.n2 + '/' + p.d2 + ' = ' + r.tn + '/' + r.L + ' = ' + _aAnsText(r.ans) + '.' ], '' + _aAnsText(r.ans) + '') }; } } ];
/* ---------------- practice session ---------------- */ function PracticeSession(seed){ this._rand = mulberry32(seed >>> 0); this.pools = {}; this.seen = {}; this.score = 0; this.streak = 0; this.problemNum = 0; this.current = null; this.wrongCount = 0; this.revealed = false; this._done = false; } PracticeSession.prototype._draw = function (){ var guard, t, pool, p, key, prob; for (guard = 0; guard < 200; guard++){ t = TEMPLATES[Math.floor(this._rand() * TEMPLATES.length)]; pool = this.pools[t.id]; if (!pool || pool.length === 0){ pool = shuffle(t.enumerate().slice(), this._rand); this.pools[t.id] = pool; } p = pool.pop(); key = t.id + '|' + p.key; if (this.seen[key]) continue; this.seen[key] = 1; prob = t.make(p); prob.templateId = t.id; prob.tag = t.tag; prob.key = key; prob.params = p; prob.answerDisplayHTML = ansHTML(prob.answer); prob.accept = [ansText(prob.answer)]; return prob; } this.pools = {}; this.seen = {}; t = TEMPLATES[0]; p = t.enumerate()[0]; prob = t.make(p); prob.templateId = t.id; prob.tag = t.tag; prob.key = t.id + '|' + p.key; prob.params = p; prob.answerDisplayHTML = ansHTML(prob.answer); prob.accept = [ansText(prob.answer)]; return prob; }; PracticeSession.prototype.next = function (){ this.current = this._draw(); this.wrongCount = 0; this.revealed = false; this._done = false; this.problemNum++; return this.current; }; PracticeSession.prototype.submit = function (raw){ var pr = this.current; if (!pr) return { status: 'none' }; if (this._done) return { status: 'already' }; var parsed = parseByKind(pr.answer.kind, raw); if (!parsed.ok) return { status: 'invalid', entryHint: entryHintFor(pr.answer.kind) }; var ok = checkAnswer(pr.answer, parsed); if (ok){ this._done = true; var firstTry = !(!(this.wrongCount === 0) || this.revealed); if (!this.revealed){ this.streak++; if (firstTry) this.score++; } return { status: 'correct', firstTry: firstTry, score: this.score, streak: this.streak, answerHTML: pr.answerDisplayHTML }; } this.wrongCount++; this.streak = 0; if (this.wrongCount === 1) return { status: 'hint', hintHTML: pr.hintHTML, streak: 0 }; this.revealed = true; return { status: 'why', whyHTML: pr.whyHTML, streak: 0 }; }; PracticeSession.prototype.reveal = function (){ if (!this.current) return null; this.revealed = true; return this.current.whyHTML; }; /* ---------------- game API (wraps the same templates) ---------------- */ var _gameKind = 'int'; function difficultyFor(qi){ if (qi < 5) return 0; if (qi < 12) return 1; return 2; } function genQuestion(rng, asked, qIndex){ var diff = difficultyFor(qIndex); var elig = []; var i, t; for (i = 0; i < TEMPLATES.length; i++){ if (TEMPLATES[i].level <= diff) elig.push(TEMPLATES[i]); } if (elig.length === 0) elig = TEMPLATES; for (var tries = 0; tries < 80; tries++){ t = elig[Math.floor(rng() * elig.length)]; var pool = t.enumerate(); var p = pool[Math.floor(rng() * pool.length)]; var key = t.id + '|' + p.key; if (asked[key]) continue; asked[key] = 1; var prob = t.make(p); _gameKind = prob.answer.kind; return { typeLabel: t.tag, prompt: prob.promptHTML, correct: prob.answer, why: prob.whyHTML, answerDisplayHTML: ansHTML(prob.answer) }; } t = elig[0]; var p0 = t.enumerate()[0]; var pr0 = t.make(p0); _gameKind = pr0.answer.kind; return { typeLabel: t.tag, prompt: pr0.promptHTML, correct: pr0.answer, why: pr0.whyHTML, answerDisplayHTML: ansHTML(pr0.answer) }; } function gameParseAnswer(raw){ return parseByKind(_gameKind, raw); } function gameAnswerEquals(correct, parsed){ return checkAnswer(correct, parsed); } function gameAnswerText(correct){ return ansText(correct); } function gameAnswerHintText(){ return entryHintFor(_gameKind); } function newGameState(){ return { score: 0, streak: 0, bestStreak: 0, lives: 3, timeLeft: 60, qIndex: 0, answered: 0, correct: 0, over: false, overReason: '' }; } function tickTimer(s){ if (s.timeLeft > 0) s.timeLeft--; if (s.timeLeft <= 0){ s.over = true; s.overReason = 'time'; return true; } return false; } function scoreStep(s, ok){ s.answered++; if (ok){ s.correct++; s.streak++; if (s.streak > s.bestStreak) s.bestStreak = s.streak; var pts = 10; if (s.streak >= 4) pts += 2 * (s.streak - 3); s.score += pts; return { points: pts, gameOver: false }; } s.streak = 0; s.lives--; var over = s.lives <= 0; if (over){ s.over = true; s.overReason = 'lives'; } return { points: 0, gameOver: over }; } function accuracyPct(s){ if (s.answered === 0) return 0; return Math.round(s.correct / s.answered * 100); } /* ---------------- worksheet generation ---------------- */ function _wgcd6(a, b){ while (b){ var t = a % b; a = b; b = t; } return a; } function _wlcm6(a, b){ return a / _wgcd6(a, b) * b; } function _wAns6(tn, L){ var g = _wgcd6(tn, L), sn = tn / g, sd = L / g; var w = Math.floor(sn / sd), r = sn % sd; if (r === 0) return { html: '' + w + '', ans: { kind: 'int', v: w } }; if (w === 0) return { html: '' + sn + '/' + sd + '', ans: { kind: 'frac', n: sn, d: sd } }; return { html: '' + w + ' ' + r + '/' + sd + '', ans: { kind: 'mixed', w: w, n: r, d: sd } }; } function wgLikeAdd(){ var d = ri(3, 10), n1 = ri(1, d - 1), n2 = ri(1, d - n1); var A = _wAns6(n1 + n2, d); return { kind: 'likeadd', params: { n1: n1, n2: n2, d: d }, stemHTML: 'Add: ' + n1 + '/' + d + ' + ' + n2 + '/' + d + '.', answerHTML: A.html, answer: A.ans }; } function wgLikeSub(){ var d = ri(3, 10), n1 = ri(2, d - 1), n2 = ri(1, n1 - 1); var A = _wAns6(n1 - n2, d); return { kind: 'likesub', params: { n1: n1, n2: n2, d: d }, stemHTML: 'Subtract: ' + n1 + '/' + d + ' − ' + n2 + '/' + d + '.', answerHTML: A.html, answer: A.ans }; } function wgUnlikeAdd(){ var d1 = ri(2, 6), d2 = d1 + ri(1, 3); var n1 = ri(1, d1 - 1), n2 = ri(1, d2 - 1); var L = _wlcm6(d1, d2), tn = n1 * L / d1 + n2 * L / d2; if (tn >= L){ n1 = 1; n2 = 1; tn = L / d1 + L / d2; } var A = _wAns6(tn, L); return { kind: 'unlikeadd', params: { n1: n1, d1: d1, n2: n2, d2: d2 }, stemHTML: 'Add: ' + n1 + '/' + d1 + ' + ' + n2 + '/' + d2 + '.', answerHTML: A.html, answer: A.ans }; } function wgUnlikeSub(){ var d1 = ri(2, 8), d2 = ri(2, 8); if (d1 === d2) d2 = d2 === 8 ? 6 : d2 + 1; var n1 = ri(1, d1 - 1), n2 = ri(1, d2 - 1); if (n1 / d1 <= n2 / d2){ var t = n1; n1 = n2; n2 = t; var td = d1; d1 = d2; d2 = td; } var L = _wlcm6(d1, d2), tn = n1 * L / d1 - n2 * L / d2; var A = _wAns6(tn, L); return { kind: 'unlikesub', params: { n1: n1, d1: d1, n2: n2, d2: d2 }, stemHTML: 'Subtract: ' + n1 + '/' + d1 + ' − ' + n2 + '/' + d2 + '.', answerHTML: A.html, answer: A.ans }; } var WSGENS = { easy: [wgLikeAdd, wgLikeAdd, wgLikeSub], medium: [wgLikeAdd, wgLikeSub, wgUnlikeAdd, wgUnlikeSub], hard: [wgUnlikeAdd, wgUnlikeSub, wgLikeAdd, wgUnlikeAdd] };
function generateSet(seed, diff){ setSeed(seed); var gens = WSGENS[diff] || WSGENS.medium; var probs = [], seen = {}, guard = 0; while (!(!(probs.length < 20) || !(guard < 600))){ guard++; var g = gens[Math.floor(_rand() * gens.length)]; var pr = g(); if (seen[pr.stemHTML]) continue; seen[pr.stemHTML] = 1; probs.push(pr); } return probs; } /* ---------------- expose ---------------- */ var API = { PracticeSession: PracticeSession, TEMPLATES: TEMPLATES, WSGENS: WSGENS, generateSet: generateSet, genQuestion: genQuestion, difficultyFor: difficultyFor, parseAnswer: gameParseAnswer, parseByKind: parseByKind, parsePartsInput: parsePartsInput, parseQRInput: parseQRInput, answerEquals: gameAnswerEquals, answerText: gameAnswerText, answerHintText: gameAnswerHintText, newGameState: newGameState, scoreStep: scoreStep, tickTimer: tickTimer, accuracyPct: accuracyPct, mulberry32: mulberry32, setSeed: setSeed, ggcd: ggcd, gred: gred, sameFrac: sameFrac, decVal: decVal, decNorm: decNorm, decAdd: decAdd, decSub: decSub, decText: decText, decTextFromVP: decTextFromVP, fracHTML: fracHTML, mixedHTML: mixedHTML, ansHTML: ansHTML, ansText: ansText, checkAnswer: checkAnswer }; return API; });
/* ===== game UI wiring (browser only) — generic; core is node-testable ===== */ (function(){ 'use strict'; var CORE = window.FiboCore_fractions_add_subtract; var $ = function(id){ return document.getElementById(id); };
var HEART_SVG = '';
var startScreen = $('startScreen'), gameScreen = $('gameScreen'), overScreen = $('overScreen'); var startBtn = $('startBtn'), againBtn = $('againBtn'); var answerForm = $('answerForm'), answerInput = $('answerInput'), submitBtn = $('submitBtn'); var feedback = $('feedback');
var state, rng, asked, current, timerId, locked;
function show(screen){ var screens = [startScreen, gameScreen, overScreen]; for (var i = 0; i < screens.length; i++){ screens[i].classList.remove('show'); } screen.classList.add('show'); } function renderHearts(){ var elm = $('livesVal'); elm.innerHTML = ''; for (var i = 0; i < 3; i++){ var span = document.createElement('span'); span.innerHTML = HEART_SVG; var svg = span.firstChild; if (i >= state.lives) svg.classList.add('lost'); elm.appendChild(svg); } elm.setAttribute('aria-label', state.lives + (state.lives === 1 ? ' life' : ' lives') + ' remaining'); }
function renderHud(){ $('timerVal').textContent = state.timeLeft; $('scoreVal').textContent = state.score; $('streakVal').textContent = state.streak; $('bestStreakHud').textContent = 'Best streak ' + state.bestStreak; $('qCount').textContent = 'Question ' + (state.qIndex + 1); $('timerBox').classList.toggle('timer-low', state.timeLeft <= 10); $('timeFill').style.width = (state.timeLeft / 60 * 100) + '%'; renderHearts(); } function nextQuestion(){ locked = false; current = CORE.genQuestion(rng, asked, state.qIndex); state.qIndex += 1; $('qType').textContent = current.typeLabel; $('qPrompt').innerHTML = current.prompt; feedback.className = 'feedback'; feedback.innerHTML = ''; answerInput.value = ''; answerInput.disabled = false; submitBtn.disabled = false; renderHud(); setTimeout(function(){ answerInput.focus(); }, 60); } function stopTimer(){ if (timerId){ clearInterval(timerId); timerId = null; } } function startTimer(){ stopTimer(); timerId = setInterval(function(){ var expired = CORE.tickTimer(state); renderHud(); if (expired) endGame(); }, 1000); } function startGame(){ state = CORE.newGameState(); rng = CORE.mulberry32(Date.now() % 2147483647); asked = {}; show(gameScreen); nextQuestion(); startTimer(); } function endGame(){ stopTimer(); state.over = true; var title = $('overTitle'), reason = $('overReason'); if (state.overReason === 'lives'){ title.textContent = 'Out of lives!'; reason.textContent = 'You used all 3 lives. The streak bonus rewards careful answers — take a breath and sprint again.'; } else { title.textContent = 'Time’s up!'; reason.textContent = '60 seconds on the clock. Your best streak shows how far careful speed can take you.'; } $('finalScore').textContent = state.score; $('finalBest').textContent = state.bestStreak; $('finalAnswered').textContent = state.answered; $('finalAcc').textContent = CORE.accuracyPct(state) + '%'; show(overScreen); setTimeout(function(){ againBtn.focus(); }, 60); } function onSubmit(ev){ ev.preventDefault(); if (locked || state.over) return; var raw = answerInput.value; var parsed = CORE.parseAnswer(raw); var ok = CORE.answerEquals(current.correct, parsed); var res = CORE.scoreStep(state, ok); locked = true; answerInput.disabled = true; submitBtn.disabled = true; var fbClass, title, body; if (ok){ fbClass = 'correct'; title = 'Correct! +' + res.points + ' points'; body = (state.streak >= 4) ? 'Streak of ' + state.streak + ' — streak bonus applied (+' + (2 * (state.streak - 3)) + ').' : 'Keep the streak going for bonus points.'; } else { fbClass = 'wrong'; title = 'Not quite — 1 life lost.'; if (parsed === null){ body = 'I could not read that answer. ' + CORE.answerHintText() + ' ' + '' + current.why + ''; } else { body = 'The correct answer is ' + CORE.answerText(current.correct) + '. ' + '' + current.why + ''; } } feedback.className = 'feedback show ' + fbClass; feedback.innerHTML = '
'; renderHud();
if (res.gameOver){ setTimeout(endGame, 1400); } else { setTimeout(nextQuestion, ok ? 1100 : 2400); } }
startBtn.addEventListener('click', startGame); againBtn.addEventListener('click', startGame); answerForm.addEventListener('submit', onSubmit); })();