MathMai / game_logic.py
Heterogeneity2025's picture
Upload 5 files
f4b7b59 verified
Raw
History Blame Contribute Delete
12.3 kB
"""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