Files
Hulumath-Web/backend/contest/game_services.py
T

221 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}