21 Commits
Author SHA1 Message Date
Jacky 587962f9c9 Merge pull request 'new_bug_contest' (#23) from bug3.0 into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/23
2026-08-09 16:58:35 +08:00
huluxia 94188b5f0a new_bug_contest
CI / test (pull_request) Failing after 2m6s
PR合并自动部署 / release-check (pull_request) Successful in 13s
PR合并自动部署 / deploy (pull_request) Successful in 13s
2026-08-09 16:45:22 +08:00
Jacky 46815c28f1 Merge pull request 'Bug2.0' (#20) from bug2.0 into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/20
2026-08-09 16:22:21 +08:00
huluxia 589adfa5e6 contest
CI / test (pull_request) Successful in 4m2s
PR合并自动部署 / release-check (pull_request) Successful in 12s
PR合并自动部署 / deploy (pull_request) Successful in 14s
2026-08-09 16:15:41 +08:00
huluxia 9f9aa2825a update select video
CI / test (pull_request) Failing after 2m20s
2026-08-09 15:59:09 +08:00
huluxia ee006fba18 test contest 2026-08-09 15:33:07 +08:00
Jacky ebd6070aae Merge pull request 'ci: remove duplicate release dependency installs' (#19) from ci/avoid-repeat-dependency-downloads into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/19
2026-08-09 04:00:56 +08:00
Jacky 52816ce442 ci: remove duplicate release dependency installs
CI / test (pull_request) Successful in 3m24s
PR合并自动部署 / release-check (pull_request) Successful in 12s
PR合并自动部署 / deploy (pull_request) Successful in 12s
2026-08-09 03:58:04 +08:00
Jacky 903ead60c9 Merge pull request 'fix: restore KaTeX assets and deployment checks' (#16) from fix/latex-assets-and-deploy into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/16
2026-08-09 03:48:12 +08:00
Jacky 3e47ba787d fix: restore KaTeX assets and deployment checks
CI / test (pull_request) Canceled after 7s
PR合并自动部署 / release-check (pull_request) Successful in 1m40s
PR合并自动部署 / deploy (pull_request) Successful in 14s
2026-08-09 03:47:17 +08:00
Jacky feb8845ec7 Merge pull request 'Fix/api error details' (#14) from fix/api-error-details into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/14
2026-08-09 03:37:16 +08:00
Jacky f177739824 merge: sync latest main
CI / test (pull_request) Successful in 3m3s
PR合并自动部署 / release-check (pull_request) Successful in 1m32s
PR合并自动部署 / deploy (pull_request) Failing after 10s
2026-08-09 03:36:14 +08:00
Jacky aa3246a1cf merge: integrate bug1.0 updates 2026-08-09 03:34:36 +08:00
Jacky 2b87b32f5b Merge pull request 'update bug1.0' (#13) from bug1.0 into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/13
2026-08-09 03:34:04 +08:00
Jacky ce7bed5f19 fix: harden twenty four game submissions 2026-08-09 03:30:31 +08:00
huluxia 9f1872c558 update bug1.0
CI / test (pull_request) Successful in 3m8s
PR合并自动部署 / release-check (pull_request) Successful in 1m31s
PR合并自动部署 / deploy (pull_request) Failing after 10s
2026-08-09 03:29:50 +08:00
Jacky b1ac6cef86 fix: expose actionable API error details 2026-08-09 03:26:45 +08:00
Jacky 6dc9052219 Merge pull request 'Feat/realtime challenge match' (#12) from feat/realtime-challenge-match into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/12
2026-08-09 03:12:43 +08:00
Jacky 1dd828609e feat: add realtime challenge client and local smoke test
CI / test (pull_request) Successful in 2m53s
PR合并自动部署 / release-check (pull_request) Successful in 1m34s
PR合并自动部署 / deploy (pull_request) Successful in 12s
2026-08-09 03:08:01 +08:00
Jacky 821311f4ad feat: complete realtime challenge matchmaking 2026-08-09 03:07:38 +08:00
Jacky b6fc2431ca 更新 CONTRIBUTING.md 2026-08-09 02:24:14 +08:00
113 changed files with 2944 additions and 174 deletions
+10
View File
@@ -60,6 +60,16 @@ jobs:
- name: ASGI import check
working-directory: backend
run: ../.venv-ci/bin/python -c "from config.asgi import application; print(type(application).__name__)"
- name: Production static assets check
working-directory: backend
env:
DJANGO_DEBUG: "false"
DJANGO_USE_HTTPS: "false"
DJANGO_SECRET_KEY: ci-static-assets-check
DJANGO_ALLOWED_HOSTS: localhost
DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
REDIS_URL: redis://127.0.0.1:6379/0
run: ../.venv-ci/bin/python manage.py collectstatic --noinput --clear
- name: SQLite tests and coverage
run: |
.venv-ci/bin/python -m pytest -q \
+11 -51
View File
@@ -15,64 +15,24 @@ jobs:
release-check:
if: ${{ github.event.pull_request.merged == true }}
runs-on: ubuntu-latest
timeout-minutes: 15
services:
mysql:
image: mysql:8.0.35
env:
MYSQL_ROOT_PASSWORD: ci-root-password
MYSQL_DATABASE: hulumath
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -pci-root-password --silent"
--health-interval 5s
--health-timeout 5s
--health-retries 20
timeout-minutes: 5
steps:
- name: 检出 main
env:
REPOSITORY_URL: http://117.72.28.96:8765/Jacky/Hulumath-Web.git
run: git clone --branch main --single-branch "$REPOSITORY_URL" .
- name: 显示 Python 版本
run: python3 --version
- name: 安装 MySQL 编译依赖
- name: 检查发布文件和脚本语法
run: |
sudo apt-get update
sudo apt-get install -y \
default-libmysqlclient-dev \
pkg-config \
python3-dev \
python3-venv
- name: 安装发布检查依赖
run: |
python3 -m venv .venv-release
.venv-release/bin/python -m pip install \
--index-url https://mirrors.aliyun.com/pypi/simple \
--timeout 120 \
--retries 5 \
-r requirements.txt
- name: 检查迁移文件
working-directory: backend
run: ../.venv-release/bin/python manage.py makemigrations --check --dry-run
- name: Django 系统检查
working-directory: backend
run: ../.venv-release/bin/python manage.py check
- name: ASGI 启动导入检查
working-directory: backend
run: ../.venv-release/bin/python -c "from config.asgi import application; print(type(application).__name__)"
- name: MySQL 发布迁移检查
env:
DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
run: |
.venv-release/bin/python backend/manage.py check --database default
.venv-release/bin/python backend/manage.py check_mysql
.venv-release/bin/python backend/manage.py migrate --noinput
set -eu
test -f requirements.txt
test -f backend/manage.py
test -f deploy/hulumath-web.service
test -f scripts/deploy_production.sh
test -f scripts/smoke_production.py
bash -n scripts/deploy_production.sh
python3 -m compileall -q backend scripts
git diff-tree --check -m -r HEAD
deploy:
if: ${{ github.event.pull_request.merged == true }}
+3 -1
View File
@@ -1,3 +1,5 @@
新功能前要用分支发PR!禁止直接向main提交。
# Hulumath-Web 代码贡献指南
本指南面向人类开发者和 AI 编程助手。开始修改前,请完整阅读本文。
@@ -426,7 +428,7 @@ PR 描述至少包含:
2. 处理审查意见,不要无解释地关闭讨论。
3. 请求 Jacky 审查。
4. 只有 Jacky 明确批准后才可合并。
5. 合并后观察 `PR合并自动部署``release-check` `deploy`
5. 合并后观察 `PR合并自动部署``release-check` 做无依赖轻量检查,`deploy` 执行生产发布与冒烟
6. 部署失败时保留日志,先判断是代码、迁移、网络还是冒烟检查问题。
## 14. 安全与隐私
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: install migrate seed run test check
.PHONY: install migrate seed run run-asgi test check
install:
python3 -m venv .venv
@@ -14,6 +14,9 @@ seed:
run:
cd backend && ../.venv/bin/python manage.py runserver
run-asgi:
cd backend && ../.venv/bin/uvicorn config.asgi:application --host 127.0.0.1 --port 8000 --reload
test:
.venv/bin/python -m pytest -q
+1
View File
@@ -76,6 +76,7 @@ Gitea 会在 PR 合并到 `main` 后自动测试和部署:
- [Ubuntu + 宝塔面板从零部署](docs/BAOTA_UBUNTU_FROM_ZERO.md)
- [自动发布机制与运维说明](docs/DEPLOYMENT.md)
- [MySQL 8 数据迁移说明](docs/MYSQL8_MIGRATION.md)
- [本地实时 1v1 与联机码约战测试](docs/LOCAL_REALTIME_TEST.md)
## 目录
+52 -4
View File
@@ -1,17 +1,65 @@
import logging
from rest_framework.views import exception_handler as drf_exception_handler
logger = logging.getLogger(__name__)
def _error_messages(details):
messages = []
def collect(value):
if isinstance(value, dict):
for item in value.values():
collect(item)
elif isinstance(value, (list, tuple)):
for item in value:
collect(item)
elif value is not None:
message = str(value).strip()
if message and message not in messages:
messages.append(message)
collect(details)
return messages
def _error_message(details):
messages = _error_messages(details)
if not messages:
return "请求未能完成"
return "".join(messages)[:300]
def exception_handler(exc, context):
response = drf_exception_handler(exc, context)
request = context.get("request")
if response is None:
logger.exception(
"api_unhandled_error method=%s path=%s",
getattr(request, "method", "-"),
getattr(request, "path", "-"),
)
return response
request = context.get("request")
details = response.data
code = getattr(exc, "default_code", "request_error")
message = _error_message(details)
fields = ",".join(details.keys()) if isinstance(details, dict) else "-"
logger.info(
"api_request_error method=%s path=%s status=%s code=%s fields=%s message=%s",
getattr(request, "method", "-"),
getattr(request, "path", "-"),
response.status_code,
code,
fields,
message,
)
response.data = {
"error": {
"code": getattr(exc, "default_code", "request_error"),
"message": "请求未能完成",
"details": response.data,
"code": code,
"message": message,
"details": details,
"request_id": getattr(request, "request_id", None),
}
}
+67
View File
@@ -1,6 +1,9 @@
import re
from pathlib import Path
from django.conf import settings
from django.core.management import call_command
from django.test import override_settings
STATIC_ROOT = Path(settings.BASE_DIR) / "static"
@@ -13,6 +16,18 @@ def test_app_js_幂等键兼容非安全上下文():
assert source.count('"Idempotency-Key": createIdempotencyKey()') == 2
def test_app_js_api_错误优先显示详情并输出安全诊断日志():
source = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
assert "function collectApiErrorMessages(" in source
assert "const baseMessage = detailMessages.length" in source
assert 'console.error("[Hulumath API]", {' in source
assert "requestId: error.requestId" in source
assert "error.details = details || null" in source
assert "payload.error?.message || payload.detail ||" in source
assert "payload.error?.message || payload.detail ||\n (details ?" not in source
def test_app_css_答题提交按钮可见且长弹窗可滚动():
source = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
@@ -51,7 +66,59 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在():
assert "pointerdown" in toolbox
assert "window.HuluGames" in games
assert "sudoku-board" in games
assert 'errorMessage.className = "form-error game-form-error"' in games
assert "errorMessage.textContent = error.message" in games
assert "touch-action: none" in styles
assert ".calculator-controls [hidden]" in styles
assert ".sudoku-board" in styles
assert "@media (max-width: 700px)" in styles
def test_latex_默认源码可渲染且_katex_静态资源完整():
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
katex_root = STATIC_ROOT / "vendor" / "katex"
katex_css = (katex_root / "katex.min.css").read_text(encoding="utf-8")
font_paths = set(re.findall(r"url\(fonts/([^)]+)\)", katex_css))
assert (
'<textarea id="latex-source" spellcheck="false">'
r"\int_{0}^{1} x^2\,dx = \frac{1}{3}</textarea>"
) in template
assert r"\\int_{0}^{1}" not in template
assert "function normalizeLatexSource(source)" in app
assert "globalThis.katex.render(normalized, target" in app
assert "throwOnError: true" in app
assert len(font_paths) == 60
assert all((katex_root / "fonts" / path).is_file() for path in font_paths)
assert (katex_root / "LICENSE.txt").is_file()
def test_production_manifest_可处理全部静态资源(tmp_path):
storage = "whitenoise.storage.CompressedManifestStaticFilesStorage"
with override_settings(
STATIC_ROOT=tmp_path,
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": storage},
},
):
call_command("collectstatic", interactive=False, clear=True, verbosity=0)
assert (tmp_path / "staticfiles.json").is_file()
assert (tmp_path / "vendor" / "katex" / "katex.min.css").is_file()
def test_realtime_match_联机码与_websocket_前端资源存在():
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8")
styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
assert 'id="challenge-create"' in template
assert 'id="challenge-join-form"' in template
assert template.count("js/realtime.js") == 1
assert "new WebSocket" in realtime
assert "setInterval(refreshMatch, 2000)" in realtime
assert "Idempotency-Key" in realtime
assert ".challenge-panel" in styles
assert ".realtime-progress-panel" in styles
+22 -1
View File
@@ -62,11 +62,32 @@ class QuestionVersionAdmin(admin.ModelAdmin):
admin.site.register(ContestAnswer)
admin.site.register(RealtimeMatch)
admin.site.register(RatingHistory)
admin.site.register(LeaderboardSnapshot)
@admin.register(RealtimeMatch)
class RealtimeMatchAdmin(admin.ModelAdmin):
list_display = (
"id",
"contest",
"match_type",
"challenge_code",
"player_one",
"player_two",
"status",
"created_at",
)
list_filter = ("match_type", "status", "contest__track")
search_fields = (
"challenge_code",
"player_one__username",
"player_two__username",
)
readonly_fields = ("created_at", "started_at", "completed_at")
ordering = ("-created_at",)
@admin.register(MathGameAttempt)
class MathGameAttemptAdmin(admin.ModelAdmin):
list_display = (
+14 -1
View File
@@ -26,7 +26,11 @@ class MatchConsumer(AsyncJsonWebsocketConsumer):
await self.send_json({"type": "pong"})
return
if event_type == "progress":
answered_count = max(0, min(int(content.get("answered_count", 0)), 100))
try:
answered_count = max(0, min(int(content.get("answered_count", 0)), 100))
except (TypeError, ValueError):
await self.send_json({"type": "error", "message": "答题进度无效"})
return
await self.channel_layer.group_send(
self.group_name,
{
@@ -45,6 +49,15 @@ class MatchConsumer(AsyncJsonWebsocketConsumer):
}
)
async def match_state(self, event):
await self.send_json(
{
"type": "state",
"reason": event["reason"],
"match_id": str(self.match_id),
}
)
@database_sync_to_async
def _is_participant(self, user_id):
return RealtimeMatch.objects.filter(id=self.match_id).filter(
+22 -2
View File
@@ -1,5 +1,6 @@
import ast
import secrets
import unicodedata
from collections import Counter
from fractions import Fraction
@@ -36,6 +37,18 @@ TWENTY_FOUR_PUZZLES = {
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)]
@@ -80,7 +93,10 @@ def start_game(user, kind, difficulty):
def _validate_twenty_four_expression(source, numbers):
source = str(source or "").strip()
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:
@@ -90,7 +106,11 @@ def _validate_twenty_four_expression(source, numbers):
used = []
def evaluate(node):
if isinstance(node, ast.Constant) and isinstance(node.value, int):
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(
@@ -2,33 +2,133 @@ from django.core.management.base import BaseCommand
from contest.models import Contest, ContestQuestion, Question, QuestionVersion
import random
def _generate_beginner():
"""入门:100以内加减乘除,约 400 题。"""
questions = []
ops = [
("+", lambda a, b: a + b),
("-", lambda a, b: a - b),
("×", lambda a, b: a * b),
("÷", lambda a, b: a // b if b != 0 and a % b == 0 else None),
]
idx = 0
for a in range(2, 100):
for b in range(2, min(a + 1, 100)):
for sym, fn in ops:
if idx >= 400:
return questions
if sym == "÷" and (b == 0 or a % b != 0):
continue
if sym == "-" and a < b:
continue
result = fn(a, b)
if result is None or result < 0 or result > 10000:
continue
slug = f"b-auto-{idx:04d}"
prompt = f"{a} {sym} {b}"
answer = str(result)
if (slug, prompt, answer) not in questions:
questions.append((slug, prompt, answer))
idx += 1
return questions[:400]
def _generate_standard():
"""标准:两位数乘除、大数减法、平方,约 400 题。"""
questions = []
idx = 0
# 两位数乘法
for a in range(11, 100):
for b in range(11, min(a + 1, 100)):
if idx >= 150:
break
questions.append((f"s-auto-mul-{idx:04d}", f"{a} × {b}", str(a * b)))
idx += 1
# 三位数除法
for a in range(100, 1000, 7):
for b in range(11, 50):
if idx >= 300:
break
if a % b == 0:
questions.append((f"s-auto-div-{idx:04d}", f"{a} ÷ {b}", str(a // b)))
idx += 1
# 大数减法
for a in range(1000, 10000, 137):
b = random.randint(100, a - 1)
if idx >= 350:
break
questions.append((f"s-auto-sub-{idx:04d}", f"{a} - {b}", str(a - b)))
idx += 1
# 平方
for a in range(11, 100):
if idx >= 400:
break
questions.append((f"s-auto-sq-{idx:04d}", f"{a}²", str(a * a)))
idx += 1
return questions[:400]
def _generate_advanced():
"""进阶:模运算、组合数、高次幂、开方、等差数列求和,约 200 题。"""
questions = []
idx = 0
# 模运算
for base in range(2, 20):
for exp in range(2, 13):
for mod in range(3, 20):
if idx >= 50:
break
result = pow(base, exp, mod)
questions.append((f"a-auto-mod-{idx:04d}", f"{base}^{exp} ÷ {mod} 的余数", str(result)))
idx += 1
# 组合数 C(n,k)
for n in range(5, 31):
for k in range(2, min(n // 2 + 1, 8)):
if idx >= 100:
break
from math import comb
questions.append((f"a-auto-comb-{idx:04d}", f"C({n},{k})", str(comb(n, k))))
idx += 1
# 高次幂
for base in range(2, 10):
for exp in range(3, 10):
if idx >= 130:
break
result = base ** exp
if result > 10**8:
continue
questions.append((f"a-auto-pow-{idx:04d}", f"{base}^{exp}", str(result)))
idx += 1
# 开方(完全平方数)
for n in range(10, 200):
if idx >= 160:
break
r = int(n**0.5)
if r * r == n:
questions.append((f"a-auto-sqrt-{idx:04d}", f"{n}", str(r)))
idx += 1
# 等差数列求和 1..n
for n in range(10, 201, 5):
if idx >= 200:
break
result = n * (n + 1) // 2
questions.append((f"a-auto-sum-{idx:04d}", f"1 到 {n} 的整数和", str(result)))
idx += 1
return questions[:200]
QUESTIONS = {
Question.Track.BEGINNER: [
("b-12-plus-19", "12 + 19", "31"),
("b-8-times-7", "8 × 7", "56"),
("b-90-minus-37", "90 - 37", "53"),
("b-144-div-12", "144 ÷ 12", "12"),
("b-25-times-4", "25 × 4", "100"),
],
Question.Track.STANDARD: [
("s-17-times-23", "17 × 23", "391"),
("s-625-div-25", "625 ÷ 25", "25"),
("s-48-times-15", "48 × 15", "720"),
("s-1000-minus-387", "1000 - 387", "613"),
("s-35-squared", "35²", "1225"),
],
Question.Track.ADVANCED: [
("a-mod-2pow10", "2¹⁰ 除以 7 的余数", "2"),
("a-sum-1-50", "1 到 50 的整数和", "1275"),
("a-15-choose-2", "C(15,2)", "105"),
("a-sqrt-2025", "√2025", "45"),
("a-3pow6", "3⁶", "729"),
],
Question.Track.BEGINNER: _generate_beginner(),
Question.Track.STANDARD: _generate_standard(),
Question.Track.ADVANCED: _generate_advanced(),
}
class Command(BaseCommand):
help = "创建首批分层题目、实时 1v1、每日赛单人练习"
help = "创建分层题库(入门/标准/进阶)和实时 1v1、每日赛单人练习"
def handle(self, *args, **options):
versions = {}
@@ -93,6 +193,9 @@ class Command(BaseCommand):
},
)
ContestQuestion.objects.filter(contest=contest).delete()
pool = list(versions[track])
random.shuffle(pool)
selected = pool[:8]
ContestQuestion.objects.bulk_create(
[
ContestQuestion(
@@ -101,11 +204,14 @@ class Command(BaseCommand):
order=index,
points=100,
)
for index, version in enumerate(versions[track], start=1)
for index, version in enumerate(selected, start=1)
]
)
total = sum(map(len, QUESTIONS.values()))
self.stdout.write(
self.style.SUCCESS(
f"已创建 {sum(map(len, QUESTIONS.values()))} 道题{len(contest_specs)} 场比赛"
f"已创建 {total} 道题(入门{len(QUESTIONS[Question.Track.BEGINNER])}"
f"标准{len(QUESTIONS[Question.Track.STANDARD])}"
f"进阶{len(QUESTIONS[Question.Track.ADVANCED])})和 {len(contest_specs)} 场比赛"
)
)
@@ -0,0 +1,36 @@
# Generated by Django 4.2.23 on 2026-08-08 18:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contest', '0003_mathgameattempt_and_more'),
]
operations = [
migrations.RemoveIndex(
model_name='realtimematch',
name='matchmaking_lookup_idx',
),
migrations.AddField(
model_name='realtimematch',
name='challenge_code',
field=models.CharField(blank=True, max_length=8, null=True, unique=True),
),
migrations.AddField(
model_name='realtimematch',
name='expires_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='realtimematch',
name='match_type',
field=models.CharField(choices=[('random', '随机匹配'), ('challenge', '联机码约战')], default='random', max_length=16),
),
migrations.AddIndex(
model_name='realtimematch',
index=models.Index(fields=['contest', 'match_type', 'status', 'player_one_rating', 'created_at'], name='matchmaking_lookup_idx'),
),
]
+18 -1
View File
@@ -77,6 +77,10 @@ class ContestQuestion(models.Model):
class RealtimeMatch(models.Model):
class MatchType(models.TextChoices):
RANDOM = "random", "随机匹配"
CHALLENGE = "challenge", "联机码约战"
class Status(models.TextChoices):
WAITING = "waiting", "等待对手"
ACTIVE = "active", "进行中"
@@ -85,6 +89,12 @@ class RealtimeMatch(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
contest = models.ForeignKey(Contest, on_delete=models.PROTECT)
match_type = models.CharField(
max_length=16,
choices=MatchType.choices,
default=MatchType.RANDOM,
)
challenge_code = models.CharField(max_length=8, unique=True, null=True, blank=True)
player_one = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="matches_as_player_one"
)
@@ -106,13 +116,20 @@ class RealtimeMatch(models.Model):
related_name="won_matches",
)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
models.Index(
fields=("contest", "status", "player_one_rating", "created_at"),
fields=(
"contest",
"match_type",
"status",
"player_one_rating",
"created_at",
),
name="matchmaking_lookup_idx",
)
]
+307 -25
View File
@@ -1,5 +1,9 @@
import secrets
from datetime import timedelta
from decimal import Decimal
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
@@ -16,6 +20,93 @@ from .models import (
RealtimeMatch,
)
CHALLENGE_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
WAITING_MATCH_TTL = timedelta(minutes=10)
def _broadcast_match(match_id, reason):
channel_layer = get_channel_layer()
if channel_layer is None:
return
async_to_sync(channel_layer.group_send)(
f"match_{match_id}",
{
"type": "match.state",
"reason": reason,
},
)
def notify_match_on_commit(match_id, reason):
transaction.on_commit(lambda: _broadcast_match(match_id, reason))
def _new_challenge_code():
for _ in range(20):
code = "".join(secrets.choice(CHALLENGE_CODE_ALPHABET) for _ in range(6))
if not RealtimeMatch.objects.filter(challenge_code=code).exists():
return code
raise ValidationError("暂时无法生成联机码,请稍后重试")
def _validate_realtime_contest(contest):
if contest.kind != Contest.Kind.REALTIME or contest.status != Contest.Status.PUBLISHED:
raise ValidationError("实时比赛不可用")
def _cancel_expired_waiting_matches():
RealtimeMatch.objects.filter(status=RealtimeMatch.Status.WAITING).filter(
Q(expires_at__isnull=True) | Q(expires_at__lte=timezone.now())
).update(status=RealtimeMatch.Status.CANCELLED)
def _active_match_for(user):
return (
RealtimeMatch.objects.filter(
Q(player_one=user) | Q(player_two=user),
status=RealtimeMatch.Status.ACTIVE,
)
.select_related("contest", "player_one", "player_two")
.first()
)
def _cancel_other_waiting_matches(user, match_type):
matches = list(
RealtimeMatch.objects.filter(
player_one=user,
status=RealtimeMatch.Status.WAITING,
)
.exclude(match_type=match_type)
.values_list("id", flat=True)
)
if matches:
RealtimeMatch.objects.filter(id__in=matches).update(
status=RealtimeMatch.Status.CANCELLED
)
for match_id in matches:
notify_match_on_commit(match_id, "cancelled")
def _activate_match(match, user):
now = timezone.now()
match.player_two = user
match.player_two_rating = user.rating
match.status = RealtimeMatch.Status.ACTIVE
match.started_at = now
match.save(
update_fields=["player_two", "player_two_rating", "status", "started_at"]
)
ContestAttempt.objects.bulk_create(
[
ContestAttempt(contest=match.contest, user=match.player_one, match=match),
ContestAttempt(contest=match.contest, user=user, match=match),
]
)
match.attempts.update(started_at=now)
notify_match_on_commit(match.id, "matched")
return match
def normalize_answer(value):
text = str(value).strip().lower().replace(" ", "")
@@ -35,12 +126,12 @@ def attempt_payload(attempt, include_results=False):
"metadata": item.question_version.metadata,
"points": item.points,
}
if include_results and item.id in answers:
answer = answers[item.id]
if include_results:
answer = answers.get(item.id)
question.update(
{
"submitted_answer": answer.submitted_answer,
"is_correct": answer.is_correct,
"submitted_answer": answer.submitted_answer if answer else "",
"is_correct": answer.is_correct if answer else False,
"correct_answer": item.question_version.answer,
"explanation": item.question_version.explanation,
}
@@ -48,6 +139,7 @@ def attempt_payload(attempt, include_results=False):
questions.append(question)
return {
"attempt_id": attempt.id,
"match_id": attempt.match_id,
"contest": attempt.contest.title,
"kind": attempt.contest.kind,
"status": attempt.status,
@@ -90,7 +182,10 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key):
)
if attempt.status != ContestAttempt.Status.ACTIVE:
if submission_key and attempt.submission_key == submission_key:
return attempt_payload(attempt, include_results=True)
include_results = not attempt.match_id or (
attempt.match.status == RealtimeMatch.Status.COMPLETED
)
return attempt_payload(attempt, include_results=include_results)
raise ValidationError("该答题记录已经结算")
if not submission_key:
raise ValidationError({"Idempotency-Key": "正式提交必须提供幂等键"})
@@ -153,20 +248,34 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key):
},
)
if attempt.match_id:
finalize_match(attempt.match_id)
match = finalize_match(attempt.match_id)
if match.status != RealtimeMatch.Status.COMPLETED:
notify_match_on_commit(attempt.match_id, "submitted")
attempt.match.refresh_from_db()
return attempt_payload(
attempt,
include_results=attempt.match.status == RealtimeMatch.Status.COMPLETED,
)
return attempt_payload(attempt, include_results=True)
@transaction.atomic
def find_match(user, contest):
if contest.kind != Contest.Kind.REALTIME or contest.status != Contest.Status.PUBLISHED:
raise ValidationError("实时比赛不可用")
_validate_realtime_contest(contest)
_cancel_expired_waiting_matches()
active = _active_match_for(user)
if active:
if active.contest_id == contest.id:
return active
raise ValidationError("你已有一场进行中的实时比赛")
_cancel_other_waiting_matches(user, RealtimeMatch.MatchType.RANDOM)
existing = (
RealtimeMatch.objects.filter(
Q(player_one=user) | Q(player_two=user),
contest=contest,
match_type=RealtimeMatch.MatchType.RANDOM,
status__in=[RealtimeMatch.Status.WAITING, RealtimeMatch.Status.ACTIVE],
)
).filter(Q(status=RealtimeMatch.Status.ACTIVE) | Q(expires_at__gt=timezone.now()))
.order_by("-created_at")
.first()
)
@@ -177,7 +286,9 @@ def find_match(user, contest):
RealtimeMatch.objects.select_for_update(skip_locked=True)
.filter(
contest=contest,
match_type=RealtimeMatch.MatchType.RANDOM,
status=RealtimeMatch.Status.WAITING,
expires_at__gt=timezone.now(),
player_one_rating__gte=max(0, user.rating - 300),
player_one_rating__lte=user.rating + 300,
)
@@ -188,42 +299,204 @@ def find_match(user, contest):
if waiting is None:
return RealtimeMatch.objects.create(
contest=contest,
match_type=RealtimeMatch.MatchType.RANDOM,
player_one=user,
player_one_rating=user.rating,
expires_at=timezone.now() + WAITING_MATCH_TTL,
)
waiting.player_two = user
waiting.player_two_rating = user.rating
waiting.status = RealtimeMatch.Status.ACTIVE
waiting.started_at = timezone.now()
waiting.save(
update_fields=["player_two", "player_two_rating", "status", "started_at"]
return _activate_match(waiting, user)
@transaction.atomic
def create_challenge(user, contest):
_validate_realtime_contest(contest)
_cancel_expired_waiting_matches()
active = _active_match_for(user)
if active:
raise ValidationError("你已有一场进行中的实时比赛")
_cancel_other_waiting_matches(user, RealtimeMatch.MatchType.CHALLENGE)
existing = (
RealtimeMatch.objects.filter(
player_one=user,
contest=contest,
match_type=RealtimeMatch.MatchType.CHALLENGE,
status=RealtimeMatch.Status.WAITING,
expires_at__gt=timezone.now(),
)
.order_by("-created_at")
.first()
)
ContestAttempt.objects.bulk_create(
[
ContestAttempt(contest=contest, user=waiting.player_one, match=waiting),
ContestAttempt(contest=contest, user=user, match=waiting),
]
if existing:
return existing
return RealtimeMatch.objects.create(
contest=contest,
match_type=RealtimeMatch.MatchType.CHALLENGE,
challenge_code=_new_challenge_code(),
player_one=user,
player_one_rating=user.rating,
expires_at=timezone.now() + WAITING_MATCH_TTL,
)
return waiting
@transaction.atomic
def join_challenge(user, challenge_code):
_cancel_expired_waiting_matches()
code = str(challenge_code or "").strip().upper()
if len(code) != 6 or any(character not in CHALLENGE_CODE_ALPHABET for character in code):
raise ValidationError({"challenge_code": "联机码应为 6 位大写字母或数字"})
try:
match = (
RealtimeMatch.objects.select_for_update()
.select_related("contest", "player_one")
.get(
challenge_code=code,
match_type=RealtimeMatch.MatchType.CHALLENGE,
)
)
except RealtimeMatch.DoesNotExist as exc:
raise ValidationError({"challenge_code": "联机码不存在"}) from exc
if match.player_one_id == user.id:
raise ValidationError({"challenge_code": "不能加入自己创建的约战"})
if match.status != RealtimeMatch.Status.WAITING or (
match.expires_at and match.expires_at <= timezone.now()
):
raise ValidationError({"challenge_code": "联机码已失效或已被使用"})
active = _active_match_for(user)
if active and active.id != match.id:
raise ValidationError("你已有一场进行中的实时比赛")
own_waiting_ids = list(
RealtimeMatch.objects.filter(
player_one=user,
status=RealtimeMatch.Status.WAITING,
)
.exclude(id=match.id)
.values_list("id", flat=True)
)
if own_waiting_ids:
RealtimeMatch.objects.filter(id__in=own_waiting_ids).update(
status=RealtimeMatch.Status.CANCELLED
)
for match_id in own_waiting_ids:
notify_match_on_commit(match_id, "cancelled")
return _activate_match(match, user)
@transaction.atomic
def cancel_waiting_match(user, match_id):
match = RealtimeMatch.objects.select_for_update().get(id=match_id)
if match.player_one_id != user.id:
raise ValidationError("只有创建者可以取消等待")
if match.status != RealtimeMatch.Status.WAITING:
raise ValidationError("只能取消等待中的比赛")
match.status = RealtimeMatch.Status.CANCELLED
match.save(update_fields=["status"])
notify_match_on_commit(match.id, "cancelled")
return match
def match_payload(match, user):
attempt = match.attempts.filter(user=user).first()
reveal_results = match.status == RealtimeMatch.Status.COMPLETED
attempts = {
attempt.user_id: attempt
for attempt in match.attempts.select_related("user", "contest").all()
}
attempt = attempts.get(user.id)
opponent = match.player_two if match.player_one_id == user.id else match.player_one
opponent_attempt = attempts.get(opponent.id) if opponent else None
rating_change = (
match.rating_changes.filter(user=user).values("delta", "rating_after").first()
if reveal_results
else None
)
return {
"match_id": match.id,
"match_type": match.match_type,
"is_owner": match.player_one_id == user.id,
"challenge_code": (
match.challenge_code
if match.match_type == RealtimeMatch.MatchType.CHALLENGE
and match.status == RealtimeMatch.Status.WAITING
else None
),
"status": match.status,
"contest": match.contest.title,
"duration_seconds": match.contest.duration_seconds,
"expires_at": match.expires_at,
"started_at": match.started_at,
"opponent": (
{"nickname": opponent.nickname, "rating": opponent.rating}
{
"nickname": opponent.nickname,
"rating": opponent.rating,
"status": opponent_attempt.status if opponent_attempt else None,
"score": opponent_attempt.score if reveal_results and opponent_attempt else None,
"correct_count": (
opponent_attempt.correct_count
if reveal_results and opponent_attempt
else None
),
"duration_ms": (
opponent_attempt.duration_ms
if reveal_results and opponent_attempt
else None
),
}
if opponent
else None
),
"attempt": attempt_payload(attempt) if attempt else None,
"attempt": (
attempt_payload(attempt, include_results=reveal_results)
if attempt
else None
),
"result": (
{
"winner": (
"draw"
if match.winner_id is None
else "self"
if match.winner_id == user.id
else "opponent"
),
"rating_delta": rating_change["delta"] if rating_change else 0,
"rating_after": rating_change["rating_after"] if rating_change else user.rating,
}
if reveal_results
else None
),
"websocket_path": f"/ws/v1/contest/matches/{match.id}/",
}
@transaction.atomic
def refresh_match_state(match_id):
match = (
RealtimeMatch.objects.select_for_update()
.select_related("contest", "player_one", "player_two")
.get(id=match_id)
)
if (
match.status == RealtimeMatch.Status.WAITING
and match.expires_at
and match.expires_at <= timezone.now()
):
match.status = RealtimeMatch.Status.CANCELLED
match.save(update_fields=["status"])
notify_match_on_commit(match.id, "expired")
elif match.status == RealtimeMatch.Status.ACTIVE and match.started_at:
deadline = match.started_at + timedelta(seconds=match.contest.duration_seconds)
if timezone.now() >= deadline:
now = timezone.now()
for attempt in match.attempts.filter(status=ContestAttempt.Status.ACTIVE):
elapsed_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000))
attempt.status = ContestAttempt.Status.EXPIRED
attempt.duration_ms = elapsed_ms
attempt.submitted_at = now
attempt.save(update_fields=["status", "duration_ms", "submitted_at"])
match = finalize_match(match.id)
return match
def _elo_delta(rating, opponent_rating, score, k=32):
expected = 1 / (1 + 10 ** ((opponent_rating - rating) / 400))
return round(k * (score - expected))
@@ -249,7 +522,15 @@ def finalize_match(match_id):
first_result, second_result = 0.0, 1.0
match.winner_id = second.user_id
else:
first_result = second_result = 0.5
diff = first.duration_ms - second.duration_ms
if abs(diff) <= 100:
first_result = second_result = 0.5
elif diff < 0:
first_result, second_result = 1.0, 0.0
match.winner_id = first.user_id
else:
first_result, second_result = 0.0, 1.0
match.winner_id = second.user_id
users = {
user.id: user
@@ -277,4 +558,5 @@ def finalize_match(match_id):
match.status = RealtimeMatch.Status.COMPLETED
match.completed_at = timezone.now()
match.save(update_fields=["winner", "status", "completed_at"])
notify_match_on_commit(match.id, "completed")
return match
+87
View File
@@ -0,0 +1,87 @@
import pytest
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.urls import path
from accounts.models import User
from contest.consumers import MatchConsumer
from contest.models import Contest, Question, RealtimeMatch
@pytest.mark.django_db(transaction=True)
def test_match_consumer_双方连接并同步答题进度():
first = User.objects.create_user(
username="socket_player_one",
password="StrongPass_2026",
nickname="WS 玩家一",
)
second = User.objects.create_user(
username="socket_player_two",
password="StrongPass_2026",
nickname="WS 玩家二",
)
outsider = User.objects.create_user(
username="socket_outsider",
password="StrongPass_2026",
nickname="WS 局外人",
)
contest = Contest.objects.create(
slug="socket-contest",
title="WebSocket 联机赛",
kind=Contest.Kind.REALTIME,
track=Question.Track.STANDARD,
status=Contest.Status.PUBLISHED,
)
match = RealtimeMatch.objects.create(
contest=contest,
player_one=first,
player_two=second,
player_one_rating=first.rating,
player_two_rating=second.rating,
status=RealtimeMatch.Status.ACTIVE,
)
application = URLRouter(
[
path(
"ws/test/<uuid:match_id>/",
MatchConsumer.as_asgi(),
)
]
)
async def scenario():
outsider_socket = WebsocketCommunicator(application, f"/ws/test/{match.id}/")
outsider_socket.scope["user"] = outsider
outsider_connected, close_code = await outsider_socket.connect()
assert not outsider_connected
assert close_code == 4403
first_socket = WebsocketCommunicator(application, f"/ws/test/{match.id}/")
second_socket = WebsocketCommunicator(application, f"/ws/test/{match.id}/")
first_socket.scope["user"] = first
second_socket.scope["user"] = second
first_connected, _ = await first_socket.connect()
second_connected, _ = await second_socket.connect()
assert first_connected and second_connected
assert (await first_socket.receive_json_from())["type"] == "connected"
assert (await second_socket.receive_json_from())["type"] == "connected"
await first_socket.send_json_to({"type": "progress", "answered_count": 3})
first_progress = await first_socket.receive_json_from()
second_progress = await second_socket.receive_json_from()
assert first_progress["answered_count"] == 3
assert second_progress["answered_count"] == 3
assert second_progress["user_id"] == str(first.id)
await get_channel_layer().group_send(
f"match_{match.id}",
{"type": "match.state", "reason": "completed"},
)
assert (await first_socket.receive_json_from())["reason"] == "completed"
assert (await second_socket.receive_json_from())["reason"] == "completed"
await first_socket.disconnect()
await second_socket.disconnect()
async_to_sync(scenario)()
+56
View File
@@ -47,6 +47,55 @@ def test_twenty_four_服务端校验数字使用与幂等提交(game_user):
assert replay["score"] == result["score"]
@pytest.mark.django_db
def test_twenty_four_api_兼容常见数学符号并完成计分(client, game_user):
attempt = MathGameAttempt.objects.create(
user=game_user,
kind=MathGameAttempt.Kind.TWENTY_FOUR,
puzzle={"numbers": [1, 3, 4, 6]},
solution={"target": 24},
)
client.force_login(game_user)
response = client.post(
f"/api/v1/contests/games/attempts/{attempt.id}/submit/",
{"expression": "6÷(1−3÷4)"},
content_type="application/json",
HTTP_IDEMPOTENCY_KEY="unicode-game-submit",
)
assert response.status_code == 200
assert response.json()["status"] == MathGameAttempt.Status.COMPLETED
assert response.json()["score"] >= 100
attempt.refresh_from_db()
assert attempt.submission == {"expression": "6/(1-3/4)"}
@pytest.mark.django_db
def test_twenty_four_api_答案错误时返回具体原因和请求编号(client, game_user):
attempt = MathGameAttempt.objects.create(
user=game_user,
kind=MathGameAttempt.Kind.TWENTY_FOUR,
puzzle={"numbers": [1, 3, 4, 6]},
solution={"target": 24},
)
client.force_login(game_user)
response = client.post(
f"/api/v1/contests/games/attempts/{attempt.id}/submit/",
{"expression": "1 + 3 + 4 + 6"},
content_type="application/json",
HTTP_IDEMPOTENCY_KEY="incorrect-game-submit",
HTTP_X_REQUEST_ID="twenty-four-invalid-test",
)
assert response.status_code == 400
assert response.json()["error"]["message"] == "当前结果是 14,还没有得到 24"
assert response.json()["error"]["request_id"] == "twenty-four-invalid-test"
attempt.refresh_from_db()
assert attempt.status == MathGameAttempt.Status.ACTIVE
@pytest.mark.django_db
def test_twenty_four_拒绝额外数字和非四则表达式(game_user):
attempt = MathGameAttempt.objects.create(
@@ -70,6 +119,13 @@ def test_twenty_four_拒绝额外数字和非四则表达式(game_user):
{"expression": "pow(2, 3) * 3"},
"invalid-function",
)
with pytest.raises(ValidationError, match="只允许"):
submit_game(
game_user,
attempt.id,
{"expression": "6 / (True - 3 / 4)"},
"invalid-boolean",
)
@pytest.mark.django_db
+205
View File
@@ -0,0 +1,205 @@
from datetime import timedelta
import pytest
from django.test import Client
from django.utils import timezone
from accounts.models import User
from contest.models import (
Contest,
ContestQuestion,
Question,
QuestionVersion,
RealtimeMatch,
)
@pytest.fixture
def realtime_api_setup(db):
question = Question.objects.create(
slug="realtime-api-question",
track=Question.Track.STANDARD,
)
version = QuestionVersion.objects.create(
question=question,
version=1,
prompt="18 + 24",
answer="42",
explanation="18 + 24 = 42",
)
contest = Contest.objects.create(
slug="realtime-api",
title="API 联机赛",
kind=Contest.Kind.REALTIME,
track=Question.Track.STANDARD,
status=Contest.Status.PUBLISHED,
duration_seconds=60,
)
ContestQuestion.objects.create(
contest=contest,
question_version=version,
order=1,
points=100,
)
first = User.objects.create_user(
username="api_player_one",
password="StrongPass_2026",
nickname="API 玩家一",
)
second = User.objects.create_user(
username="api_player_two",
password="StrongPass_2026",
nickname="API 玩家二",
)
first_client = Client()
second_client = Client()
first_client.force_login(first)
second_client.force_login(second)
return contest, first_client, second_client
@pytest.mark.django_db
def test_challenge_api_创建加入状态提交形成完整闭环(realtime_api_setup):
contest, first_client, second_client = realtime_api_setup
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
content_type="application/json",
)
code = created.json()["challenge_code"]
joined = second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": code.lower()},
content_type="application/json",
)
match_id = joined.json()["match_id"]
first_state = first_client.get(f"/api/v1/contests/matches/{match_id}/")
assert created.status_code == 201
assert joined.status_code == 200
assert joined.json()["status"] == "active"
assert first_state.json()["opponent"]["nickname"] == "API 玩家二"
first_attempt = first_state.json()["attempt"]["attempt_id"]
second_attempt = joined.json()["attempt"]["attempt_id"]
first_submit = first_client.post(
f"/api/v1/contests/attempts/{first_attempt}/submit/",
{"answers": [{"order": 1, "answer": "42"}]},
content_type="application/json",
HTTP_IDEMPOTENCY_KEY="api-submit-one",
)
second_submit = second_client.post(
f"/api/v1/contests/attempts/{second_attempt}/submit/",
{"answers": [{"order": 1, "answer": "0"}]},
content_type="application/json",
HTTP_IDEMPOTENCY_KEY="api-submit-two",
)
assert first_submit.json()["status"] == "active"
assert "correct_answer" not in first_submit.json()["attempt"]["questions"][0]
assert second_submit.json()["status"] == "completed"
assert second_submit.json()["result"]["winner"] == "opponent"
final_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final_state["result"]["winner"] == "self"
assert final_state["attempt"]["questions"][0]["correct_answer"] == "42"
@pytest.mark.django_db
def test_challenge_api_无效联机码返回具体原因_request_id_和诊断日志(
realtime_api_setup,
caplog,
):
_, _, second_client = realtime_api_setup
caplog.set_level("INFO", logger="common.api")
response = second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": "ABC234"},
content_type="application/json",
HTTP_X_REQUEST_ID="challenge-invalid-test",
)
assert response.status_code == 400
assert response["X-Request-ID"] == "challenge-invalid-test"
assert response.json()["error"] == {
"code": "invalid",
"message": "联机码不存在",
"details": {"challenge_code": "联机码不存在"},
"request_id": "challenge-invalid-test",
}
assert "api_request_error method=POST" in caplog.text
assert "path=/api/v1/contests/challenges/join/" in caplog.text
assert "fields=challenge_code message=联机码不存在" in caplog.text
@pytest.mark.django_db
def test_challenge_api_创建者不能加入自己的联机码(realtime_api_setup):
contest, first_client, _ = realtime_api_setup
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
content_type="application/json",
)
response = first_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": created.json()["challenge_code"]},
content_type="application/json",
)
assert response.status_code == 400
assert response.json()["error"]["message"] == "不能加入自己创建的约战"
@pytest.mark.django_db
def test_challenge_api_过期联机码返回具体原因(realtime_api_setup):
contest, first_client, second_client = realtime_api_setup
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
content_type="application/json",
)
RealtimeMatch.objects.filter(id=created.json()["match_id"]).update(
expires_at=timezone.now() - timedelta(seconds=1)
)
response = second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": created.json()["challenge_code"]},
content_type="application/json",
)
assert response.status_code == 400
assert response.json()["error"]["message"] == "联机码已失效或已被使用"
@pytest.mark.django_db
def test_challenge_api_已使用联机码返回具体原因(realtime_api_setup):
contest, first_client, second_client = realtime_api_setup
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
content_type="application/json",
)
second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": created.json()["challenge_code"]},
content_type="application/json",
)
third = User.objects.create_user(
username="api_player_three",
password="StrongPass_2026",
nickname="API 玩家三",
)
third_client = Client()
third_client.force_login(third)
response = third_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": created.json()["challenge_code"]},
content_type="application/json",
)
assert response.status_code == 400
assert response.json()["error"]["message"] == "联机码已失效或已被使用"
+159
View File
@@ -15,9 +15,14 @@ from contest.models import (
RealtimeMatch,
)
from contest.services import (
cancel_waiting_match,
create_challenge,
finalize_match,
find_match,
join_challenge,
match_payload,
normalize_answer,
refresh_match_state,
start_attempt,
submit_attempt,
)
@@ -62,6 +67,16 @@ def daily_contest(db):
return contest
@pytest.fixture
def realtime_contest(daily_contest):
daily_contest.kind = Contest.Kind.REALTIME
daily_contest.slug = "realtime-with-question"
daily_contest.title = "联机测试赛"
daily_contest.duration_seconds = 60
daily_contest.save(update_fields=["kind", "slug", "title", "duration_seconds"])
return daily_contest
@pytest.mark.parametrize(
"raw, expected",
[
@@ -201,3 +216,147 @@ def test_find_match_同分玩家配对并由_finalize_match_唯一结算_rating(
assert first.rating == 1016
assert second.rating == 984
assert RatingHistory.objects.filter(match=active).count() == 2
@pytest.mark.django_db
def test_challenge_code_创建者和加入者通过联机码进入同一场(realtime_contest):
first = User.objects.create_user(
username="challenge_owner",
password="StrongPass_2026",
nickname="房主",
)
second = User.objects.create_user(
username="challenge_guest",
password="StrongPass_2026",
nickname="访客",
)
waiting = create_challenge(first, realtime_contest)
active = join_challenge(second, waiting.challenge_code.lower())
owner_payload = match_payload(active, first)
guest_payload = match_payload(active, second)
assert len(waiting.challenge_code) == 6
assert active.id == waiting.id
assert active.match_type == RealtimeMatch.MatchType.CHALLENGE
assert active.status == RealtimeMatch.Status.ACTIVE
assert active.attempts.count() == 2
assert owner_payload["opponent"]["nickname"] == "访客"
assert guest_payload["opponent"]["nickname"] == "房主"
assert owner_payload["attempt"]["questions"] == guest_payload["attempt"]["questions"]
@pytest.mark.django_db
def test_random_match_不会加入联机码约战(realtime_contest):
owner = User.objects.create_user(
username="private_owner",
password="StrongPass_2026",
nickname="约战房主",
)
random_player = User.objects.create_user(
username="random_player",
password="StrongPass_2026",
nickname="随机玩家",
)
challenge = create_challenge(owner, realtime_contest)
random_match = find_match(random_player, realtime_contest)
assert challenge.status == RealtimeMatch.Status.WAITING
assert random_match.id != challenge.id
assert random_match.match_type == RealtimeMatch.MatchType.RANDOM
@pytest.mark.django_db
def test_realtime_submit_双方结束前不泄露答案且结束后结算(realtime_contest):
first = User.objects.create_user(
username="fair_player_one",
password="StrongPass_2026",
nickname="公平玩家一",
)
second = User.objects.create_user(
username="fair_player_two",
password="StrongPass_2026",
nickname="公平玩家二",
)
match = join_challenge(
second,
create_challenge(first, realtime_contest).challenge_code,
)
first_attempt = match.attempts.get(user=first)
second_attempt = match.attempts.get(user=second)
first_result = submit_attempt(
first,
first_attempt.id,
[{"order": 1, "answer": "42"}],
"fair-submit-one",
)
active_payload = match_payload(match, first)
assert "correct_answer" not in first_result["questions"][0]
assert active_payload["status"] == RealtimeMatch.Status.ACTIVE
assert active_payload["attempt"]["status"] == ContestAttempt.Status.SUBMITTED
submit_attempt(
second,
second_attempt.id,
[{"order": 1, "answer": "0"}],
"fair-submit-two",
)
match.refresh_from_db()
completed_payload = match_payload(match, first)
assert match.status == RealtimeMatch.Status.COMPLETED
assert completed_payload["result"]["winner"] == "self"
assert completed_payload["attempt"]["questions"][0]["correct_answer"] == "42"
assert completed_payload["opponent"]["score"] == 0
@pytest.mark.django_db
def test_realtime_timeout_未提交玩家自动过期并完成比赛(realtime_contest):
first = User.objects.create_user(
username="timeout_one",
password="StrongPass_2026",
nickname="超时玩家一",
)
second = User.objects.create_user(
username="timeout_two",
password="StrongPass_2026",
nickname="超时玩家二",
)
match = join_challenge(
second,
create_challenge(first, realtime_contest).challenge_code,
)
RealtimeMatch.objects.filter(id=match.id).update(
started_at=timezone.now() - timedelta(seconds=61)
)
refreshed = refresh_match_state(match.id)
assert refreshed.status == RealtimeMatch.Status.COMPLETED
assert not refreshed.attempts.filter(status=ContestAttempt.Status.ACTIVE).exists()
@pytest.mark.django_db
def test_challenge_owner_可取消等待中的联机码(realtime_contest):
owner = User.objects.create_user(
username="cancel_owner",
password="StrongPass_2026",
nickname="取消房主",
)
waiting = create_challenge(owner, realtime_contest)
cancelled = cancel_waiting_match(owner, waiting.id)
assert cancelled.status == RealtimeMatch.Status.CANCELLED
with pytest.raises(ValidationError, match="失效"):
join_challenge(
User.objects.create_user(
username="late_guest",
password="StrongPass_2026",
nickname="迟到访客",
),
waiting.challenge_code,
)
+317
View File
@@ -0,0 +1,317 @@
"""端到端模拟两人实时竞赛,覆盖同分不同时长的平局判定场景。"""
from datetime import timedelta
import pytest
from django.test import Client
from django.utils import timezone
from accounts.models import User
from contest.models import (
Contest,
ContestQuestion,
Question,
QuestionVersion,
RealtimeMatch,
)
def _setup_realtime_contest(db, slug="tiebreak-contest", duration=60):
question = Question.objects.create(
slug=f"{slug}-q",
track=Question.Track.STANDARD,
)
version = QuestionVersion.objects.create(
question=question,
version=1,
prompt="18 + 24",
answer="42",
explanation="18 + 24 = 42",
)
contest = Contest.objects.create(
slug=slug,
title="同分决胜测试赛",
kind=Contest.Kind.REALTIME,
track=Question.Track.STANDARD,
status=Contest.Status.PUBLISHED,
duration_seconds=duration,
)
ContestQuestion.objects.create(
contest=contest,
question_version=version,
order=1,
points=100,
)
return contest
def _create_user(username, nickname):
return User.objects.create_user(
username=username,
password="StrongPass_2026",
nickname=nickname,
)
def _client(user):
c = Client()
c.force_login(user)
return c
def _create_and_join(first_client, second_client, contest):
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
content_type="application/json",
)
assert created.status_code == 201
code = created.json()["challenge_code"]
joined = second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": code},
content_type="application/json",
)
assert joined.status_code == 200
assert joined.json()["status"] == "active"
match_id = joined.json()["match_id"]
first_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json()
second_state = second_client.get(f"/api/v1/contests/matches/{match_id}/").json()
return match_id, first_state, second_state
def _set_start(match, user, seconds_ago):
"""统一设置 match 和 attempt 的 started_at。"""
t = timezone.now() - timedelta(seconds=seconds_ago)
match.started_at = t
match.save(update_fields=["started_at"])
match.attempts.filter(user=user).update(started_at=t)
def _submit(client, attempt_id, answer, key):
return client.post(
f"/api/v1/contests/attempts/{attempt_id}/submit/",
{"answers": [{"order": 1, "answer": answer}]},
content_type="application/json",
HTTP_IDEMPOTENCY_KEY=key,
)
@pytest.mark.django_db
def test_同分但一方更快则快者胜(db):
"""两人答对同一题,但提交时间不同,用时短者获胜。"""
contest = _setup_realtime_contest(db, "tiebreak-fast")
first = _create_user("tie_p1", "快方")
second = _create_user("tie_p2", "慢方")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
r1 = _submit(fc, fa, "42", "tie-fast-one")
assert r1.status_code == 200
_set_start(match, second, 15)
r2 = _submit(sc, sa, "42", "tie-fast-two")
assert r2.status_code == 200
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 100
assert final["opponent"]["score"] == 100
assert final["attempt"]["duration_ms"] < final["opponent"]["duration_ms"]
@pytest.mark.django_db
def test_同分且同时长则平局(db):
"""两人答对同一题且用时完全相同,判平局。"""
contest = _setup_realtime_contest(db, "tiebreak-draw")
first = _create_user("draw_p1", "")
second = _create_user("draw_p2", "")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
_submit(fc, fa, "42", "draw-one")
_set_start(match, second, 5)
_submit(sc, sa, "42", "draw-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "draw"
@pytest.mark.django_db
def test_一方答对一方答错则答对者胜(db):
"""一人答对一人答错,答对者胜(不受时间影响)。"""
contest = _setup_realtime_contest(db, "tiebreak-correct")
first = _create_user("corr_p1", "答对方")
second = _create_user("corr_p2", "答错方")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 3)
_submit(fc, fa, "42", "corr-one")
_set_start(match, second, 10)
_submit(sc, sa, "0", "corr-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 100
assert final["opponent"]["score"] == 0
@pytest.mark.django_db
def test_双方都答错则快者胜(db):
"""两人都答错,分数相同(0 分),比较用时,快者胜。"""
contest = _setup_realtime_contest(db, "tiebreak-both-wrong")
first = _create_user("wrong_p1", "快错")
second = _create_user("wrong_p2", "慢错")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
_submit(fc, fa, "0", "wrong-one")
_set_start(match, second, 12)
_submit(sc, sa, "0", "wrong-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 0
assert final["opponent"]["score"] == 0
@pytest.mark.django_db
def test_超时提交导致分数为零_同分时比较用时(db):
"""一方超时提交(分数为 0),另一方正常提交也得 0 分,同分快者胜。"""
contest = _setup_realtime_contest(db, "tiebreak-timeout", duration=10)
first = _create_user("to_p1", "超时方")
second = _create_user("to_p2", "正常零分方")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 20)
r1 = _submit(fc, fa, "42", "to-one")
assert r1.status_code == 200
assert r1.json()["attempt"]["status"] == "expired"
_set_start(match, second, 3)
_submit(sc, sa, "0", "to-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["attempt"]["score"] == 0
assert final["opponent"]["score"] == 0
assert final["result"]["winner"] == "opponent"
@pytest.mark.django_db
def test_随机匹配同分快者胜(db):
"""通过随机匹配(非联机码)也能正确触发同分快者胜。"""
contest = _setup_realtime_contest(db, "tiebreak-random")
first = _create_user("rnd_p1", "随机快")
second = _create_user("rnd_p2", "随机慢")
fc, sc = _client(first), _client(second)
r1 = fc.post(
f"/api/v1/contests/{contest.slug}/matchmaking/",
{},
content_type="application/json",
)
r2 = sc.post(
f"/api/v1/contests/{contest.slug}/matchmaking/",
{},
content_type="application/json",
)
assert r1.json()["status"] == "active" or r2.json()["status"] == "active"
match_id = r1.json().get("match_id") or r2.json()["match_id"]
fs = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
ss = sc.get(f"/api/v1/contests/matches/{match_id}/").json()
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 4)
_submit(fc, fa, "42", "rnd-one")
_set_start(match, second, 8)
_submit(sc, sa, "42", "rnd-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 100
assert final["opponent"]["score"] == 100
@pytest.mark.django_db
def test_rating_变化在同分快者胜时正确(db):
"""同分快者胜时,Elo rating 按正常胜负变化(不平局)。"""
contest = _setup_realtime_contest(db, "tiebreak-rating")
first = _create_user("rate_p1", "Rating快")
second = _create_user("rate_p2", "Rating慢")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
_submit(fc, fa, "42", "rate-one")
_set_start(match, second, 12)
_submit(sc, sa, "42", "rate-two")
first.refresh_from_db()
second.refresh_from_db()
assert first.rating > 1000
assert second.rating < 1000
@pytest.mark.django_db
def test_双方都超时则由_refresh_自动结算判平(db):
"""双方都超时(分数都为 0),refresh_match_state 自动结算,duration 统一为时限,判平。"""
contest = _setup_realtime_contest(db, "tiebreak-both-timeout", duration=10)
first = _create_user("both_to_p1", "快超时")
second = _create_user("both_to_p2", "慢超时")
fc, sc = _client(first), _client(second)
match_id, _, _ = _create_and_join(fc, sc, contest)
# 两个人都不提交,直接让 match 超时
match = RealtimeMatch.objects.get(id=match_id)
match.started_at = timezone.now() - timedelta(seconds=15)
match.save(update_fields=["started_at"])
match.attempts.update(started_at=match.started_at)
from contest.services import refresh_match_state
refresh_match_state(match.id)
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["attempt"]["score"] == 0
assert final["opponent"]["score"] == 0
# 双方都超时,duration_ms 相同(都约 15000ms),判平
assert final["result"]["winner"] == "draw"
+11 -1
View File
@@ -3,8 +3,11 @@ from django.urls import path
from .views import (
AttemptStartView,
AttemptSubmitView,
ChallengeCreateView,
ChallengeJoinView,
ContestListView,
LeaderboardView,
MatchCancelView,
MatchmakingView,
MatchStateView,
MathGameCatalogView,
@@ -16,11 +19,18 @@ from .views import (
urlpatterns = [
path("", ContestListView.as_view(), name="contest-list"),
path("challenges/join/", ChallengeJoinView.as_view(), name="challenge-join"),
path("matches/<uuid:match_id>/", MatchStateView.as_view(), name="match-state"),
path("matches/<uuid:match_id>/cancel/", MatchCancelView.as_view(), name="match-cancel"),
path("<slug:slug>/start/", AttemptStartView.as_view(), name="attempt-start"),
path("<slug:slug>/matchmaking/", MatchmakingView.as_view(), name="matchmaking"),
path(
"<slug:slug>/challenges/",
ChallengeCreateView.as_view(),
name="challenge-create",
),
path("<slug:slug>/leaderboard/", LeaderboardView.as_view(), name="leaderboard"),
path("attempts/<uuid:attempt_id>/submit/", AttemptSubmitView.as_view(), name="attempt-submit"),
path("matches/<uuid:match_id>/", MatchStateView.as_view(), name="match-state"),
path("games/", MathGameCatalogView.as_view(), name="math-game-catalog"),
path("games/history/", MathGameHistoryView.as_view(), name="math-game-history"),
path("games/<str:kind>/start/", MathGameStartView.as_view(), name="math-game-start"),
+31 -3
View File
@@ -6,8 +6,12 @@ from rest_framework.views import APIView
from .game_services import game_payload, request_sudoku_hint, start_game, submit_game
from .models import Contest, ContestAttempt, MathGameAttempt, RealtimeMatch
from .services import (
cancel_waiting_match,
create_challenge,
find_match,
join_challenge,
match_payload,
refresh_match_state,
start_attempt,
submit_attempt,
)
@@ -44,13 +48,16 @@ class AttemptStartView(APIView):
class AttemptSubmitView(APIView):
def post(self, request, attempt_id):
get_object_or_404(ContestAttempt, id=attempt_id, user=request.user)
attempt = get_object_or_404(ContestAttempt, id=attempt_id, user=request.user)
payload = submit_attempt(
user=request.user,
attempt_id=attempt_id,
raw_answers=request.data.get("answers", []),
submission_key=request.headers.get("Idempotency-Key"),
)
if attempt.match_id:
match = refresh_match_state(attempt.match_id)
return Response(match_payload(match, request.user))
return Response(payload)
@@ -63,12 +70,33 @@ class MatchmakingView(APIView):
class MatchStateView(APIView):
def get(self, request, match_id):
match = get_object_or_404(
existing = get_object_or_404(
RealtimeMatch.objects.select_related("player_one", "player_two"),
id=match_id,
)
if request.user.id not in (match.player_one_id, match.player_two_id):
if request.user.id not in (existing.player_one_id, existing.player_two_id):
return Response(status=status.HTTP_403_FORBIDDEN)
match = refresh_match_state(match_id)
return Response(match_payload(match, request.user))
class ChallengeCreateView(APIView):
def post(self, request, slug):
contest = get_object_or_404(Contest, slug=slug)
match = create_challenge(request.user, contest)
return Response(match_payload(match, request.user), status=status.HTTP_201_CREATED)
class ChallengeJoinView(APIView):
def post(self, request):
match = join_challenge(request.user, request.data.get("challenge_code"))
return Response(match_payload(match, request.user))
class MatchCancelView(APIView):
def post(self, request, match_id):
get_object_or_404(RealtimeMatch, id=match_id)
match = cancel_waiting_match(request.user, match_id)
return Response(match_payload(match, request.user))
@@ -134,7 +134,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
docs = Path(settings.PROJECT_ROOT) / "docs"
mathbti_path = docs / "old_scripts" / "seed_mathbti.json"
story_path = docs / "数学少年线_story.json"
story_path = docs / "信仰者线_story.json"
videos_path = docs / "old_scripts" / "seed_videos.json"
if not mathbti_path.exists() or not story_path.exists() or not videos_path.exists():
raise CommandError("缺少 docs 中的初始内容文件")
@@ -167,7 +167,7 @@ class Command(BaseCommand):
if errors:
raise CommandError("; ".join(errors))
flagship, _ = Story.objects.update_or_create(
slug="believer-math-teen",
slug="believer",
defaults={
"title": story_document["title"],
"summary": story_document.get("description", ""),
+97 -5
View File
@@ -132,6 +132,16 @@ button { color: inherit; }
.track-switch { display: flex; gap: 5px; margin-bottom: 22px; }
.track-switch button { border: 1px solid var(--line); padding: 9px 18px; border-radius: 99px; background: transparent; cursor: pointer; }
.track-switch button.active { background: var(--ink); color: white; }
.challenge-panel { display: grid; grid-template-columns: minmax(0, 1fr) minmax(380px, .9fr); gap: 24px; align-items: center; margin-bottom: 22px; padding: 24px 26px; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(120deg, rgba(25,101,72,.07), rgba(204,232,91,.12)); }.challenge-panel h2 { margin: 7px 0 6px; font: 27px Georgia, serif; }.challenge-panel p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.7; }.challenge-actions { display: grid; gap: 9px; }.challenge-actions > .primary-button { width: 100%; }.challenge-actions form { display: grid; grid-template-columns: 1fr auto; gap: 8px; }.challenge-actions input { min-width: 0; border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; background: white; text-transform: uppercase; letter-spacing: .18em; font-weight: 700; }.challenge-actions .dark-button { width: auto; margin: 0; white-space: nowrap; }
.realtime-status-line { display: flex; justify-content: space-between; gap: 15px; margin: 10px 0 18px; color: var(--muted); font-size: 11px; }.realtime-status-line strong { color: var(--green); }
.realtime-waiting { display: grid; justify-items: center; gap: 16px; padding: 35px 20px; border-radius: 18px; background: #eef1eb; text-align: center; }.realtime-waiting p { max-width: 480px; margin: 0; color: var(--muted); line-height: 1.7; }.realtime-pulse { width: 18px; height: 18px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 0 rgba(25,101,72,.35); animation: realtime-pulse 1.5s infinite; }.challenge-code { border: 1px dashed var(--green); border-radius: 14px; padding: 14px 24px; background: white; color: var(--green); font: 700 31px/1 "SFMono-Regular", Consolas, monospace; letter-spacing: .22em; cursor: pointer; }
@keyframes realtime-pulse { 70% { box-shadow: 0 0 0 15px rgba(25,101,72,0); } 100% { box-shadow: 0 0 0 0 rgba(25,101,72,0); } }
.realtime-progress-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }.realtime-progress-panel > div { padding: 13px 15px; border-radius: 12px; background: #eef1eb; }.realtime-progress-panel span, .realtime-progress-panel b { display: block; }.realtime-progress-panel span { color: var(--muted); font-size: 10px; }.realtime-progress-panel b { margin-top: 5px; color: var(--green); }
.realtime-answer-form input { margin-top: 7px; width: 100%; padding: 12px; border: 1px solid #d6d8d1; border-radius: 9px; }.realtime-submitted { margin-top: 20px; padding: 30px; border-radius: 16px; background: var(--ink); color: white; text-align: center; }.realtime-submitted strong { color: var(--lime); font: 27px Georgia, serif; }.realtime-submitted p { margin-bottom: 0; color: #b9c2bc; }
.realtime-result { margin: 20px 0; padding: 28px; border-radius: 17px; background: var(--ink); color: white; text-align: center; }.realtime-result > strong { color: var(--lime); font: 38px Georgia, serif; }.realtime-result p { color: #c5cec8; }.realtime-result > b { display: inline-block; padding: 6px 10px; border-radius: 99px; background: rgba(204,232,91,.13); color: var(--lime); }.result-opponent > strong { color: #ef947c; }
.realtime-time-line { margin: 10px 0 6px; font-size: 13px; color: #9ba89d; }
.realtime-tiebreak { display: block; margin-top: 6px; font-size: 11px; color: var(--lime); font-weight: 700; }
.realtime-review { display: grid; gap: 9px; }.realtime-review article { padding: 15px; border: 1px solid var(--line); border-left: 4px solid var(--green); border-radius: 11px; background: white; }.realtime-review article.incorrect { border-left-color: #c05245; }.realtime-review p { margin: 7px 0; color: #3e4942; }.realtime-review small { color: var(--muted); }
.math-games-section { margin-top: 60px; }
.game-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.game-card { min-height: 285px; padding: 26px; border: 1px solid var(--line); border-radius: 20px; background: var(--panel); display: flex; flex-direction: column; overflow: hidden; position: relative; }
@@ -153,7 +163,9 @@ button { color: inherit; }
.editor-pane label, .preview-pane > span { display: block; color: #9eaaa2; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; }
.editor-pane textarea { width: 100%; height: 330px; margin-top: 22px; resize: none; border: 0; outline: 0; background: transparent; color: #dcefa1; font: 16px/1.8 "SFMono-Regular", Consolas, monospace; }
.preview-pane { display: flex; flex-direction: column; }
#latex-preview { margin: auto; max-width: 100%; overflow-wrap: anywhere; font: 28px/1.6 Georgia, serif; }
#latex-preview { margin: auto; max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 18px 4px; font-size: 28px; line-height: 1.6; }
#latex-preview .katex-display { margin: 0; overflow-x: auto; overflow-y: hidden; }
#latex-preview.latex-preview-error { color: #b84136; font: 14px/1.7 system-ui, sans-serif; white-space: normal; }
.preview-pane button { align-self: flex-end; }
.profile-panel { padding: 35px; }
.metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
@@ -211,10 +223,16 @@ button { color: inherit; }
.pet-node { position: absolute; left: calc(50% - 49px); top: 145px; width: 98px; height: 98px; border: 2px solid #b49d8b; border-radius: 50%; background: white; display: grid; place-items: center; box-shadow: 0 13px 25px rgba(38,48,40,.12); }
.pet-node b { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 50%; background: #778990; color: white; font: 22px Georgia, serif; }.pet-node span { position: absolute; bottom: 8px; font-size: 10px; color: var(--muted); }
.map-hint { margin: 0; text-align: center; color: #a0a49e; font-size: 11px; }
.video-browser { margin-top: 28px; }.filter-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 11px; }
.filter-chip { border: 1px solid var(--line); border-radius: 99px; padding: 9px 14px; background: rgba(255,255,252,.7); color: var(--muted); cursor: pointer; }
.filter-chip.active { background: var(--ink); border-color: var(--ink); color: white; }
.discipline-filters .filter-chip { border-radius: 9px; }
.video-browser { margin-top: 28px; }.filter-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 11px; }
.filter-dropdown { position: relative; }
.filter-dropdown-toggle { display: flex; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 12px; padding: 10px 16px; background: rgba(255,255,252,.82); color: var(--ink); cursor: pointer; font-size: 14px; min-width: 140px; justify-content: space-between; }
.filter-dropdown-toggle b { font-size: 10px; color: var(--muted); transition: transform .2s; }
.filter-dropdown-toggle[aria-expanded="true"] b { transform: rotate(180deg); }
.filter-dropdown-panel { position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; min-width: 200px; max-height: 320px; overflow-y: auto; border: 1px solid var(--line); border-radius: 14px; background: rgba(255,255,252,.96); backdrop-filter: blur(16px); box-shadow: 0 12px 40px rgba(38,48,40,.12); padding: 6px; display: flex; flex-direction: column; gap: 2px; }
.filter-dropdown-panel.hidden { display: none; }
.filter-option { border: 0; border-radius: 10px; padding: 10px 14px; background: transparent; color: var(--ink); cursor: pointer; text-align: left; font-size: 13px; transition: background .15s; }
.filter-option:hover { background: #eef1eb; }
.filter-option.active { background: var(--ink); color: white; }
.video-section-heading { margin: 35px 0 16px; display: flex; justify-content: space-between; align-items: end; }.video-section-heading h2 { margin: 8px 0 0; font: 30px Georgia, serif; }.video-section-heading > span { color: var(--muted); font-size: 12px; }
.video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }.video-grid.loading { display: block; color: var(--muted); padding: 35px 0; }
.video-card { border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,252,.82); overflow: hidden; cursor: pointer; transition: .2s ease; }
@@ -262,6 +280,80 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
.content-grid { gap: 11px; }.editor-shell { min-height: 700px; }.page-title { padding-top: 42px; }.page-title h1 { font-size: 45px; }
.tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; }
.math-games-section { margin-top: 45px; }.game-card { min-height: 260px; padding: 21px; }.game-card-controls { align-items: stretch; }.game-card-controls .primary-button { flex: 1; }.twenty-four-numbers { gap: 7px; }.twenty-four-numbers button { border-radius: 13px; font-size: 28px; }.sudoku-board input { font-size: clamp(13px, 4.5vw, 20px); }.sudoku-actions { display: grid; grid-template-columns: 1fr 1fr; }.game-keypad { gap: 5px; }
.challenge-panel { grid-template-columns: 1fr; padding: 20px; }.challenge-actions form { grid-template-columns: 1fr; }.challenge-actions .dark-button { width: 100%; }.challenge-code { width: 100%; padding: 14px 10px; font-size: 27px; }.realtime-status-line { display: grid; }.realtime-progress-panel { grid-template-columns: 1fr 1fr; }
.map-stage { height: 480px; transform: scale(.92); }.ability-node { width: 108px; height: 94px; }.node-vision { left: calc(50% - 54px); }.node-humanities { left: 0; top: 100px; }.node-connection { right: 0; top: 100px; }.node-detection { left: 2%; bottom: 30px; }.node-modeling { right: 2%; bottom: 30px; }.pet-node { top: 190px; }
.map-lines { display: none; }.ability-legend { margin-top: 14px; justify-content: start; }.video-cover { height: 150px; }
}
.life-hub, .life-skills { animation: rise .35s ease; }
.back-button { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 10px; padding: 8px 16px; background: transparent; color: var(--muted); cursor: pointer; margin-bottom: 18px; font-size: 13px; transition: .2s ease; }
.back-button:hover { color: var(--ink); border-color: var(--ink); }
.life-map { margin-top: 28px; }
.life-map-canvas { position: relative; max-width: 1200px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; padding: 30px 0; }
.life-map-lines { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; pointer-events: none; }
.life-map-center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 1; display: grid; place-items: center; text-align: center; pointer-events: none; }
.life-map-center b { display: grid; place-items: center; width: 56px; height: 56px; border-radius: 50%; background: var(--ink); color: var(--lime); font: 22px Georgia, serif; margin-bottom: 6px; }
.life-map-center span { font-size: 9px; color: var(--muted); letter-spacing: .12em; line-height: 1.4; }
.route-card {
position: relative; width: 100%; padding: 28px 26px 24px; border-radius: 22px; z-index: 2;
border: 1px solid rgba(23,33,27,.1); background: rgba(255,255,252,.82); backdrop-filter: blur(14px);
box-shadow: 0 24px 70px rgba(38,48,40,.06); cursor: pointer; transition: .25s ease; display: flex; flex-direction: column; min-height: 300px;
}
.route-card:hover { transform: translateY(-6px); box-shadow: 0 28px 80px rgba(38,48,40,.1); }
.route-card::after { content: ""; position: absolute; width: 80px; height: 80px; border-radius: 50%; right: -28px; top: -28px; opacity: .5; }
.route-believer::after { background: var(--lime); }
.route-spreader::after { background: var(--blue); }
.route-applier::after { background: var(--orange); }
.route-seer::after { background: #bb8ac4; }
.route-index { font: 700 13px Georgia, serif; color: #b0b6ae; letter-spacing: .08em; }
.route-spirit { margin-top: 14px; } .route-spirit b { display: block; font: 25px Georgia, serif; } .route-spirit small { font-size: 9px; color: var(--muted); letter-spacing: .22em; }
.route-conflict { margin: 16px 0 18px; color: #3e4942; font-size: 14px; line-height: 1.7; }
.route-mathematicians { display: flex; gap: 10px; margin-bottom: 18px; }
.route-mathematician { display: flex; flex-direction: column; align-items: center; gap: 5px; flex: 1; }
.route-mathematician img { width: 52px; height: 52px; border-radius: 50%; object-fit: cover; border: 2px solid rgba(23,33,27,.08); }
.route-mathematician b { font-size: 10px; text-align: center; }
.route-mathematician small { font-size: 8px; color: var(--muted); text-align: center; line-height: 1.3; }
.route-meta { margin-top: auto; margin-bottom: 16px; }
.route-status { font-size: 10px; color: var(--green); font-weight: 700; letter-spacing: .06em; }
.route-enter { border: 0; border-bottom: 2px solid currentColor; padding: 4px 0; background: transparent; cursor: pointer; font-size: 14px; font-weight: 650; text-align: left; }
.route-believer .route-enter { color: var(--green); } .route-spreader .route-enter { color: var(--blue); }
.route-applier .route-enter { color: var(--orange); } .route-seer .route-enter { color: #9b6bb0; }
.alumni-entry { margin-top: 50px; border-radius: 22px; background: var(--ink); color: white; padding: 40px 46px; overflow: hidden; position: relative; }
.alumni-entry::after { content: ""; position: absolute; width: 280px; height: 280px; right: -80px; bottom: -120px; border: 45px solid rgba(204,232,91,.08); border-radius: 50%; }
.alumni-entry-content { display: flex; align-items: center; justify-content: space-between; gap: 30px; position: relative; z-index: 1; }
.alumni-entry .kicker { color: var(--lime); }
.alumni-entry h2 { margin: 12px 0 10px; font: 30px Georgia, serif; }
.alumni-entry p { color: #aeb9b1; font-size: 14px; line-height: 1.7; max-width: 480px; }
.alumni-entry-action { display: flex; flex-direction: column; align-items: flex-end; gap: 14px; }
.alumni-entry-action span { font-size: 11px; color: #aeb9b1; }
.alumni-entry .primary-button { background: var(--lime); color: var(--ink); }
.skill-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.skill-grid.loading { display: block; color: var(--muted); padding: 40px 0; }
.skill-card { border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,252,.82); overflow: hidden; transition: .2s ease; }
.skill-card:hover { transform: translateY(-4px); box-shadow: 0 18px 38px rgba(38,48,40,.1); }
.skill-header { height: 90px; background: var(--ink); display: flex; align-items: flex-end; padding: 18px; }
.skill-meta { color: var(--lime); font-size: 10px; font-weight: 700; letter-spacing: .16em; }
.skill-body { padding: 22px; display: flex; flex-direction: column; min-height: 180px; }
.skill-body h3 { margin: 0 0 10px; font: 21px/1.3 Georgia, serif; }
.skill-body p { color: var(--muted); font-size: 13px; line-height: 1.7; flex: 1; }
.skill-body footer { display: flex; align-items: center; justify-content: space-between; margin-top: 18px; }
.skill-body footer span { color: var(--muted); font-size: 12px; }
.skill-body footer button { border: 0; border-bottom: 2px solid var(--green); background: transparent; color: var(--green); padding: 4px 0; cursor: pointer; font-weight: 650; }
.skill-body footer button:disabled { cursor: not-allowed; opacity: .5; }
@media (max-width: 1050px) {
.life-map-canvas { grid-template-columns: 1fr; }
.life-map-lines, .life-map-center { display: none; }
.alumni-entry-content { flex-direction: column; align-items: flex-start; }
.alumni-entry-action { align-items: flex-start; }
.skill-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 700px) {
.skill-grid { grid-template-columns: 1fr; }
.alumni-entry { padding: 28px 24px; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+277 -38
View File
@@ -1,3 +1,30 @@
const ROUTE_MATHEMATICIANS = {
believer: [
{ name: "高斯", portrait: "/static/img/mathematicians/1000.png", identity: "定理工匠" },
{ name: "祖冲之", portrait: "/static/img/mathematicians/1010.png", identity: "穷竭守尺人" },
{ name: "欧拉", portrait: "/static/img/mathematicians/1001.png", identity: "公式狂僧" },
{ name: "诺特", portrait: "/static/img/mathematicians/1011.png", identity: "抽象代数宗师" },
],
spreader: [
{ name: "斐波那契", portrait: "/static/img/mathematicians/1100.png", identity: "商路算郎" },
{ name: "华罗庚", portrait: "/static/img/mathematicians/1110.png", identity: "双法布道者" },
{ name: "伽罗瓦", portrait: "/static/img/mathematicians/1101.png", identity: "决斗狂热者" },
{ name: "Lovelace", portrait: "/static/img/mathematicians/1111.png", identity: "织机先知" },
],
applier: [
{ name: "秦九韶", portrait: "/static/img/mathematicians/0100.png", identity: "大衍谋士" },
{ name: "牛顿", portrait: "/static/img/mathematicians/0110.png", identity: "宇宙立法者" },
{ name: "图灵", portrait: "/static/img/mathematicians/0111.png", identity: "密码破译使" },
{ name: "冯·诺依曼", portrait: "/static/img/mathematicians/0101.png", identity: "博弈游侠" },
],
seer: [
{ name: "希帕提娅", portrait: "/static/img/mathematicians/0000.png", identity: "几何殉道者" },
{ name: "赵爽", portrait: "/static/img/mathematicians/0010.png", identity: "弦图先觉" },
{ name: "庞加莱", portrait: "/static/img/mathematicians/0001.png", identity: "拓扑游方" },
{ name: "约翰逊", portrait: "/static/img/mathematicians/0011.png", identity: "人脑计算机" },
],
};
const state = {
user: null,
stories: [],
@@ -43,22 +70,72 @@ function createIdempotencyKey() {
].join("-");
}
function collectApiErrorMessages(value, messages = []) {
if (Array.isArray(value)) {
value.forEach((item) => collectApiErrorMessages(item, messages));
} else if (value && typeof value === "object") {
Object.values(value).forEach((item) => collectApiErrorMessages(item, messages));
} else if (value !== undefined && value !== null) {
const message = String(value).trim();
if (message && !messages.includes(message)) messages.push(message);
}
return messages;
}
function logApiError(error) {
if (error.status === 401 || error.status === 403) return;
console.error("[Hulumath API]", {
method: error.method,
path: error.path,
status: error.status,
code: error.code,
requestId: error.requestId,
details: error.details,
});
}
async function api(path, options = {}) {
const method = (options.method || "GET").toUpperCase();
const requestPath = `/api/v1/${path}`;
const headers = { Accept: "application/json", ...(options.headers || {}) };
if (options.body && typeof options.body !== "string") {
headers["Content-Type"] = "application/json";
options.body = JSON.stringify(options.body);
}
if (!["GET", "HEAD"].includes(options.method || "GET")) headers["X-CSRFToken"] = csrfToken();
const response = await fetch(`/api/v1/${path}`, { credentials: "same-origin", ...options, headers });
if (!["GET", "HEAD"].includes(method)) headers["X-CSRFToken"] = csrfToken();
let response;
try {
response = await fetch(requestPath, { credentials: "same-origin", ...options, headers });
} catch (cause) {
const error = new Error("网络连接失败,请检查连接后重试");
error.status = null;
error.code = "network_error";
error.requestId = null;
error.details = null;
error.path = requestPath;
error.method = method;
error.cause = cause;
logApiError(error);
throw error;
}
if (response.status === 204) return null;
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const details = payload.error?.details;
const message = payload.error?.message || payload.detail ||
(details ? Object.values(details).flat().join(" ") : "请求失败");
const detailMessages = collectApiErrorMessages(details);
const requestId = payload.error?.request_id || response.headers.get("X-Request-ID");
const baseMessage = detailMessages.length
? detailMessages.join("")
: payload.error?.message || payload.detail || `请求失败(HTTP ${response.status}`;
const message = requestId ? `${baseMessage}(请求编号:${requestId}` : baseMessage;
const error = new Error(message);
error.status = response.status;
error.code = payload.error?.code || "request_error";
error.requestId = requestId;
error.details = details || null;
error.path = requestPath;
error.method = method;
logApiError(error);
throw error;
}
return payload;
@@ -135,25 +212,100 @@ function card({ meta, title, body, foot, action, onClick, disabled = false }) {
return article;
}
function renderRouteMathematicians() {
Object.entries(ROUTE_MATHEMATICIANS).forEach(([clan, mathematicians]) => {
const root = $(`[data-mathematicians="${clan}"]`);
if (!root) return;
root.replaceChildren(...mathematicians.map((person) => {
const item = document.createElement("div");
item.className = "route-mathematician";
const img = document.createElement("img");
img.src = person.portrait;
img.alt = person.name;
img.loading = "lazy";
const name = document.createElement("b");
name.textContent = person.name;
const identity = document.createElement("small");
identity.textContent = person.identity;
item.append(img, name, identity);
return item;
}));
});
}
async function loadStories() {
const root = $("#story-list");
try {
state.stories = await api("math-life/stories/");
root.classList.remove("loading");
root.replaceChildren(...state.stories.map((story) => card({
meta: story.kind === "flagship" ? "旗舰人生" : story.kind === "skill" ? "人物 Skill" : "特别篇",
title: story.title,
body: story.summary || "在不完整信息中判断机会、关系与代价。",
foot: `${story.estimated_minutes} 分钟`,
action: story.available ? "开始人生 →" : "查看预告",
disabled: !story.available,
onClick: () => beginStory(story.slug),
})));
renderRouteBadges();
renderSkillList();
} catch (error) {
root.textContent = error.message;
showToast(error.message);
}
}
function renderRouteBadges() {
const flagship = state.stories.find((story) => story.kind === "flagship" && story.available);
const skillCount = state.stories.filter((story) => story.kind === "skill").length;
const alumniCount = $("#alumni-count");
alumniCount.textContent = skillCount > 0 ? `当前 ${skillCount} 个校友人生可体验` : "即将上线校友访谈";
if (flagship) {
const route = flagship.slug.includes("believer") ? "believer" : "believer";
const card = $(`.route-card[data-route="${route}"]`);
if (card) {
card.querySelector(".route-status").textContent = `可体验 · 约 ${flagship.estimated_minutes} 分钟`;
}
}
}
function renderSkillList() {
const root = $("#skill-list");
const skills = state.stories.filter((story) => story.kind === "skill");
if (skills.length === 0) {
root.classList.remove("loading");
root.textContent = "校友访谈即将上线,敬请期待。";
return;
}
root.classList.remove("loading");
root.replaceChildren(...skills.map((story) => {
const article = document.createElement("article");
article.className = "skill-card";
const header = document.createElement("div");
header.className = "skill-header";
const meta = document.createElement("span");
meta.className = "skill-meta";
meta.textContent = story.kind === "skill" ? "校友访谈" : "特别篇";
header.append(meta);
const body = document.createElement("div");
body.className = "skill-body";
const title = document.createElement("h3");
title.textContent = story.title;
const summary = document.createElement("p");
summary.textContent = story.summary || "在不完整信息中判断机会、关系与代价。";
const footer = document.createElement("footer");
const duration = document.createElement("span");
duration.textContent = `${story.estimated_minutes} 分钟`;
const button = document.createElement("button");
button.textContent = story.available ? "开始人生 →" : "查看预告";
button.disabled = !story.available;
if (story.available) button.addEventListener("click", () => beginStory(story.slug));
footer.append(duration, button);
body.append(title, summary, footer);
article.append(header, body);
return article;
}));
}
function showLifeHub() {
$("#life-hub").classList.remove("hidden");
$("#life-skills").classList.add("hidden");
}
function showLifeSkills() {
$("#life-hub").classList.add("hidden");
$("#life-skills").classList.remove("hidden");
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function beginStory(slug) {
if (!requireAuth()) return;
try {
@@ -308,12 +460,8 @@ async function beginContest(contest) {
if (!requireAuth()) return;
try {
if (contest.kind === "realtime") {
const match = await api(`contests/${contest.slug}/matchmaking/`, { method: "POST", body: {} });
if (match.status === "waiting") {
showToast("已进入匹配队列,等待同赛道对手");
return;
}
renderAttempt(match.attempt);
await window.HuluRealtime.startRandom(contest);
return;
} else {
renderAttempt(await api(`contests/${contest.slug}/start/`, { method: "POST", body: {} }));
}
@@ -379,27 +527,46 @@ function renderAttempt(attempt) {
root.append(label, title, form);
}
function filterChip(label, value, type, active) {
function filterOption(label, value, type, active) {
const button = document.createElement("button");
button.className = `filter-chip${active ? " active" : ""}`;
button.className = `filter-option${active ? " active" : ""}`;
button.textContent = label;
button.dataset[type] = value;
button.setAttribute("role", "option");
button.addEventListener("click", () => {
if (type === "ability") state.videoAbility = value;
if (type === "discipline") state.videoDiscipline = value;
renderVideoFilters();
renderVideos();
closeFilterDropdowns();
});
return button;
}
function closeFilterDropdowns() {
$$(".filter-dropdown-panel").forEach((panel) => panel.classList.add("hidden"));
$$(".filter-dropdown-toggle").forEach((toggle) => toggle.setAttribute("aria-expanded", "false"));
}
function toggleFilterDropdown(type) {
const panel = $(`#${type}-panel`);
const toggle = $(`#${type}-toggle`);
const isOpen = !panel.classList.contains("hidden");
closeFilterDropdowns();
if (!isOpen) {
panel.classList.remove("hidden");
toggle.setAttribute("aria-expanded", "true");
}
}
function renderVideoFilters() {
if (!state.videoCatalog) return;
const abilityRoot = $("#ability-filters");
abilityRoot.replaceChildren(
filterChip(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility),
const abilityPanel = $("#ability-panel");
abilityPanel.replaceChildren(
filterOption(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility),
...state.videoCatalog.abilities.map((ability) =>
filterChip(
filterOption(
`${ability.icon} ${ability.label} ${ability.count}`,
ability.id,
"ability",
@@ -407,11 +574,14 @@ function renderVideoFilters() {
)
),
);
const disciplineRoot = $("#discipline-filters");
disciplineRoot.replaceChildren(
filterChip("全部专业", "", "discipline", !state.videoDiscipline),
const abilityLabel = state.videoCatalog.abilities.find((a) => a.id === state.videoAbility);
$("#ability-label").textContent = abilityLabel ? `${abilityLabel.icon} ${abilityLabel.label}` : "全部维度";
const disciplinePanel = $("#discipline-panel");
disciplinePanel.replaceChildren(
filterOption("全部专业", "", "discipline", !state.videoDiscipline),
...state.videoCatalog.disciplines.map((discipline) =>
filterChip(
filterOption(
`${discipline.icon || "∑"} ${discipline.name}`,
discipline.name,
"discipline",
@@ -419,11 +589,10 @@ function renderVideoFilters() {
)
),
);
const disciplineLabel = state.videoCatalog.disciplines.find((d) => d.name === state.videoDiscipline);
$("#discipline-label").textContent = disciplineLabel ? `${disciplineLabel.icon || "∑"} ${disciplineLabel.name}` : "全部专业";
const selected = state.videoCatalog.abilities.find(
(item) => item.id === state.videoAbility
);
$("#video-stream-title").textContent = selected ? `${selected.label}视频流` : "全部视频";
$("#video-stream-title").textContent = abilityLabel ? `${abilityLabel.label}视频流` : "全部视频";
$$(".ability-node").forEach((node) => {
node.classList.toggle("active", node.dataset.ability === state.videoAbility);
});
@@ -913,12 +1082,52 @@ function switchTool(tool) {
window.HuluToolbox?.activate(tool);
}
function normalizeLatexSource(source) {
let normalized = String(source || "").trim();
if (normalized.startsWith("$$") && normalized.endsWith("$$")) {
normalized = normalized.slice(2, -2).trim();
} else if (normalized.startsWith("\\[") && normalized.endsWith("\\]")) {
normalized = normalized.slice(2, -2).trim();
} else if (normalized.startsWith("$") && normalized.endsWith("$")) {
normalized = normalized.slice(1, -1).trim();
}
return normalized.replace(/\\\\(?=[A-Za-z,;:!])/g, "\\");
}
function renderLatexPreview(source) {
const target = $("#latex-preview");
target.replaceChildren();
target.classList.remove("latex-preview-error");
const normalized = normalizeLatexSource(source);
if (!normalized) return;
if (typeof globalThis.katex?.render !== "function") {
target.classList.add("latex-preview-error");
target.textContent = "公式渲染组件加载失败,请刷新页面后重试。";
return;
}
try {
globalThis.katex.render(normalized, target, {
displayMode: true,
throwOnError: true,
trust: false,
maxExpand: 500,
maxSize: 20,
});
} catch (error) {
target.classList.add("latex-preview-error");
target.textContent = `公式语法错误:${error.message}`;
}
}
async function saveFormula() {
if (!requireAuth()) return;
try {
await api("latex/documents/", {
method: "POST",
body: { title: `公式 ${new Date().toLocaleString()}`, source: $("#latex-source").value },
body: {
title: `公式 ${new Date().toLocaleString()}`,
source: normalizeLatexSource($("#latex-source").value),
},
});
showToast("公式及首个版本已保存");
} catch (error) {
@@ -932,13 +1141,39 @@ function bindUI() {
$$("[data-open-auth]").forEach((button) => button.addEventListener("click", openAuth));
$("#start-mathbti").addEventListener("click", startMathBTI);
$("#save-formula").addEventListener("click", saveFormula);
$("#latex-source").addEventListener("input", (event) => { $("#latex-preview").textContent = event.target.value; });
$("#latex-source").addEventListener("input", (event) => { renderLatexPreview(event.target.value); });
$$(".tool-card").forEach((button) => {
button.addEventListener("click", () => switchTool(button.dataset.tool));
});
$("#symbol-search").addEventListener("input", (event) => {
renderSymbols(event.target.value);
});
$("#enter-alumni").addEventListener("click", showLifeSkills);
$("#back-to-hub").addEventListener("click", showLifeHub);
$("#ability-toggle").addEventListener("click", () => toggleFilterDropdown("ability"));
$("#discipline-toggle").addEventListener("click", () => toggleFilterDropdown("discipline"));
document.addEventListener("click", (event) => {
if (!event.target.closest(".filter-dropdown")) closeFilterDropdowns();
});
$$(".route-card").forEach((card) => {
card.addEventListener("click", (event) => {
if (event.target.closest(".route-enter")) return;
const route = card.dataset.route;
const flagship = state.stories.find(
(story) => story.kind === "flagship" && story.available && story.slug.includes(route),
);
if (flagship) beginStory(flagship.slug);
else showToast(`${card.querySelector(".route-spirit b").textContent}人生即将开放`);
});
card.querySelector(".route-enter").addEventListener("click", () => {
const route = card.dataset.route;
const flagship = state.stories.find(
(story) => story.kind === "flagship" && story.available && story.slug.includes(route),
);
if (flagship) beginStory(flagship.slug);
else showToast(`${card.querySelector(".route-spirit b").textContent}人生即将开放`);
});
});
$$(".ability-node").forEach((button) => {
button.addEventListener("click", () => {
state.videoAbility =
@@ -951,6 +1186,7 @@ function bindUI() {
$("#auth-button").addEventListener("click", async () => {
if (!state.user) return openAuth();
await api("accounts/logout/", { method: "POST", body: {} });
window.HuluRealtime?.reset();
state.user = null;
updateUserUI();
showToast("已退出登录");
@@ -995,7 +1231,9 @@ function bindUI() {
async function boot() {
bindUI();
renderRouteMathematicians();
window.HuluToolbox?.init();
window.HuluRealtime?.init();
renderSymbols();
await Promise.all([
loadUser(),
@@ -1004,6 +1242,7 @@ async function boot() {
loadContent(),
window.HuluGames?.load(),
]);
renderLatexPreview($("#latex-source").value);
}
boot();
+10 -1
View File
@@ -143,6 +143,13 @@
input.placeholder = "例如:6 / (1 - 3 / 4)";
input.autocomplete = "off";
input.inputMode = "text";
input.spellcheck = false;
const errorMessage = document.createElement("p");
errorMessage.className = "form-error game-form-error";
errorMessage.setAttribute("role", "alert");
input.addEventListener("input", () => {
errorMessage.textContent = "";
});
const keypad = document.createElement("div");
keypad.className = "game-keypad";
["+", "-", "*", "/", "(", ")"].forEach((operator) => {
@@ -159,10 +166,11 @@
submit.className = "primary-button";
submit.type = "submit";
submit.textContent = "验证并计分";
form.append(input, keypad, submit);
form.append(input, keypad, errorMessage, submit);
form.addEventListener("submit", async (event) => {
event.preventDefault();
submit.disabled = true;
errorMessage.textContent = "";
try {
const result = await api(
`contests/games/attempts/${attempt.attempt_id}/submit/`,
@@ -175,6 +183,7 @@
renderTwentyFour(result);
showToast("得到 24,成绩已记录");
} catch (error) {
errorMessage.textContent = error.message;
showToast(error.message);
submit.disabled = false;
}
+468
View File
@@ -0,0 +1,468 @@
(function () {
const realtime = {
match: null,
socket: null,
pollTimer: null,
clockTimer: null,
opponentProgress: 0,
reconnectTimer: null,
};
function stopTimers() {
if (realtime.pollTimer) window.clearInterval(realtime.pollTimer);
if (realtime.clockTimer) window.clearInterval(realtime.clockTimer);
realtime.pollTimer = null;
realtime.clockTimer = null;
}
function closeSocket() {
if (realtime.reconnectTimer) window.clearTimeout(realtime.reconnectTimer);
realtime.reconnectTimer = null;
if (realtime.socket) {
realtime.socket.onclose = null;
realtime.socket.close();
}
realtime.socket = null;
}
function websocketUrl(path) {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}${path}`;
}
function connectSocket() {
closeSocket();
if (!realtime.match?.websocket_path) return;
const socket = new WebSocket(websocketUrl(realtime.match.websocket_path));
realtime.socket = socket;
socket.addEventListener("open", () => {
updateConnectionStatus("实时连接已建立");
socket.send(JSON.stringify({ type: "ping" }));
});
socket.addEventListener("message", async (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "state") {
await refreshMatch();
} else if (
message.type === "progress" &&
message.user_id !== String(state.user?.id)
) {
realtime.opponentProgress = message.answered_count;
updateProgressUI();
}
});
socket.addEventListener("close", () => {
updateConnectionStatus("实时连接中断,正在使用轮询");
if (["waiting", "active"].includes(realtime.match?.status)) {
realtime.reconnectTimer = window.setTimeout(connectSocket, 2000);
}
});
socket.addEventListener("error", () => {
updateConnectionStatus("WebSocket 不可用,轮询仍在工作");
});
}
function updateConnectionStatus(message) {
const node = document.querySelector("#realtime-connection");
if (node) node.textContent = message;
}
async function refreshMatch() {
if (!realtime.match?.match_id) return;
try {
const match = await api(`contests/matches/${realtime.match.match_id}/`);
const previous = realtime.match;
const previousStatus = previous.status;
realtime.match = match;
const shouldRender =
previous.status !== match.status ||
previous.attempt?.status !== match.attempt?.status;
if (shouldRender) renderMatch();
else {
updateClock();
if (
previous.opponent?.status !== match.opponent?.status &&
match.opponent?.status &&
match.opponent.status !== "active"
) {
realtime.opponentProgress = match.attempt?.questions.length || 0;
updateProgressUI();
updateConnectionStatus("对手已提交,完成后将立即结算");
}
}
if (previousStatus === "waiting" && match.status === "active") {
showToast(`已匹配到 ${match.opponent.nickname}`);
}
if (!["waiting", "active"].includes(match.status)) {
stopTimers();
closeSocket();
}
} catch (error) {
if (error.status === 404) {
stopTimers();
closeSocket();
}
}
}
function startPolling() {
stopTimers();
realtime.pollTimer = window.setInterval(refreshMatch, 2000);
realtime.clockTimer = window.setInterval(updateClock, 250);
}
function openMatch(match) {
realtime.match = match;
realtime.opponentProgress = 0;
renderMatch();
const dialog = document.querySelector("#experience-dialog");
if (!dialog.open) dialog.showModal();
connectSocket();
if (["waiting", "active"].includes(match.status)) startPolling();
}
function header(root, match) {
const label = document.createElement("span");
label.className = "kicker";
label.textContent =
`${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · REALTIME`;
const title = document.createElement("h2");
title.textContent = match.contest;
const status = document.createElement("div");
status.className = "realtime-status-line";
const connection = document.createElement("span");
connection.id = "realtime-connection";
connection.textContent = "正在建立实时连接…";
const clock = document.createElement("strong");
clock.id = "realtime-clock";
status.append(connection, clock);
root.append(label, title, status);
}
function updateClock() {
const clock = document.querySelector("#realtime-clock");
if (!clock || !realtime.match) return;
if (realtime.match.status === "waiting") {
const expiresAt = new Date(realtime.match.expires_at).getTime();
const seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
clock.textContent = `联机码 ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} 后失效`;
return;
}
if (realtime.match.status === "active") {
const startedAt = new Date(realtime.match.started_at).getTime();
const elapsed = (Date.now() - startedAt) / 1000;
const remaining = Math.max(0, Math.ceil(realtime.match.duration_seconds - elapsed));
clock.textContent = `剩余 ${remaining}`;
if (remaining === 0) refreshMatch();
return;
}
clock.textContent = "";
}
async function copyCode(code) {
try {
await navigator.clipboard.writeText(code);
showToast(`联机码 ${code} 已复制`);
} catch {
showToast(`联机码:${code}`);
}
}
function renderWaiting(root, match) {
const panel = document.createElement("div");
panel.className = "realtime-waiting";
const pulse = document.createElement("span");
pulse.className = "realtime-pulse";
const message = document.createElement("p");
message.textContent =
match.match_type === "challenge"
? "把联机码发给朋友。对方加入后会自动开始,无需再次点击。"
: "正在寻找同赛道、相近 Rating 的玩家。";
panel.append(pulse, message);
if (match.challenge_code) {
const code = document.createElement("button");
code.className = "challenge-code";
code.textContent = match.challenge_code;
code.title = "点击复制联机码";
code.addEventListener("click", () => copyCode(match.challenge_code));
panel.append(code);
}
if (match.is_owner) {
const cancel = document.createElement("button");
cancel.className = "ghost-button";
cancel.textContent = "取消等待";
cancel.addEventListener("click", async () => {
cancel.disabled = true;
try {
realtime.match = await api(`contests/matches/${match.match_id}/cancel/`, {
method: "POST",
body: {},
});
renderMatch();
stopTimers();
closeSocket();
} catch (error) {
showToast(error.message);
cancel.disabled = false;
}
});
panel.append(cancel);
}
root.append(panel);
updateClock();
}
function progressPanel(root, match) {
const panel = document.createElement("div");
panel.className = "realtime-progress-panel";
const self = document.createElement("div");
self.innerHTML = `<span>你</span><b id="self-progress">0 / ${match.attempt.questions.length}</b>`;
const opponent = document.createElement("div");
opponent.innerHTML =
`<span>${match.opponent?.nickname || "对手"}</span>` +
`<b id="opponent-progress">${realtime.opponentProgress} / ${match.attempt.questions.length}</b>`;
panel.append(self, opponent);
root.append(panel);
}
function updateProgressUI(selfCount) {
if (Number.isInteger(selfCount)) {
const self = document.querySelector("#self-progress");
if (self && realtime.match?.attempt) {
self.textContent = `${selfCount} / ${realtime.match.attempt.questions.length}`;
}
}
const opponent = document.querySelector("#opponent-progress");
if (opponent && realtime.match?.attempt) {
opponent.textContent =
`${realtime.opponentProgress} / ${realtime.match.attempt.questions.length}`;
}
}
function sendProgress(answeredCount) {
if (realtime.socket?.readyState === WebSocket.OPEN) {
realtime.socket.send(
JSON.stringify({ type: "progress", answered_count: answeredCount })
);
}
}
function renderActive(root, match) {
progressPanel(root, match);
const attempt = match.attempt;
if (attempt.status !== "active") {
const waiting = document.createElement("div");
waiting.className = "realtime-submitted";
waiting.innerHTML =
"<strong>答案已锁定</strong><p>等待对手提交。双方完成后才会公开答案和 Rating 变化。</p>";
root.append(waiting);
updateClock();
return;
}
const form = document.createElement("form");
form.className = "choice-list realtime-answer-form";
attempt.questions.forEach((question) => {
const field = document.createElement("label");
field.textContent = `${question.order}. ${question.prompt}`;
const input = document.createElement("input");
input.name = String(question.order);
input.inputMode = "decimal";
input.autocomplete = "off";
input.addEventListener("input", () => {
const answered = [...form.querySelectorAll("input")].filter(
(item) => item.value.trim()
).length;
updateProgressUI(answered);
sendProgress(answered);
});
field.append(input);
form.append(field);
});
const submit = document.createElement("button");
submit.className = "primary-button";
submit.type = "submit";
submit.textContent = "提交并等待对手";
form.append(submit);
form.addEventListener("submit", async (event) => {
event.preventDefault();
submit.disabled = true;
const data = new FormData(form);
try {
realtime.match = await api(
`contests/attempts/${attempt.attempt_id}/submit/`,
{
method: "POST",
headers: { "Idempotency-Key": createIdempotencyKey() },
body: {
answers: attempt.questions.map((question) => ({
order: question.order,
answer: data.get(String(question.order)) || "",
})),
},
},
);
renderMatch();
} catch (error) {
showToast(error.message);
submit.disabled = false;
}
});
root.append(form);
updateClock();
}
function renderCompleted(root, match) {
const result = document.createElement("section");
result.className = `realtime-result result-${match.result.winner}`;
const outcome = document.createElement("strong");
outcome.textContent =
match.result.winner === "self"
? "获胜"
: match.result.winner === "opponent"
? "本局惜败"
: "平局";
const score = document.createElement("p");
score.textContent =
`${match.attempt.score} 分 · ${match.opponent.score}${match.opponent.nickname}`;
const timeLine = document.createElement("p");
timeLine.className = "realtime-time-line";
const selfMs = match.attempt.duration_ms || 0;
const opponentMs = match.opponent.duration_ms || 0;
const selfSec = (selfMs / 1000).toFixed(1);
const opponentSec = (opponentMs / 1000).toFixed(1);
timeLine.textContent = `${selfSec}s · ${match.opponent.nickname} ${opponentSec}s`;
if (match.attempt.score === match.opponent.score && match.result.winner !== "draw") {
const faster = selfMs < opponentMs ? "你" : match.opponent.nickname;
const tiebreak = document.createElement("small");
tiebreak.className = "realtime-tiebreak";
tiebreak.textContent = `同分,${faster}更快完成,快者胜`;
timeLine.append(tiebreak);
}
const rating = document.createElement("b");
const sign = match.result.rating_delta > 0 ? "+" : "";
rating.textContent =
`Rating ${sign}${match.result.rating_delta}${match.result.rating_after}`;
result.append(outcome, score, timeLine, rating);
root.append(result);
// 刷新右上角用户 Rating 显示
if (typeof loadUser === "function") loadUser();
const review = document.createElement("div");
review.className = "realtime-review";
match.attempt.questions.forEach((question) => {
const item = document.createElement("article");
const title = document.createElement("b");
title.textContent = `${question.order}. ${question.prompt}`;
const answer = document.createElement("p");
answer.textContent =
`你的答案:${question.submitted_answer || "未作答"} · 正确答案:${question.correct_answer}`;
const explanation = document.createElement("small");
explanation.textContent = question.explanation || "";
item.className = question.is_correct ? "correct" : "incorrect";
item.append(title, answer, explanation);
review.append(item);
});
root.append(review);
}
function renderMatch() {
const root = document.querySelector("#experience-content");
const match = realtime.match;
root.replaceChildren();
header(root, match);
if (match.status === "waiting") renderWaiting(root, match);
else if (match.status === "active") renderActive(root, match);
else if (match.status === "completed") renderCompleted(root, match);
else {
const message = document.createElement("p");
message.className = "scene";
message.textContent = "这场匹配已取消或联机码已过期。";
root.append(message);
}
}
function currentRealtimeContest() {
return state.contests.find(
(contest) => contest.kind === "realtime" && contest.track === state.track
);
}
async function startRandom(contest) {
const match = await api(`contests/${contest.slug}/matchmaking/`, {
method: "POST",
body: {},
});
openMatch(match);
}
async function createChallenge() {
if (!requireAuth()) return;
const contest = currentRealtimeContest();
if (!contest) {
showToast("当前赛道没有可用的实时比赛");
return;
}
try {
const match = await api(`contests/${contest.slug}/challenges/`, {
method: "POST",
body: {},
});
openMatch(match);
} catch (error) {
showToast(error.message);
}
}
async function joinChallenge(event) {
event.preventDefault();
if (!requireAuth()) return;
const input = document.querySelector("#challenge-code-input");
const challengeCode = input.value.trim().toUpperCase();
if (challengeCode.length !== 6) {
showToast("请输入 6 位联机码");
return;
}
try {
const match = await api("contests/challenges/join/", {
method: "POST",
body: { challenge_code: challengeCode },
});
input.value = "";
openMatch(match);
} catch (error) {
showToast(error.message);
}
}
function init() {
document
.querySelector("#challenge-create")
.addEventListener("click", createChallenge);
document
.querySelector("#challenge-join-form")
.addEventListener("submit", joinChallenge);
document
.querySelector("#challenge-code-input")
.addEventListener("input", (event) => {
event.target.value = event.target.value
.toUpperCase()
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, "")
.slice(0, 6);
});
}
function reset() {
stopTimers();
closeSocket();
realtime.match = null;
realtime.opponentProgress = 0;
}
window.HuluRealtime = { init, startRandom, refreshMatch, reset };
})();
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More