Spaces:
Sleeping
Sleeping
| """Math game logic: problem generation + adaptive difficulty. | |
| No UI / model dependencies live here so it can be unit-tested in isolation. | |
| A "problem" is a plain dict: | |
| { | |
| "skill": str, # which skill this exercises | |
| "display": str, # big text/emoji shown on screen | |
| "spoken": str, # plain-English text read aloud by TTS | |
| "answer": str, # the correct choice (as a string) | |
| "choices": list[str], # 4 shuffled multiple-choice options | |
| } | |
| Game state is a plain dict so it can live in a Gradio gr.State: | |
| {"level": int, "score": int, "streak": int, "wrong_streak": int, "asked": int} | |
| """ | |
| import random | |
| MIN_LEVEL = 1 | |
| MAX_LEVEL = 10 | |
| LEVEL_UP_STREAK = 3 # correct-in-a-row needed to level up | |
| LEVEL_DOWN_STREAK = 2 # wrong-in-a-row before easing down | |
| SHAPES = ["circle", "square", "triangle", "star", "heart", "diamond"] | |
| SHAPE_EMOJI = { | |
| "circle": "⚪", | |
| "square": "🟦", | |
| "triangle": "🔺", | |
| "star": "⭐", | |
| "heart": "❤️", | |
| "diamond": "🔷", | |
| } | |
| SHAPE_SIDES = {"triangle": 3, "square": 4, "circle": 0} | |
| # Friendly emoji used to render "count the objects" problems. | |
| OBJECT_EMOJI = ["🍎", "🍌", "🐶", "🐱", "🐟", "🌟", "🚗", "🎈", "🍓", "🦋"] | |
| def new_state(): | |
| """Return a fresh game-state dict.""" | |
| return {"level": 1, "score": 0, "streak": 0, "wrong_streak": 0, "asked": 0} | |
| # -------------------------------------------------------------------------- | |
| # Difficulty / skill selection | |
| # -------------------------------------------------------------------------- | |
| def _skills_for_level(level): | |
| """Which skills are unlocked at a given level (harder skills appear later).""" | |
| skills = ["add_sub", "count_compare", "shapes_patterns"] | |
| if level >= 4: | |
| skills.append("multiply") | |
| return skills | |
| def update_state(state, correct): | |
| """Apply the adaptive rule after an answer. Mutates and returns ``state``. | |
| Level up after LEVEL_UP_STREAK correct in a row; level down after | |
| LEVEL_DOWN_STREAK wrong in a row. Streaks reset on the opposite outcome. | |
| """ | |
| state["asked"] += 1 | |
| if correct: | |
| state["score"] += 1 | |
| state["streak"] += 1 | |
| state["wrong_streak"] = 0 | |
| if state["streak"] >= LEVEL_UP_STREAK and state["level"] < MAX_LEVEL: | |
| state["level"] += 1 | |
| state["streak"] = 0 | |
| else: | |
| state["wrong_streak"] += 1 | |
| state["streak"] = 0 | |
| if state["wrong_streak"] >= LEVEL_DOWN_STREAK and state["level"] > MIN_LEVEL: | |
| state["level"] -= 1 | |
| state["wrong_streak"] = 0 | |
| return state | |
| # -------------------------------------------------------------------------- | |
| # Helpers | |
| # -------------------------------------------------------------------------- | |
| def _make_choices(answer, distractors, n=4): | |
| """Build n unique shuffled string choices that always include ``answer``.""" | |
| answer_str = str(answer) | |
| choices = [answer_str] | |
| for d in distractors: | |
| d = str(d) | |
| if d not in choices: | |
| choices.append(d) | |
| if len(choices) >= n: | |
| break | |
| # Pad if we still came up short (e.g. tiny numbers near 0): step outward. | |
| if len(choices) < n: | |
| try: | |
| base = int(float(answer)) | |
| k = 1 | |
| while len(choices) < n and k < 50: | |
| cand = str(base + k) | |
| if cand not in choices: | |
| choices.append(cand) | |
| k += 1 | |
| except (ValueError, TypeError): | |
| pass | |
| random.shuffle(choices) | |
| return choices | |
| def _numeric_distractors(answer, spread=3, allow_negative=False): | |
| """Plausible near-miss numbers around ``answer``.""" | |
| candidates = set() | |
| attempts = 0 | |
| while len(candidates) < 6 and attempts < 40: | |
| attempts += 1 | |
| delta = random.randint(-spread, spread) | |
| val = answer + delta | |
| if val == answer: | |
| continue | |
| if val < 0 and not allow_negative: | |
| continue | |
| candidates.add(val) | |
| return list(candidates) | |
| # -------------------------------------------------------------------------- | |
| # Skill generators -- each returns a problem dict | |
| # -------------------------------------------------------------------------- | |
| def _gen_add_sub(level): | |
| if level <= 1: | |
| a, b = random.randint(0, 5), random.randint(0, 5) | |
| op = "+" | |
| elif level == 2: | |
| a, b = random.randint(0, 10), random.randint(0, 10) | |
| op = "+" | |
| elif level == 3: | |
| a = random.randint(2, 10) | |
| b = random.randint(0, a) # subtraction within 10, no negatives | |
| op = "-" | |
| elif level == 4: | |
| op = random.choice(["+", "-"]) | |
| if op == "+": | |
| a, b = random.randint(0, 20), random.randint(0, 20 - 0) | |
| b = random.randint(0, 20 - a) if a <= 20 else 0 | |
| else: | |
| a = random.randint(2, 20) | |
| b = random.randint(0, a) | |
| elif level == 5: | |
| # missing addend: a + ? = total | |
| a = random.randint(1, 15) | |
| b = random.randint(1, 20 - a) if a < 20 else 1 | |
| total = a + b | |
| return { | |
| "skill": "add_sub", | |
| "display": f"{a} + ? = {total}", | |
| "spoken": f"{a} plus what makes {total}?", | |
| "answer": str(b), | |
| "choices": _make_choices(b, _numeric_distractors(b, 3)), | |
| } | |
| elif level <= 7: | |
| op = random.choice(["+", "-"]) | |
| if op == "+": | |
| a = random.randint(10, 50) | |
| b = random.randint(1, 99 - a) | |
| else: | |
| a = random.randint(10, 99) | |
| b = random.randint(1, a) | |
| else: # level 8-10 | |
| op = random.choice(["+", "-"]) | |
| if op == "+": | |
| a = random.randint(20, 80) | |
| b = random.randint(10, 99 - a) if a < 99 else 10 | |
| else: | |
| a = random.randint(30, 99) | |
| b = random.randint(10, a) | |
| answer = a + b if op == "+" else a - b | |
| spread = 3 if answer < 20 else max(3, answer // 10) | |
| return { | |
| "skill": "add_sub", | |
| "display": f"{a} {op} {b} = ?", | |
| "spoken": f"What is {a} {'plus' if op == '+' else 'minus'} {b}?", | |
| "answer": str(answer), | |
| "choices": _make_choices(answer, _numeric_distractors(answer, spread)), | |
| } | |
| def _gen_count_compare(level): | |
| if level <= 1: | |
| n = random.randint(1, 5) | |
| emoji = random.choice(OBJECT_EMOJI) | |
| return { | |
| "skill": "count_compare", | |
| "display": emoji * n, | |
| "spoken": "How many do you see?", | |
| "answer": str(n), | |
| "choices": _make_choices(n, _numeric_distractors(n, 2)), | |
| } | |
| if level == 2: | |
| n = random.randint(3, 10) | |
| emoji = random.choice(OBJECT_EMOJI) | |
| return { | |
| "skill": "count_compare", | |
| "display": emoji * n, | |
| "spoken": "How many do you see?", | |
| "answer": str(n), | |
| "choices": _make_choices(n, _numeric_distractors(n, 2)), | |
| } | |
| if level == 3: | |
| a, b = random.sample(range(0, 11), 2) | |
| bigger = max(a, b) | |
| return { | |
| "skill": "count_compare", | |
| "display": f"{a} or {b}", | |
| "spoken": f"Which number is bigger, {a} or {b}?", | |
| "answer": str(bigger), | |
| "choices": _make_choices( | |
| bigger, [min(a, b)] + _numeric_distractors(bigger, 3) | |
| ), | |
| } | |
| # level 4+: missing number in a sequence (skip-counting at higher levels) | |
| if level <= 4: | |
| step = 1 | |
| elif level <= 6: | |
| step = random.choice([1, 2, 5]) | |
| else: | |
| step = random.choice([2, 5, 10]) | |
| start = random.randint(0, 10) * step + random.randint(0, step) | |
| seq = [start + step * i for i in range(4)] | |
| hide = random.randint(1, 2) # hide an interior term | |
| answer = seq[hide] | |
| shown = [str(x) if i != hide else "?" for i, x in enumerate(seq)] | |
| return { | |
| "skill": "count_compare", | |
| "display": " ".join(shown), | |
| "spoken": "What number is missing?", | |
| "answer": str(answer), | |
| "choices": _make_choices(answer, _numeric_distractors(answer, max(2, step))), | |
| } | |
| def _gen_multiply(level): | |
| if level <= 4: | |
| groups = random.randint(2, 3) | |
| per = random.randint(2, 3) | |
| emoji = random.choice(OBJECT_EMOJI) | |
| display = " ".join([emoji * per] * groups) | |
| answer = groups * per | |
| return { | |
| "skill": "multiply", | |
| "display": display, | |
| "spoken": f"{groups} groups of {per}. How many in total?", | |
| "answer": str(answer), | |
| "choices": _make_choices(answer, _numeric_distractors(answer, 3)), | |
| } | |
| if level == 5: | |
| table = random.choice([2, 10]) | |
| other = random.randint(1, 10) | |
| elif level <= 7: | |
| table = random.choice([2, 5, 10]) | |
| other = random.randint(1, 10) | |
| else: | |
| table = random.randint(2, 9) | |
| other = random.randint(2, 9) | |
| answer = table * other | |
| return { | |
| "skill": "multiply", | |
| "display": f"{table} × {other} = ?", | |
| "spoken": f"What is {table} times {other}?", | |
| "answer": str(answer), | |
| "choices": _make_choices(answer, _numeric_distractors(answer, max(3, table))), | |
| } | |
| def _gen_shapes_patterns(level): | |
| if level <= 1: | |
| shape = random.choice(SHAPES) | |
| distract = random.sample([s for s in SHAPES if s != shape], 3) | |
| return { | |
| "skill": "shapes_patterns", | |
| "display": SHAPE_EMOJI[shape], | |
| "spoken": "What shape is this?", | |
| "answer": shape, | |
| "choices": _make_choices(shape, distract), | |
| } | |
| if level == 2: | |
| target = random.choice(SHAPES) | |
| return { | |
| "skill": "shapes_patterns", | |
| "display": "🔎", | |
| "spoken": f"Which one is the {target}?", | |
| "answer": SHAPE_EMOJI[target], | |
| "choices": _make_choices( | |
| SHAPE_EMOJI[target], | |
| [SHAPE_EMOJI[s] for s in SHAPES if s != target], | |
| ), | |
| } | |
| if level <= 4: | |
| # ABAB pattern -> what comes next? | |
| a, b = random.sample(SHAPES, 2) | |
| ea, eb = SHAPE_EMOJI[a], SHAPE_EMOJI[b] | |
| seq = [ea, eb, ea, eb] | |
| nxt = ea # next after ...ea, eb, ea, eb is ea | |
| return { | |
| "skill": "shapes_patterns", | |
| "display": " ".join(seq) + " ?", | |
| "spoken": "What comes next in the pattern?", | |
| "answer": nxt, | |
| "choices": _make_choices(nxt, [eb] + [SHAPE_EMOJI[s] for s in SHAPES | |
| if s not in (a, b)]), | |
| } | |
| if level <= 6: | |
| # AABB pattern | |
| a, b = random.sample(SHAPES, 2) | |
| ea, eb = SHAPE_EMOJI[a], SHAPE_EMOJI[b] | |
| seq = [ea, ea, eb, eb, ea, ea] | |
| nxt = eb | |
| return { | |
| "skill": "shapes_patterns", | |
| "display": " ".join(seq) + " ?", | |
| "spoken": "What comes next in the pattern?", | |
| "answer": nxt, | |
| "choices": _make_choices(nxt, [ea] + [SHAPE_EMOJI[s] for s in SHAPES | |
| if s not in (a, b)]), | |
| } | |
| # level 7+: count the sides of a shape | |
| shape = random.choice([s for s in SHAPE_SIDES]) | |
| answer = SHAPE_SIDES[shape] | |
| return { | |
| "skill": "shapes_patterns", | |
| "display": SHAPE_EMOJI[shape], | |
| "spoken": f"How many sides does a {shape} have?", | |
| "answer": str(answer), | |
| "choices": _make_choices(answer, [0, 3, 4, 5, 6]), | |
| } | |
| _GENERATORS = { | |
| "add_sub": _gen_add_sub, | |
| "count_compare": _gen_count_compare, | |
| "multiply": _gen_multiply, | |
| "shapes_patterns": _gen_shapes_patterns, | |
| } | |
| def generate_problem(level, skill=None): | |
| """Return a problem dict for ``level`` (1-10), optionally forcing a skill.""" | |
| level = max(MIN_LEVEL, min(MAX_LEVEL, int(level))) | |
| if skill is None: | |
| skill = random.choice(_skills_for_level(level)) | |
| problem = _GENERATORS[skill](level) | |
| # Safety: guarantee the answer is among the (deduplicated) choices. | |
| if problem["answer"] not in problem["choices"]: | |
| problem["choices"][0] = problem["answer"] | |
| random.shuffle(problem["choices"]) | |
| return problem | |