221 lines
8.1 KiB
Python
221 lines
8.1 KiB
Python
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}
|