import ast import secrets import unicodedata 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)], } TWENTY_FOUR_SYMBOLS = str.maketrans( { "×": "*", "·": "*", "∙": "*", "÷": "/", "−": "-", "–": "-", "—": "-", } ) 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, "match_id": attempt.match_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 build_game(kind, difficulty=MathGameAttempt.Difficulty.STANDARD): 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} return puzzle, solution def start_game(user, kind, difficulty=MathGameAttempt.Difficulty.STANDARD): puzzle, solution = build_game(kind, difficulty) 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 = unicodedata.normalize("NFKC", str(source or "")).translate( TWENTY_FOUR_SYMBOLS ) source = source.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) and not isinstance(node.value, bool) ): 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.ACTIVE: if ( attempt.status == MathGameAttempt.Status.COMPLETED and 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}