feat: add math engine and puzzle services

This commit is contained in:
2026-08-09 01:52:20 +08:00
parent 6bcff55c4f
commit b99a22fc06
21 changed files with 1011 additions and 1 deletions
+220
View File
@@ -0,0 +1,220 @@
import ast
import secrets
from collections import Counter
from fractions import Fraction
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from .models import MathGameAttempt
SUDOKU_PUZZLES = {
MathGameAttempt.Difficulty.EASY: [
(
"530070000600195000098000060800060003400803001700020006060000280000419005000080079",
"534678912672195348198342567859761423426853791713924856961537284287419635345286179",
),
],
MathGameAttempt.Difficulty.STANDARD: [
(
"000260701680070090190004500820100040004602900050003028009300074040050036703018000",
"435269781682571493197834562826195347374682915951743628519326874248957136763418259",
),
],
MathGameAttempt.Difficulty.HARD: [
(
"000000010400000000020000000000050407008000300001090000300400200050100000000806000",
"693784512487512936125963874932651487568247391741398625319475268856129743274836159",
),
],
}
TWENTY_FOUR_PUZZLES = {
MathGameAttempt.Difficulty.EASY: [(3, 3, 8, 8), (1, 3, 4, 6), (4, 4, 10, 10)],
MathGameAttempt.Difficulty.STANDARD: [(2, 3, 4, 9), (3, 5, 7, 13), (4, 7, 8, 8)],
MathGameAttempt.Difficulty.HARD: [(1, 5, 5, 5), (3, 3, 7, 7), (5, 5, 7, 11)],
}
def _grid_from_text(value):
return [[int(value[row * 9 + column]) for column in range(9)] for row in range(9)]
def game_payload(attempt):
return {
"attempt_id": attempt.id,
"kind": attempt.kind,
"difficulty": attempt.difficulty,
"status": attempt.status,
"puzzle": attempt.puzzle,
"score": attempt.score,
"duration_ms": attempt.duration_ms,
"hints_used": attempt.hints_used,
"started_at": attempt.started_at,
}
def start_game(user, kind, difficulty):
if kind not in MathGameAttempt.Kind.values:
raise ValidationError({"kind": "不支持的数学玩法"})
if difficulty not in MathGameAttempt.Difficulty.values:
raise ValidationError({"difficulty": "不支持的难度"})
if kind == MathGameAttempt.Kind.SUDOKU:
puzzle_text, solution_text = secrets.choice(SUDOKU_PUZZLES[difficulty])
puzzle = {"grid": _grid_from_text(puzzle_text), "hints": []}
solution = {"grid": _grid_from_text(solution_text)}
else:
numbers = list(secrets.choice(TWENTY_FOUR_PUZZLES[difficulty]))
secrets.SystemRandom().shuffle(numbers)
puzzle = {"numbers": numbers}
solution = {"target": 24}
attempt = MathGameAttempt.objects.create(
user=user,
kind=kind,
difficulty=difficulty,
puzzle=puzzle,
solution=solution,
)
return game_payload(attempt)
def _validate_twenty_four_expression(source, numbers):
source = str(source or "").strip()
if not source or len(source) > 120:
raise ValidationError({"expression": "请输入不超过 120 个字符的表达式"})
try:
tree = ast.parse(source, mode="eval")
except SyntaxError as exc:
raise ValidationError({"expression": "表达式语法无效"}) from exc
used = []
def evaluate(node):
if isinstance(node, ast.Constant) and isinstance(node.value, int):
used.append(node.value)
return Fraction(node.value)
if isinstance(node, ast.BinOp) and isinstance(
node.op,
(ast.Add, ast.Sub, ast.Mult, ast.Div),
):
left = evaluate(node.left)
right = evaluate(node.right)
if isinstance(node.op, ast.Add):
return left + right
if isinstance(node.op, ast.Sub):
return left - right
if isinstance(node.op, ast.Mult):
return left * right
if right == 0:
raise ValidationError({"expression": "不能除以零"})
return left / right
raise ValidationError({"expression": "只允许题目数字、括号和 + - * /"})
result = evaluate(tree.body)
if Counter(used) != Counter(numbers):
raise ValidationError({"expression": "必须且只能使用题目给出的四个数字各一次"})
if result != 24:
raise ValidationError({"expression": f"当前结果是 {result},还没有得到 24"})
return source
def _validate_sudoku_grid(raw_grid, puzzle, solution):
if (
not isinstance(raw_grid, list)
or len(raw_grid) != 9
or any(not isinstance(row, list) or len(row) != 9 for row in raw_grid)
):
raise ValidationError({"grid": "数独答案必须是 9×9 网格"})
try:
grid = [[int(value) for value in row] for row in raw_grid]
except (TypeError, ValueError) as exc:
raise ValidationError({"grid": "每个格子必须填写 1 到 9"}) from exc
if any(value < 1 or value > 9 for row in grid for value in row):
raise ValidationError({"grid": "每个格子必须填写 1 到 9"})
givens = puzzle["grid"]
for row in range(9):
for column in range(9):
if givens[row][column] and grid[row][column] != givens[row][column]:
raise ValidationError({"grid": "不能修改题目给出的数字"})
if grid != solution["grid"]:
raise ValidationError({"grid": "答案尚未满足全部行、列和九宫格"})
return grid
@transaction.atomic
def submit_game(user, attempt_id, submission, submission_key):
attempt = MathGameAttempt.objects.select_for_update().get(id=attempt_id, user=user)
if attempt.status == MathGameAttempt.Status.COMPLETED:
if submission_key and attempt.submission_key == submission_key:
return game_payload(attempt)
raise ValidationError("这局游戏已经完成")
if not submission_key:
raise ValidationError({"Idempotency-Key": "提交必须提供幂等键"})
if attempt.kind == MathGameAttempt.Kind.SUDOKU:
normalized = {
"grid": _validate_sudoku_grid(
submission.get("grid"),
attempt.puzzle,
attempt.solution,
)
}
else:
normalized = {
"expression": _validate_twenty_four_expression(
submission.get("expression"),
attempt.puzzle["numbers"],
)
}
now = timezone.now()
duration_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000))
base_score = 1800 if attempt.kind == MathGameAttempt.Kind.SUDOKU else 1000
time_penalty = duration_ms // (2000 if attempt.kind == MathGameAttempt.Kind.SUDOKU else 1000)
attempt.submission = normalized
attempt.status = MathGameAttempt.Status.COMPLETED
attempt.duration_ms = duration_ms
attempt.score = max(100, base_score - time_penalty - attempt.hints_used * 150)
attempt.submission_key = submission_key
attempt.submitted_at = now
attempt.save(
update_fields=[
"submission",
"status",
"duration_ms",
"score",
"submission_key",
"submitted_at",
]
)
return game_payload(attempt)
@transaction.atomic
def request_sudoku_hint(user, attempt_id):
attempt = MathGameAttempt.objects.select_for_update().get(id=attempt_id, user=user)
if attempt.kind != MathGameAttempt.Kind.SUDOKU:
raise ValidationError("只有数独支持提示")
if attempt.status != MathGameAttempt.Status.ACTIVE:
raise ValidationError("这局游戏已经结束")
hints = list(attempt.puzzle.get("hints", []))
if len(hints) >= 3:
raise ValidationError("每局最多使用 3 次提示")
candidates = [
(row, column)
for row in range(9)
for column in range(9)
if attempt.puzzle["grid"][row][column] == 0
and not any(item["row"] == row and item["column"] == column for item in hints)
]
row, column = secrets.choice(candidates)
hint = {
"row": row,
"column": column,
"value": attempt.solution["grid"][row][column],
}
hints.append(hint)
attempt.puzzle = {**attempt.puzzle, "hints": hints}
attempt.hints_used = len(hints)
attempt.save(update_fields=["puzzle", "hints_used"])
return {**hint, "hints_used": attempt.hints_used}