Compare commits
19
Commits
f177739824
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac22a4c52 | ||
|
|
8e94f053b1 | ||
|
|
30f335c83b | ||
|
|
6c9f475ba5 | ||
|
|
aafaf1b77a | ||
|
|
b8916180a9 | ||
|
|
40e9462842 | ||
|
|
97ca20413e | ||
|
|
587962f9c9 | ||
|
|
94188b5f0a | ||
|
|
46815c28f1 | ||
|
|
589adfa5e6 | ||
|
|
9f9aa2825a | ||
|
|
ee006fba18 | ||
|
|
ebd6070aae | ||
|
|
52816ce442 | ||
|
|
903ead60c9 | ||
|
|
3e47ba787d | ||
|
|
feb8845ec7 |
@@ -2,6 +2,7 @@
|
|||||||
# 包含 #、空格等特殊字符的值请用单引号包裹。
|
# 包含 #、空格等特殊字符的值请用单引号包裹。
|
||||||
|
|
||||||
DJANGO_SETTINGS_MODULE=config.settings
|
DJANGO_SETTINGS_MODULE=config.settings
|
||||||
|
APP_VERSION=1.2.1
|
||||||
DJANGO_SECRET_KEY='replace-with-at-least-50-random-characters'
|
DJANGO_SECRET_KEY='replace-with-at-least-50-random-characters'
|
||||||
DJANGO_DEBUG=false
|
DJANGO_DEBUG=false
|
||||||
DJANGO_USE_HTTPS=true
|
DJANGO_USE_HTTPS=true
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ jobs:
|
|||||||
- name: ASGI import check
|
- name: ASGI import check
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: ../.venv-ci/bin/python -c "from config.asgi import application; print(type(application).__name__)"
|
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
|
- name: SQLite tests and coverage
|
||||||
run: |
|
run: |
|
||||||
.venv-ci/bin/python -m pytest -q \
|
.venv-ci/bin/python -m pytest -q \
|
||||||
|
|||||||
+11
-51
@@ -15,64 +15,24 @@ jobs:
|
|||||||
release-check:
|
release-check:
|
||||||
if: ${{ github.event.pull_request.merged == true }}
|
if: ${{ github.event.pull_request.merged == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 5
|
||||||
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
|
|
||||||
steps:
|
steps:
|
||||||
- name: 检出 main
|
- name: 检出 main
|
||||||
env:
|
env:
|
||||||
REPOSITORY_URL: http://117.72.28.96:8765/Jacky/Hulumath-Web.git
|
REPOSITORY_URL: http://117.72.28.96:8765/Jacky/Hulumath-Web.git
|
||||||
run: git clone --branch main --single-branch "$REPOSITORY_URL" .
|
run: git clone --branch main --single-branch "$REPOSITORY_URL" .
|
||||||
|
|
||||||
- name: 显示 Python 版本
|
- name: 检查发布文件和脚本语法
|
||||||
run: python3 --version
|
|
||||||
|
|
||||||
- name: 安装 MySQL 编译依赖
|
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
set -eu
|
||||||
sudo apt-get install -y \
|
test -f requirements.txt
|
||||||
default-libmysqlclient-dev \
|
test -f backend/manage.py
|
||||||
pkg-config \
|
test -f deploy/hulumath-web.service
|
||||||
python3-dev \
|
test -f scripts/deploy_production.sh
|
||||||
python3-venv
|
test -f scripts/smoke_production.py
|
||||||
|
bash -n scripts/deploy_production.sh
|
||||||
- name: 安装发布检查依赖
|
python3 -m compileall -q backend scripts
|
||||||
run: |
|
git diff-tree --check -m -r HEAD
|
||||||
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
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
if: ${{ github.event.pull_request.merged == true }}
|
if: ${{ github.event.pull_request.merged == true }}
|
||||||
|
|||||||
+1
-1
@@ -428,7 +428,7 @@ PR 描述至少包含:
|
|||||||
2. 处理审查意见,不要无解释地关闭讨论。
|
2. 处理审查意见,不要无解释地关闭讨论。
|
||||||
3. 请求 Jacky 审查。
|
3. 请求 Jacky 审查。
|
||||||
4. 只有 Jacky 明确批准后才可合并。
|
4. 只有 Jacky 明确批准后才可合并。
|
||||||
5. 合并后观察 `PR合并自动部署` 的 `release-check` 和 `deploy`。
|
5. 合并后观察 `PR合并自动部署`:`release-check` 做无依赖轻量检查,`deploy` 执行生产发布与冒烟。
|
||||||
6. 部署失败时保留日志,先判断是代码、迁移、网络还是冒烟检查问题。
|
6. 部署失败时保留日志,先判断是代码、迁移、网络还是冒烟检查问题。
|
||||||
|
|
||||||
## 14. 安全与隐私
|
## 14. 安全与隐私
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install migrate seed run run-asgi test check
|
.PHONY: install migrate seed local-admin run run-asgi test check
|
||||||
|
|
||||||
install:
|
install:
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
@@ -11,6 +11,9 @@ seed:
|
|||||||
cd backend && ../.venv/bin/python manage.py seed_initial_content
|
cd backend && ../.venv/bin/python manage.py seed_initial_content
|
||||||
cd backend && ../.venv/bin/python manage.py seed_contests
|
cd backend && ../.venv/bin/python manage.py seed_contests
|
||||||
|
|
||||||
|
local-admin:
|
||||||
|
cd backend && ../.venv/bin/python manage.py init_local_admin
|
||||||
|
|
||||||
run:
|
run:
|
||||||
cd backend && ../.venv/bin/python manage.py runserver
|
cd backend && ../.venv/bin/python manage.py runserver
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
面向全年龄数学兴趣用户的“数学人生宇宙”。当前仓库包含可运行的 Django 模块化单体、响应式 Web 客户端、运营后台、内容种子、实时比赛基础设施和生产部署配置。
|
面向全年龄数学兴趣用户的“数学人生宇宙”。当前仓库包含可运行的 Django 模块化单体、响应式 Web 客户端、运营后台、内容种子、实时比赛基础设施和生产部署配置。
|
||||||
|
|
||||||
|
当前发布版本:**v1.2.1**。变更与迁移说明见
|
||||||
|
[v1.2.1 Release Notes](docs/RELEASE_NOTES_V1.2.1.md)。
|
||||||
|
|
||||||
## 参与贡献
|
## 参与贡献
|
||||||
|
|
||||||
提交代码、内容或文档前,请先完整阅读 [代码贡献指南](CONTRIBUTING.md)。
|
提交代码、内容或文档前,请先完整阅读 [代码贡献指南](CONTRIBUTING.md)。
|
||||||
@@ -14,13 +17,14 @@
|
|||||||
- 12 题 MathBTI、16 种数学人格、人物卡与数学精灵初始化
|
- 12 题 MathBTI、16 种数学人格、人物卡与数学精灵初始化
|
||||||
- 统一版本化剧情引擎、85 节点信仰者主线、2 个人物 Skill 样板
|
- 统一版本化剧情引擎、85 节点信仰者主线、2 个人物 Skill 样板
|
||||||
- 剧情服务端存档、嵌套资源效果、结局与幂等选择
|
- 剧情服务端存档、嵌套资源效果、结局与幂等选择
|
||||||
- 入门、标准、进阶三赛道的实时 1v1、今日挑战、单人闯关、24 点和数独
|
- 统一玩家池的口算、数独 Timerun、24 点实时 1v1,以及随机今日挑战和单人闯关
|
||||||
- 题目版本、服务端计时判分、Elo Rating、排行榜和基础反作弊
|
- 题目版本、服务端计时判分、Elo Rating、排行榜和基础反作弊
|
||||||
- Channels WebSocket 比赛进度通道,Redis Channel Layer
|
- Channels WebSocket 比赛进度通道,Redis Channel Layer
|
||||||
- LaTeX 文档与版本、六级零基础课程、练习判定
|
- LaTeX 文档与版本、六级零基础课程、练习判定
|
||||||
- 54 条旧版志愿者视频、五维能力地图、专业筛选与融合视频流
|
- 54 条旧版志愿者视频、五维能力地图、专业筛选与融合视频流
|
||||||
- 视频观看进度、幂等奖励、收藏、五维能力、数学精灵和人物卡册
|
- 视频观看进度、幂等奖励、收藏、五维能力、数学精灵和人物卡册
|
||||||
- 多工具工具箱:强计算器、增强函数绘图、数学白板、几何画板、符号查询和 LaTeX Lab
|
- 多工具工具箱:强计算器、增强函数绘图、统一联机数学画板、符号查询和 LaTeX Lab
|
||||||
|
- 信仰者人生 20 印记、4 个组合彩蛋、4 个直博方向与独立全屏交互
|
||||||
- Django Admin、健康检查、请求 ID、限流与统一 API 错误结构
|
- Django Admin、健康检查、请求 ID、限流与统一 API 错误结构
|
||||||
- MySQL 8.0/Redis Docker Compose、Gitea CI 和自动化测试
|
- MySQL 8.0/Redis Docker Compose、Gitea CI 和自动化测试
|
||||||
|
|
||||||
@@ -39,9 +43,19 @@ make run
|
|||||||
|
|
||||||
管理后台位于 `http://127.0.0.1:8000/admin/`。创建管理员:
|
管理后台位于 `http://127.0.0.1:8000/admin/`。创建管理员:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make local-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
默认本地测试账号为 `local_admin` / `LocalAdmin2026!`。该命令仅允许在
|
||||||
|
`DJANGO_DEBUG=true` 时运行,不会接触或修改生产管理员。需要自定义时:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
../.venv/bin/python manage.py createsuperuser
|
../.venv/bin/python manage.py init_local_admin \
|
||||||
|
--username jacky_local \
|
||||||
|
--password '仅用于本机的测试密码' \
|
||||||
|
--reset-password
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "仅在 DEBUG 环境创建或修复本地测试管理员"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--username",
|
||||||
|
default=os.getenv("LOCAL_ADMIN_USERNAME", "local_admin"),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--password",
|
||||||
|
default=os.getenv("LOCAL_ADMIN_PASSWORD", "LocalAdmin2026!"),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--email",
|
||||||
|
default=os.getenv("LOCAL_ADMIN_EMAIL", "local-admin@example.test"),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reset-password",
|
||||||
|
action="store_true",
|
||||||
|
help="已存在账号时也重置密码",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if not settings.DEBUG:
|
||||||
|
raise CommandError("init_local_admin 仅允许在 DJANGO_DEBUG=true 时运行")
|
||||||
|
|
||||||
|
username = options["username"].strip()
|
||||||
|
password = options["password"]
|
||||||
|
if not username or len(password) < 8:
|
||||||
|
raise CommandError("用户名不能为空,密码至少需要 8 个字符")
|
||||||
|
|
||||||
|
user, created = User.objects.get_or_create(
|
||||||
|
username=username,
|
||||||
|
defaults={
|
||||||
|
"nickname": "本地管理员",
|
||||||
|
"email": options["email"],
|
||||||
|
"is_staff": True,
|
||||||
|
"is_superuser": True,
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
changed_fields = []
|
||||||
|
for field in ("is_staff", "is_superuser", "is_active"):
|
||||||
|
if not getattr(user, field):
|
||||||
|
setattr(user, field, True)
|
||||||
|
changed_fields.append(field)
|
||||||
|
if not user.nickname:
|
||||||
|
user.nickname = "本地管理员"
|
||||||
|
changed_fields.append("nickname")
|
||||||
|
if created or options["reset_password"] or not user.has_usable_password():
|
||||||
|
user.set_password(password)
|
||||||
|
changed_fields.append("password")
|
||||||
|
if changed_fields:
|
||||||
|
user.save(update_fields=changed_fields)
|
||||||
|
|
||||||
|
action = "已创建" if created else "已确认"
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"{action}本地管理员 {username};后台地址 http://127.0.0.1:8000/admin/"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import pytest
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(DEBUG=True)
|
||||||
|
def test_init_local_admin_创建可登录管理员(client):
|
||||||
|
call_command(
|
||||||
|
"init_local_admin",
|
||||||
|
username="test_local_admin",
|
||||||
|
password="LocalAdmin2026!",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User.objects.get(username="test_local_admin")
|
||||||
|
assert user.is_staff is True
|
||||||
|
assert user.is_superuser is True
|
||||||
|
assert client.login(
|
||||||
|
username="test_local_admin",
|
||||||
|
password="LocalAdmin2026!",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(DEBUG=False)
|
||||||
|
def test_init_local_admin_生产环境拒绝执行():
|
||||||
|
with pytest.raises(CommandError, match="仅允许"):
|
||||||
|
call_command(
|
||||||
|
"init_local_admin",
|
||||||
|
username="forbidden_admin",
|
||||||
|
password="LocalAdmin2026!",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not User.objects.filter(username="forbidden_admin").exists()
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
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"
|
STATIC_ROOT = Path(settings.BASE_DIR) / "static"
|
||||||
|
|
||||||
@@ -58,9 +61,16 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在():
|
|||||||
assert "js/games.js" in template
|
assert "js/games.js" in template
|
||||||
assert 'id="whiteboard-canvas"' in template
|
assert 'id="whiteboard-canvas"' in template
|
||||||
assert 'id="geometry-canvas"' in template
|
assert 'id="geometry-canvas"' in template
|
||||||
|
assert template.count('data-tool="whiteboard"') == 1
|
||||||
|
assert 'data-tool="geometry"' not in template
|
||||||
|
assert 'id="board-join-form"' in template
|
||||||
|
assert "函数书写规则" in template
|
||||||
assert 'id="math-game-list"' in template
|
assert 'id="math-game-list"' in template
|
||||||
assert "window.HuluToolbox" in toolbox
|
assert "window.HuluToolbox" in toolbox
|
||||||
assert "pointerdown" in toolbox
|
assert "pointerdown" in toolbox
|
||||||
|
assert "class BoardRealtime" in toolbox
|
||||||
|
assert 'boardRealtime.send("canvas", payload)' in toolbox
|
||||||
|
assert 'boardRealtime.send("geometry", payload)' in toolbox
|
||||||
assert "window.HuluGames" in games
|
assert "window.HuluGames" in games
|
||||||
assert "sudoku-board" in games
|
assert "sudoku-board" in games
|
||||||
assert 'errorMessage.className = "form-error game-form-error"' in games
|
assert 'errorMessage.className = "form-error game-form-error"' in games
|
||||||
@@ -71,6 +81,107 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在():
|
|||||||
assert "@media (max-width: 700px)" in styles
|
assert "@media (max-width: 700px)" in styles
|
||||||
|
|
||||||
|
|
||||||
|
def test_v121_工具箱分类与响应式函数探索器资源完整():
|
||||||
|
template = (settings.BASE_DIR / "templates" / "index.html").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
|
||||||
|
toolbox = (STATIC_ROOT / "js" / "toolbox.js").read_text(encoding="utf-8")
|
||||||
|
graph = (STATIC_ROOT / "js" / "graph.js").read_text(encoding="utf-8")
|
||||||
|
styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert template.count("js/graph.js") == 1
|
||||||
|
assert 'data-tool="mental"' not in template
|
||||||
|
assert 'class="tool-groups"' in template
|
||||||
|
assert 'data-calc-category="calculus"' in template
|
||||||
|
assert 'value="solve_system"' in template
|
||||||
|
assert 'value="matrix_eigenvalues"' in template
|
||||||
|
assert 'id="graph-function-list"' in template
|
||||||
|
assert 'id="graph-x-min"' in template
|
||||||
|
assert 'id="graph-y-max"' in template
|
||||||
|
assert 'id="graph-auto-fit"' in template
|
||||||
|
assert 'id="graph-expression"' not in template
|
||||||
|
assert 'id="graph-range"' not in template
|
||||||
|
|
||||||
|
assert "function setCalculatorCategory(" in toolbox
|
||||||
|
assert "window.HuluGraph?.init()" in toolbox
|
||||||
|
assert "drawGraph" not in toolbox
|
||||||
|
assert 'tool === "mental"' not in toolbox
|
||||||
|
assert "drawGraph" not in app
|
||||||
|
assert 'tool === "mental"' not in app
|
||||||
|
assert "function addFunction(" in graph
|
||||||
|
assert "function drawAxes(" in graph
|
||||||
|
assert "function niceStep(" in graph
|
||||||
|
assert "window.devicePixelRatio" in graph
|
||||||
|
assert '"pointerdown"' in graph
|
||||||
|
assert '"wheel"' in graph
|
||||||
|
assert "ResizeObserver" in graph
|
||||||
|
assert "最多同时绘制 8 条曲线" in graph
|
||||||
|
|
||||||
|
assert ".tool-groups" in styles
|
||||||
|
assert ".calc-category-tabs" in styles
|
||||||
|
assert ".graph-function-row" in styles
|
||||||
|
assert ".graph-stage" in styles
|
||||||
|
assert "aspect-ratio: 16 / 10" in styles
|
||||||
|
assert "touch-action: none" 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_latex_长公式不会挤走保存按钮():
|
||||||
|
styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "grid-template-rows: auto minmax(0, 1fr) auto" in styles
|
||||||
|
assert "#latex-preview { align-self: center; width: 100%; min-width: 0" in styles
|
||||||
|
assert ".preview-pane button { align-self: end; justify-self: end" in styles
|
||||||
|
|
||||||
|
|
||||||
|
def test_rating_历史和登录后档案立即刷新():
|
||||||
|
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
|
||||||
|
app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
|
||||||
|
realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'id="home-match-history"' in template
|
||||||
|
assert "function renderMatchHistory(root, matches)" in app
|
||||||
|
assert "function loadHomeMatchHistory()" in app
|
||||||
|
assert '$("#view-profile").classList.contains("active") ? loadProfile() : null' in app
|
||||||
|
assert "state.user.rating = match.result.rating_after" in realtime
|
||||||
|
assert "updateUserUI();" in realtime
|
||||||
|
|
||||||
|
|
||||||
|
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_前端资源存在():
|
def test_realtime_match_联机码与_websocket_前端资源存在():
|
||||||
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
|
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
|
||||||
realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8")
|
realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8")
|
||||||
@@ -84,3 +195,43 @@ def test_realtime_match_联机码与_websocket_前端资源存在():
|
|||||||
assert "Idempotency-Key" in realtime
|
assert "Idempotency-Key" in realtime
|
||||||
assert ".challenge-panel" in styles
|
assert ".challenge-panel" in styles
|
||||||
assert ".realtime-progress-panel" in styles
|
assert ".realtime-progress-panel" in styles
|
||||||
|
|
||||||
|
|
||||||
|
def test_contest_统一玩家池并提供三种实时玩法():
|
||||||
|
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
|
||||||
|
app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
|
||||||
|
games = (STATIC_ROOT / "js" / "games.js").read_text(encoding="utf-8")
|
||||||
|
realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'id="track-switch"' not in template
|
||||||
|
assert 'data-match-mode="quiz"' in template
|
||||||
|
assert 'data-match-mode="sudoku"' in template
|
||||||
|
assert 'data-match-mode="twenty_four"' in template
|
||||||
|
assert "state.matchMode = button.dataset.matchMode" in app
|
||||||
|
assert "difficultySelect" not in games
|
||||||
|
assert "body: { game_kind: state.matchMode }" in realtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_math_life_使用独立界面印记面板且清理交叉点():
|
||||||
|
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
|
||||||
|
app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
|
||||||
|
styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'id="story-experience"' in template
|
||||||
|
assert 'id="story-mark-list"' in template
|
||||||
|
assert "数学人生交叉点" not in template
|
||||||
|
assert "<circle " not in template
|
||||||
|
assert '$("#story-experience").classList.remove("hidden")' in app
|
||||||
|
assert "function renderStoryMarks(run)" in app
|
||||||
|
assert "choice.special" in app
|
||||||
|
assert ".story-experience { position: fixed; inset: 0" in styles
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_公众号入口可复制并跳转微信():
|
||||||
|
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
|
||||||
|
app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'id="wechat-copy"' in template
|
||||||
|
assert 'href="weixin://"' in template
|
||||||
|
assert "function copyWechatName()" in app
|
||||||
|
assert 'navigator.clipboard.writeText(name)' in app
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_health_包含当前发布版本(client):
|
||||||
|
response = client.get("/health/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"status": "ok",
|
||||||
|
"database": "ok",
|
||||||
|
"version": settings.APP_VERSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_展示公众号入口与版本(client):
|
||||||
|
response = client.get("/")
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "公众号" in content
|
||||||
|
assert "葫芦数学" in content
|
||||||
|
assert f"Hulumath v{settings.APP_VERSION}" in content
|
||||||
|
assert 'href="weixin://"' in content
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
|
||||||
def home(request):
|
def home(request):
|
||||||
return render(request, "index.html")
|
return render(request, "index.html", {"app_version": settings.APP_VERSION})
|
||||||
|
|
||||||
|
|
||||||
def health(request):
|
def health(request):
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute("SELECT 1")
|
cursor.execute("SELECT 1")
|
||||||
cursor.fetchone()
|
cursor.fetchone()
|
||||||
return JsonResponse({"status": "ok", "database": "ok"})
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"database": "ok",
|
||||||
|
"version": settings.APP_VERSION,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ django_asgi_application = get_asgi_application()
|
|||||||
|
|
||||||
from common.consumers import HealthConsumer
|
from common.consumers import HealthConsumer
|
||||||
from contest.routing import websocket_urlpatterns
|
from contest.routing import websocket_urlpatterns
|
||||||
|
from toolbox.routing import websocket_urlpatterns as toolbox_websocket_urlpatterns
|
||||||
|
|
||||||
application = ProtocolTypeRouter(
|
application = ProtocolTypeRouter(
|
||||||
{
|
{
|
||||||
@@ -20,6 +21,7 @@ application = ProtocolTypeRouter(
|
|||||||
[
|
[
|
||||||
path("ws/health/", HealthConsumer.as_asgi()),
|
path("ws/health/", HealthConsumer.as_asgi()),
|
||||||
*websocket_urlpatterns,
|
*websocket_urlpatterns,
|
||||||
|
*toolbox_websocket_urlpatterns,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.core.exceptions import ImproperlyConfigured
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
PROJECT_ROOT = BASE_DIR.parent
|
PROJECT_ROOT = BASE_DIR.parent
|
||||||
|
APP_VERSION = os.getenv("APP_VERSION", "1.2.1")
|
||||||
|
|
||||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-before-production")
|
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-before-production")
|
||||||
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class ContestAdmin(admin.ModelAdmin):
|
|||||||
class ContestAttemptAdmin(admin.ModelAdmin):
|
class ContestAttemptAdmin(admin.ModelAdmin):
|
||||||
list_display = ("id", "user", "contest", "status", "score", "duration_ms", "started_at")
|
list_display = ("id", "user", "contest", "status", "score", "duration_ms", "started_at")
|
||||||
list_filter = ("status", "contest__kind", "contest__track")
|
list_filter = ("status", "contest__kind", "contest__track")
|
||||||
readonly_fields = ("started_at", "submitted_at")
|
readonly_fields = ("question_order", "started_at", "submitted_at")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CheatFlag)
|
@admin.register(CheatFlag)
|
||||||
@@ -72,13 +72,14 @@ class RealtimeMatchAdmin(admin.ModelAdmin):
|
|||||||
"id",
|
"id",
|
||||||
"contest",
|
"contest",
|
||||||
"match_type",
|
"match_type",
|
||||||
|
"game_kind",
|
||||||
"challenge_code",
|
"challenge_code",
|
||||||
"player_one",
|
"player_one",
|
||||||
"player_two",
|
"player_two",
|
||||||
"status",
|
"status",
|
||||||
"created_at",
|
"created_at",
|
||||||
)
|
)
|
||||||
list_filter = ("match_type", "status", "contest__track")
|
list_filter = ("match_type", "game_kind", "status", "contest__track")
|
||||||
search_fields = (
|
search_fields = (
|
||||||
"challenge_code",
|
"challenge_code",
|
||||||
"player_one__username",
|
"player_one__username",
|
||||||
@@ -94,6 +95,7 @@ class MathGameAttemptAdmin(admin.ModelAdmin):
|
|||||||
"id",
|
"id",
|
||||||
"user",
|
"user",
|
||||||
"kind",
|
"kind",
|
||||||
|
"match",
|
||||||
"difficulty",
|
"difficulty",
|
||||||
"status",
|
"status",
|
||||||
"score",
|
"score",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ def _grid_from_text(value):
|
|||||||
def game_payload(attempt):
|
def game_payload(attempt):
|
||||||
return {
|
return {
|
||||||
"attempt_id": attempt.id,
|
"attempt_id": attempt.id,
|
||||||
|
"match_id": attempt.match_id,
|
||||||
"kind": attempt.kind,
|
"kind": attempt.kind,
|
||||||
"difficulty": attempt.difficulty,
|
"difficulty": attempt.difficulty,
|
||||||
"status": attempt.status,
|
"status": attempt.status,
|
||||||
@@ -68,7 +69,7 @@ def game_payload(attempt):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def start_game(user, kind, difficulty):
|
def build_game(kind, difficulty=MathGameAttempt.Difficulty.STANDARD):
|
||||||
if kind not in MathGameAttempt.Kind.values:
|
if kind not in MathGameAttempt.Kind.values:
|
||||||
raise ValidationError({"kind": "不支持的数学玩法"})
|
raise ValidationError({"kind": "不支持的数学玩法"})
|
||||||
if difficulty not in MathGameAttempt.Difficulty.values:
|
if difficulty not in MathGameAttempt.Difficulty.values:
|
||||||
@@ -82,6 +83,11 @@ def start_game(user, kind, difficulty):
|
|||||||
secrets.SystemRandom().shuffle(numbers)
|
secrets.SystemRandom().shuffle(numbers)
|
||||||
puzzle = {"numbers": numbers}
|
puzzle = {"numbers": numbers}
|
||||||
solution = {"target": 24}
|
solution = {"target": 24}
|
||||||
|
return puzzle, solution
|
||||||
|
|
||||||
|
|
||||||
|
def start_game(user, kind, difficulty=MathGameAttempt.Difficulty.STANDARD):
|
||||||
|
puzzle, solution = build_game(kind, difficulty)
|
||||||
attempt = MathGameAttempt.objects.create(
|
attempt = MathGameAttempt.objects.create(
|
||||||
user=user,
|
user=user,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
@@ -164,10 +170,14 @@ def _validate_sudoku_grid(raw_grid, puzzle, solution):
|
|||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def submit_game(user, attempt_id, submission, submission_key):
|
def submit_game(user, attempt_id, submission, submission_key):
|
||||||
attempt = MathGameAttempt.objects.select_for_update().get(id=attempt_id, user=user)
|
attempt = MathGameAttempt.objects.select_for_update().get(id=attempt_id, user=user)
|
||||||
if attempt.status == MathGameAttempt.Status.COMPLETED:
|
if attempt.status != MathGameAttempt.Status.ACTIVE:
|
||||||
if submission_key and attempt.submission_key == submission_key:
|
if (
|
||||||
|
attempt.status == MathGameAttempt.Status.COMPLETED
|
||||||
|
and submission_key
|
||||||
|
and attempt.submission_key == submission_key
|
||||||
|
):
|
||||||
return game_payload(attempt)
|
return game_payload(attempt)
|
||||||
raise ValidationError("这局游戏已经完成")
|
raise ValidationError("这局游戏已经结束")
|
||||||
if not submission_key:
|
if not submission_key:
|
||||||
raise ValidationError({"Idempotency-Key": "提交必须提供幂等键"})
|
raise ValidationError({"Idempotency-Key": "提交必须提供幂等键"})
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,149 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from contest.models import Contest, ContestQuestion, Question, QuestionVersion
|
from contest.models import Contest, ContestQuestion, Question, QuestionVersion
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
# 一元一次方程,补足稳定的 200 题池
|
||||||
|
for solution in range(2, 60):
|
||||||
|
if idx >= 200:
|
||||||
|
break
|
||||||
|
coefficient = solution % 7 + 2
|
||||||
|
offset = solution % 11 + 1
|
||||||
|
total = coefficient * solution + offset
|
||||||
|
questions.append(
|
||||||
|
(
|
||||||
|
f"a-auto-linear-{idx:04d}",
|
||||||
|
f"{coefficient}x + {offset} = {total},求 x",
|
||||||
|
str(solution),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
idx += 1
|
||||||
|
return questions[:200]
|
||||||
|
|
||||||
|
|
||||||
QUESTIONS = {
|
QUESTIONS = {
|
||||||
Question.Track.BEGINNER: [
|
Question.Track.BEGINNER: _generate_beginner(),
|
||||||
("b-12-plus-19", "12 + 19", "31"),
|
Question.Track.STANDARD: _generate_standard(),
|
||||||
("b-8-times-7", "8 × 7", "56"),
|
Question.Track.ADVANCED: _generate_advanced(),
|
||||||
("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"),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "创建首批分层题目、实时 1v1、每日赛和单人练习"
|
help = "创建分层题库(入门/标准/进阶)和实时 1v1、每日赛、单人练习"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
versions = {}
|
versions = {}
|
||||||
@@ -93,6 +208,8 @@ class Command(BaseCommand):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
ContestQuestion.objects.filter(contest=contest).delete()
|
ContestQuestion.objects.filter(contest=contest).delete()
|
||||||
|
pool = list(versions[track])
|
||||||
|
random.shuffle(pool)
|
||||||
ContestQuestion.objects.bulk_create(
|
ContestQuestion.objects.bulk_create(
|
||||||
[
|
[
|
||||||
ContestQuestion(
|
ContestQuestion(
|
||||||
@@ -101,11 +218,14 @@ class Command(BaseCommand):
|
|||||||
order=index,
|
order=index,
|
||||||
points=100,
|
points=100,
|
||||||
)
|
)
|
||||||
for index, version in enumerate(versions[track], start=1)
|
for index, version in enumerate(pool, start=1)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
total = sum(map(len, QUESTIONS.values()))
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
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)} 场比赛"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 4.2.23 on 2026-08-09 16:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contest', '0004_remove_realtimematch_matchmaking_lookup_idx_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contestattempt',
|
||||||
|
name='question_order',
|
||||||
|
field=models.JSONField(blank=True, default=list),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='mathgameattempt',
|
||||||
|
name='match',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='game_attempts', to='contest.realtimematch'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='realtimematch',
|
||||||
|
name='game_kind',
|
||||||
|
field=models.CharField(choices=[('quiz', '口算竞速'), ('sudoku', '数独 Timerun'), ('twenty_four', '24 点竞速')], default='quiz', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='mathgameattempt',
|
||||||
|
constraint=models.UniqueConstraint(fields=('match', 'user'), name='unique_user_realtime_math_game'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def expand_question_pools(apps, schema_editor):
|
||||||
|
Contest = apps.get_model("contest", "Contest")
|
||||||
|
ContestQuestion = apps.get_model("contest", "ContestQuestion")
|
||||||
|
QuestionVersion = apps.get_model("contest", "QuestionVersion")
|
||||||
|
|
||||||
|
for contest in Contest.objects.all().iterator():
|
||||||
|
existing_version_ids = set(
|
||||||
|
ContestQuestion.objects.filter(contest=contest).values_list(
|
||||||
|
"question_version_id",
|
||||||
|
flat=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
latest_versions = {}
|
||||||
|
versions = QuestionVersion.objects.filter(
|
||||||
|
question__track=contest.track,
|
||||||
|
question__is_active=True,
|
||||||
|
).order_by("question_id", "-version")
|
||||||
|
for version in versions.iterator():
|
||||||
|
latest_versions.setdefault(version.question_id, version.id)
|
||||||
|
|
||||||
|
next_order = (
|
||||||
|
ContestQuestion.objects.filter(contest=contest)
|
||||||
|
.order_by("-order")
|
||||||
|
.values_list("order", flat=True)
|
||||||
|
.first()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
additions = []
|
||||||
|
for version_id in latest_versions.values():
|
||||||
|
if version_id in existing_version_ids:
|
||||||
|
continue
|
||||||
|
next_order += 1
|
||||||
|
additions.append(
|
||||||
|
ContestQuestion(
|
||||||
|
contest_id=contest.id,
|
||||||
|
question_version_id=version_id,
|
||||||
|
order=next_order,
|
||||||
|
points=100,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ContestQuestion.objects.bulk_create(additions, batch_size=500)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("contest", "0005_contestattempt_question_order_mathgameattempt_match_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
expand_question_pools,
|
||||||
|
migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 4.2.23 on 2026-08-09 16:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contest', '0006_expand_contest_question_pools'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name='realtimematch',
|
||||||
|
name='matchmaking_lookup_idx',
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='realtimematch',
|
||||||
|
index=models.Index(fields=['contest', 'match_type', 'game_kind', 'status', 'player_one_rating', 'created_at'], name='matchmaking_mode_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def fill_advanced_pool(apps, schema_editor):
|
||||||
|
Contest = apps.get_model("contest", "Contest")
|
||||||
|
ContestQuestion = apps.get_model("contest", "ContestQuestion")
|
||||||
|
Question = apps.get_model("contest", "Question")
|
||||||
|
QuestionVersion = apps.get_model("contest", "QuestionVersion")
|
||||||
|
|
||||||
|
versions = []
|
||||||
|
for offset, solution in enumerate(range(2, 22), start=180):
|
||||||
|
coefficient = solution % 7 + 2
|
||||||
|
constant = solution % 11 + 1
|
||||||
|
total = coefficient * solution + constant
|
||||||
|
question, _ = Question.objects.update_or_create(
|
||||||
|
slug=f"a-auto-linear-{offset:04d}",
|
||||||
|
defaults={
|
||||||
|
"track": "advanced",
|
||||||
|
"tags": ["口算"],
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
version, _ = QuestionVersion.objects.update_or_create(
|
||||||
|
question=question,
|
||||||
|
version=1,
|
||||||
|
defaults={
|
||||||
|
"prompt": f"{coefficient}x + {constant} = {total},求 x",
|
||||||
|
"answer": str(solution),
|
||||||
|
"explanation": f"答案为 {solution}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
versions.append(version)
|
||||||
|
|
||||||
|
for contest in Contest.objects.filter(track="advanced").iterator():
|
||||||
|
existing_ids = set(
|
||||||
|
ContestQuestion.objects.filter(contest=contest).values_list(
|
||||||
|
"question_version_id",
|
||||||
|
flat=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
next_order = (
|
||||||
|
ContestQuestion.objects.filter(contest=contest)
|
||||||
|
.order_by("-order")
|
||||||
|
.values_list("order", flat=True)
|
||||||
|
.first()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
additions = []
|
||||||
|
for version in versions:
|
||||||
|
if version.id in existing_ids:
|
||||||
|
continue
|
||||||
|
next_order += 1
|
||||||
|
additions.append(
|
||||||
|
ContestQuestion(
|
||||||
|
contest_id=contest.id,
|
||||||
|
question_version_id=version.id,
|
||||||
|
order=next_order,
|
||||||
|
points=100,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ContestQuestion.objects.bulk_create(additions)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("contest", "0007_remove_realtimematch_matchmaking_lookup_idx_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
fill_advanced_pool,
|
||||||
|
migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -87,6 +87,11 @@ class RealtimeMatch(models.Model):
|
|||||||
COMPLETED = "completed", "已完成"
|
COMPLETED = "completed", "已完成"
|
||||||
CANCELLED = "cancelled", "已取消"
|
CANCELLED = "cancelled", "已取消"
|
||||||
|
|
||||||
|
class GameKind(models.TextChoices):
|
||||||
|
QUIZ = "quiz", "口算竞速"
|
||||||
|
SUDOKU = "sudoku", "数独 Timerun"
|
||||||
|
TWENTY_FOUR = "twenty_four", "24 点竞速"
|
||||||
|
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
contest = models.ForeignKey(Contest, on_delete=models.PROTECT)
|
contest = models.ForeignKey(Contest, on_delete=models.PROTECT)
|
||||||
match_type = models.CharField(
|
match_type = models.CharField(
|
||||||
@@ -95,6 +100,11 @@ class RealtimeMatch(models.Model):
|
|||||||
default=MatchType.RANDOM,
|
default=MatchType.RANDOM,
|
||||||
)
|
)
|
||||||
challenge_code = models.CharField(max_length=8, unique=True, null=True, blank=True)
|
challenge_code = models.CharField(max_length=8, unique=True, null=True, blank=True)
|
||||||
|
game_kind = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=GameKind.choices,
|
||||||
|
default=GameKind.QUIZ,
|
||||||
|
)
|
||||||
player_one = models.ForeignKey(
|
player_one = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="matches_as_player_one"
|
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="matches_as_player_one"
|
||||||
)
|
)
|
||||||
@@ -126,11 +136,12 @@ class RealtimeMatch(models.Model):
|
|||||||
fields=(
|
fields=(
|
||||||
"contest",
|
"contest",
|
||||||
"match_type",
|
"match_type",
|
||||||
|
"game_kind",
|
||||||
"status",
|
"status",
|
||||||
"player_one_rating",
|
"player_one_rating",
|
||||||
"created_at",
|
"created_at",
|
||||||
),
|
),
|
||||||
name="matchmaking_lookup_idx",
|
name="matchmaking_mode_idx",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -152,6 +163,7 @@ class ContestAttempt(models.Model):
|
|||||||
correct_count = models.PositiveIntegerField(default=0)
|
correct_count = models.PositiveIntegerField(default=0)
|
||||||
answer_count = models.PositiveIntegerField(default=0)
|
answer_count = models.PositiveIntegerField(default=0)
|
||||||
duration_ms = models.PositiveIntegerField(default=0)
|
duration_ms = models.PositiveIntegerField(default=0)
|
||||||
|
question_order = models.JSONField(default=list, blank=True)
|
||||||
submission_key = models.CharField(max_length=80, null=True, blank=True)
|
submission_key = models.CharField(max_length=80, null=True, blank=True)
|
||||||
started_at = models.DateTimeField(auto_now_add=True)
|
started_at = models.DateTimeField(auto_now_add=True)
|
||||||
submitted_at = models.DateTimeField(null=True, blank=True)
|
submitted_at = models.DateTimeField(null=True, blank=True)
|
||||||
@@ -232,6 +244,13 @@ class MathGameAttempt(models.Model):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="math_game_attempts",
|
related_name="math_game_attempts",
|
||||||
)
|
)
|
||||||
|
match = models.ForeignKey(
|
||||||
|
RealtimeMatch,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="game_attempts",
|
||||||
|
)
|
||||||
kind = models.CharField(max_length=20, choices=Kind.choices)
|
kind = models.CharField(max_length=20, choices=Kind.choices)
|
||||||
difficulty = models.CharField(
|
difficulty = models.CharField(
|
||||||
max_length=16,
|
max_length=16,
|
||||||
@@ -254,7 +273,11 @@ class MathGameAttempt(models.Model):
|
|||||||
models.UniqueConstraint(
|
models.UniqueConstraint(
|
||||||
fields=("user", "submission_key"),
|
fields=("user", "submission_key"),
|
||||||
name="unique_user_math_game_submission",
|
name="unique_user_math_game_submission",
|
||||||
)
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("match", "user"),
|
||||||
|
name="unique_user_realtime_math_game",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(
|
models.Index(
|
||||||
|
|||||||
+248
-61
@@ -16,12 +16,14 @@ from .models import (
|
|||||||
Contest,
|
Contest,
|
||||||
ContestAnswer,
|
ContestAnswer,
|
||||||
ContestAttempt,
|
ContestAttempt,
|
||||||
|
MathGameAttempt,
|
||||||
RatingHistory,
|
RatingHistory,
|
||||||
RealtimeMatch,
|
RealtimeMatch,
|
||||||
)
|
)
|
||||||
|
|
||||||
CHALLENGE_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
CHALLENGE_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
WAITING_MATCH_TTL = timedelta(minutes=10)
|
WAITING_MATCH_TTL = timedelta(minutes=10)
|
||||||
|
ATTEMPT_QUESTION_COUNT = 8
|
||||||
|
|
||||||
|
|
||||||
def _broadcast_match(match_id, reason):
|
def _broadcast_match(match_id, reason):
|
||||||
@@ -54,6 +56,13 @@ def _validate_realtime_contest(contest):
|
|||||||
raise ValidationError("实时比赛不可用")
|
raise ValidationError("实时比赛不可用")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_game_kind(game_kind):
|
||||||
|
value = str(game_kind or RealtimeMatch.GameKind.QUIZ)
|
||||||
|
if value not in RealtimeMatch.GameKind.values:
|
||||||
|
raise ValidationError({"game_kind": "不支持的实时比赛玩法"})
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _cancel_expired_waiting_matches():
|
def _cancel_expired_waiting_matches():
|
||||||
RealtimeMatch.objects.filter(status=RealtimeMatch.Status.WAITING).filter(
|
RealtimeMatch.objects.filter(status=RealtimeMatch.Status.WAITING).filter(
|
||||||
Q(expires_at__isnull=True) | Q(expires_at__lte=timezone.now())
|
Q(expires_at__isnull=True) | Q(expires_at__lte=timezone.now())
|
||||||
@@ -71,13 +80,13 @@ def _active_match_for(user):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _cancel_other_waiting_matches(user, match_type):
|
def _cancel_other_waiting_matches(user, match_type, game_kind):
|
||||||
matches = list(
|
matches = list(
|
||||||
RealtimeMatch.objects.filter(
|
RealtimeMatch.objects.filter(
|
||||||
player_one=user,
|
player_one=user,
|
||||||
status=RealtimeMatch.Status.WAITING,
|
status=RealtimeMatch.Status.WAITING,
|
||||||
)
|
)
|
||||||
.exclude(match_type=match_type)
|
.exclude(match_type=match_type, game_kind=game_kind)
|
||||||
.values_list("id", flat=True)
|
.values_list("id", flat=True)
|
||||||
)
|
)
|
||||||
if matches:
|
if matches:
|
||||||
@@ -88,7 +97,29 @@ def _cancel_other_waiting_matches(user, match_type):
|
|||||||
notify_match_on_commit(match_id, "cancelled")
|
notify_match_on_commit(match_id, "cancelled")
|
||||||
|
|
||||||
|
|
||||||
|
def _select_question_order(contest):
|
||||||
|
question_ids = list(
|
||||||
|
contest.contest_questions.filter(question_version__question__is_active=True)
|
||||||
|
.order_by("id")
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
if not question_ids:
|
||||||
|
raise ValidationError("比赛题库为空")
|
||||||
|
count = min(ATTEMPT_QUESTION_COUNT, len(question_ids))
|
||||||
|
return secrets.SystemRandom().sample(question_ids, count)
|
||||||
|
|
||||||
|
|
||||||
|
def _attempt_questions(attempt):
|
||||||
|
queryset = attempt.contest.contest_questions.select_related("question_version")
|
||||||
|
if attempt.question_order:
|
||||||
|
items = {item.id: item for item in queryset.filter(id__in=attempt.question_order)}
|
||||||
|
return [items[item_id] for item_id in attempt.question_order if item_id in items]
|
||||||
|
return list(queryset.all())
|
||||||
|
|
||||||
|
|
||||||
def _activate_match(match, user):
|
def _activate_match(match, user):
|
||||||
|
from .game_services import build_game
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
match.player_two = user
|
match.player_two = user
|
||||||
match.player_two_rating = user.rating
|
match.player_two_rating = user.rating
|
||||||
@@ -97,13 +128,49 @@ def _activate_match(match, user):
|
|||||||
match.save(
|
match.save(
|
||||||
update_fields=["player_two", "player_two_rating", "status", "started_at"]
|
update_fields=["player_two", "player_two_rating", "status", "started_at"]
|
||||||
)
|
)
|
||||||
ContestAttempt.objects.bulk_create(
|
if match.game_kind == RealtimeMatch.GameKind.QUIZ:
|
||||||
[
|
question_order = _select_question_order(match.contest)
|
||||||
ContestAttempt(contest=match.contest, user=match.player_one, match=match),
|
ContestAttempt.objects.bulk_create(
|
||||||
ContestAttempt(contest=match.contest, user=user, match=match),
|
[
|
||||||
]
|
ContestAttempt(
|
||||||
)
|
contest=match.contest,
|
||||||
match.attempts.update(started_at=now)
|
user=match.player_one,
|
||||||
|
match=match,
|
||||||
|
question_order=question_order,
|
||||||
|
),
|
||||||
|
ContestAttempt(
|
||||||
|
contest=match.contest,
|
||||||
|
user=user,
|
||||||
|
match=match,
|
||||||
|
question_order=question_order,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
match.attempts.update(started_at=now)
|
||||||
|
else:
|
||||||
|
puzzle, solution = build_game(
|
||||||
|
match.game_kind,
|
||||||
|
MathGameAttempt.Difficulty.STANDARD,
|
||||||
|
)
|
||||||
|
MathGameAttempt.objects.bulk_create(
|
||||||
|
[
|
||||||
|
MathGameAttempt(
|
||||||
|
user=match.player_one,
|
||||||
|
match=match,
|
||||||
|
kind=match.game_kind,
|
||||||
|
puzzle=puzzle,
|
||||||
|
solution=solution,
|
||||||
|
),
|
||||||
|
MathGameAttempt(
|
||||||
|
user=user,
|
||||||
|
match=match,
|
||||||
|
kind=match.game_kind,
|
||||||
|
puzzle=puzzle,
|
||||||
|
solution=solution,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
match.game_attempts.update(started_at=now)
|
||||||
notify_match_on_commit(match.id, "matched")
|
notify_match_on_commit(match.id, "matched")
|
||||||
return match
|
return match
|
||||||
|
|
||||||
@@ -119,9 +186,9 @@ def normalize_answer(value):
|
|||||||
def attempt_payload(attempt, include_results=False):
|
def attempt_payload(attempt, include_results=False):
|
||||||
questions = []
|
questions = []
|
||||||
answers = {answer.contest_question_id: answer for answer in attempt.answers.all()}
|
answers = {answer.contest_question_id: answer for answer in attempt.answers.all()}
|
||||||
for item in attempt.contest.contest_questions.select_related("question_version").all():
|
for display_order, item in enumerate(_attempt_questions(attempt), start=1):
|
||||||
question = {
|
question = {
|
||||||
"order": item.order,
|
"order": display_order,
|
||||||
"prompt": item.question_version.prompt,
|
"prompt": item.question_version.prompt,
|
||||||
"metadata": item.question_version.metadata,
|
"metadata": item.question_version.metadata,
|
||||||
"points": item.points,
|
"points": item.points,
|
||||||
@@ -169,7 +236,11 @@ def start_attempt(user, contest):
|
|||||||
existing,
|
existing,
|
||||||
include_results=existing.status != ContestAttempt.Status.ACTIVE,
|
include_results=existing.status != ContestAttempt.Status.ACTIVE,
|
||||||
)
|
)
|
||||||
attempt = ContestAttempt.objects.create(contest=contest, user=user)
|
attempt = ContestAttempt.objects.create(
|
||||||
|
contest=contest,
|
||||||
|
user=user,
|
||||||
|
question_order=_select_question_order(contest),
|
||||||
|
)
|
||||||
return attempt_payload(attempt)
|
return attempt_payload(attempt)
|
||||||
|
|
||||||
|
|
||||||
@@ -193,9 +264,7 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key):
|
|||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
duration_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000))
|
duration_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000))
|
||||||
limit_ms = attempt.contest.duration_seconds * 1000
|
limit_ms = attempt.contest.duration_seconds * 1000
|
||||||
items = list(
|
items = _attempt_questions(attempt)
|
||||||
attempt.contest.contest_questions.select_related("question_version").all()
|
|
||||||
)
|
|
||||||
if not isinstance(raw_answers, list):
|
if not isinstance(raw_answers, list):
|
||||||
raise ValidationError({"answers": "答案必须是数组"})
|
raise ValidationError({"answers": "答案必须是数组"})
|
||||||
by_order = {}
|
by_order = {}
|
||||||
@@ -209,8 +278,8 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key):
|
|||||||
raise ValidationError({"answers": "答案题号无效或重复"}) from exc
|
raise ValidationError({"answers": "答案题号无效或重复"}) from exc
|
||||||
score = 0
|
score = 0
|
||||||
correct_count = 0
|
correct_count = 0
|
||||||
for contest_question in items:
|
for display_order, contest_question in enumerate(items, start=1):
|
||||||
submitted = str(by_order.get(contest_question.order, ""))[:200]
|
submitted = str(by_order.get(display_order, ""))[:200]
|
||||||
correct = normalize_answer(submitted) == normalize_answer(
|
correct = normalize_answer(submitted) == normalize_answer(
|
||||||
contest_question.question_version.answer
|
contest_question.question_version.answer
|
||||||
)
|
)
|
||||||
@@ -260,20 +329,26 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key):
|
|||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def find_match(user, contest):
|
def find_match(user, contest, game_kind=RealtimeMatch.GameKind.QUIZ):
|
||||||
_validate_realtime_contest(contest)
|
_validate_realtime_contest(contest)
|
||||||
|
game_kind = _validate_game_kind(game_kind)
|
||||||
_cancel_expired_waiting_matches()
|
_cancel_expired_waiting_matches()
|
||||||
active = _active_match_for(user)
|
active = _active_match_for(user)
|
||||||
if active:
|
if active:
|
||||||
if active.contest_id == contest.id:
|
if active.contest_id == contest.id and active.game_kind == game_kind:
|
||||||
return active
|
return active
|
||||||
raise ValidationError("你已有一场进行中的实时比赛")
|
raise ValidationError("你已有一场进行中的实时比赛")
|
||||||
_cancel_other_waiting_matches(user, RealtimeMatch.MatchType.RANDOM)
|
_cancel_other_waiting_matches(
|
||||||
|
user,
|
||||||
|
RealtimeMatch.MatchType.RANDOM,
|
||||||
|
game_kind,
|
||||||
|
)
|
||||||
existing = (
|
existing = (
|
||||||
RealtimeMatch.objects.filter(
|
RealtimeMatch.objects.filter(
|
||||||
Q(player_one=user) | Q(player_two=user),
|
Q(player_one=user) | Q(player_two=user),
|
||||||
contest=contest,
|
contest=contest,
|
||||||
match_type=RealtimeMatch.MatchType.RANDOM,
|
match_type=RealtimeMatch.MatchType.RANDOM,
|
||||||
|
game_kind=game_kind,
|
||||||
status__in=[RealtimeMatch.Status.WAITING, RealtimeMatch.Status.ACTIVE],
|
status__in=[RealtimeMatch.Status.WAITING, RealtimeMatch.Status.ACTIVE],
|
||||||
).filter(Q(status=RealtimeMatch.Status.ACTIVE) | Q(expires_at__gt=timezone.now()))
|
).filter(Q(status=RealtimeMatch.Status.ACTIVE) | Q(expires_at__gt=timezone.now()))
|
||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
@@ -287,6 +362,7 @@ def find_match(user, contest):
|
|||||||
.filter(
|
.filter(
|
||||||
contest=contest,
|
contest=contest,
|
||||||
match_type=RealtimeMatch.MatchType.RANDOM,
|
match_type=RealtimeMatch.MatchType.RANDOM,
|
||||||
|
game_kind=game_kind,
|
||||||
status=RealtimeMatch.Status.WAITING,
|
status=RealtimeMatch.Status.WAITING,
|
||||||
expires_at__gt=timezone.now(),
|
expires_at__gt=timezone.now(),
|
||||||
player_one_rating__gte=max(0, user.rating - 300),
|
player_one_rating__gte=max(0, user.rating - 300),
|
||||||
@@ -300,6 +376,7 @@ def find_match(user, contest):
|
|||||||
return RealtimeMatch.objects.create(
|
return RealtimeMatch.objects.create(
|
||||||
contest=contest,
|
contest=contest,
|
||||||
match_type=RealtimeMatch.MatchType.RANDOM,
|
match_type=RealtimeMatch.MatchType.RANDOM,
|
||||||
|
game_kind=game_kind,
|
||||||
player_one=user,
|
player_one=user,
|
||||||
player_one_rating=user.rating,
|
player_one_rating=user.rating,
|
||||||
expires_at=timezone.now() + WAITING_MATCH_TTL,
|
expires_at=timezone.now() + WAITING_MATCH_TTL,
|
||||||
@@ -309,18 +386,24 @@ def find_match(user, contest):
|
|||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create_challenge(user, contest):
|
def create_challenge(user, contest, game_kind=RealtimeMatch.GameKind.QUIZ):
|
||||||
_validate_realtime_contest(contest)
|
_validate_realtime_contest(contest)
|
||||||
|
game_kind = _validate_game_kind(game_kind)
|
||||||
_cancel_expired_waiting_matches()
|
_cancel_expired_waiting_matches()
|
||||||
active = _active_match_for(user)
|
active = _active_match_for(user)
|
||||||
if active:
|
if active:
|
||||||
raise ValidationError("你已有一场进行中的实时比赛")
|
raise ValidationError("你已有一场进行中的实时比赛")
|
||||||
_cancel_other_waiting_matches(user, RealtimeMatch.MatchType.CHALLENGE)
|
_cancel_other_waiting_matches(
|
||||||
|
user,
|
||||||
|
RealtimeMatch.MatchType.CHALLENGE,
|
||||||
|
game_kind,
|
||||||
|
)
|
||||||
existing = (
|
existing = (
|
||||||
RealtimeMatch.objects.filter(
|
RealtimeMatch.objects.filter(
|
||||||
player_one=user,
|
player_one=user,
|
||||||
contest=contest,
|
contest=contest,
|
||||||
match_type=RealtimeMatch.MatchType.CHALLENGE,
|
match_type=RealtimeMatch.MatchType.CHALLENGE,
|
||||||
|
game_kind=game_kind,
|
||||||
status=RealtimeMatch.Status.WAITING,
|
status=RealtimeMatch.Status.WAITING,
|
||||||
expires_at__gt=timezone.now(),
|
expires_at__gt=timezone.now(),
|
||||||
)
|
)
|
||||||
@@ -332,6 +415,7 @@ def create_challenge(user, contest):
|
|||||||
return RealtimeMatch.objects.create(
|
return RealtimeMatch.objects.create(
|
||||||
contest=contest,
|
contest=contest,
|
||||||
match_type=RealtimeMatch.MatchType.CHALLENGE,
|
match_type=RealtimeMatch.MatchType.CHALLENGE,
|
||||||
|
game_kind=game_kind,
|
||||||
challenge_code=_new_challenge_code(),
|
challenge_code=_new_challenge_code(),
|
||||||
player_one=user,
|
player_one=user,
|
||||||
player_one_rating=user.rating,
|
player_one_rating=user.rating,
|
||||||
@@ -396,11 +480,19 @@ def cancel_waiting_match(user, match_id):
|
|||||||
|
|
||||||
|
|
||||||
def match_payload(match, user):
|
def match_payload(match, user):
|
||||||
|
from .game_services import game_payload
|
||||||
|
|
||||||
reveal_results = match.status == RealtimeMatch.Status.COMPLETED
|
reveal_results = match.status == RealtimeMatch.Status.COMPLETED
|
||||||
attempts = {
|
if match.game_kind == RealtimeMatch.GameKind.QUIZ:
|
||||||
attempt.user_id: attempt
|
attempts = {
|
||||||
for attempt in match.attempts.select_related("user", "contest").all()
|
attempt.user_id: attempt
|
||||||
}
|
for attempt in match.attempts.select_related("user", "contest").all()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
attempts = {
|
||||||
|
attempt.user_id: attempt
|
||||||
|
for attempt in match.game_attempts.select_related("user").all()
|
||||||
|
}
|
||||||
attempt = attempts.get(user.id)
|
attempt = attempts.get(user.id)
|
||||||
opponent = match.player_two if match.player_one_id == user.id else match.player_one
|
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
|
opponent_attempt = attempts.get(opponent.id) if opponent else None
|
||||||
@@ -409,9 +501,18 @@ def match_payload(match, user):
|
|||||||
if reveal_results
|
if reveal_results
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
attempt_data = None
|
||||||
|
if attempt:
|
||||||
|
attempt_data = (
|
||||||
|
attempt_payload(attempt, include_results=reveal_results)
|
||||||
|
if match.game_kind == RealtimeMatch.GameKind.QUIZ
|
||||||
|
else game_payload(attempt)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"match_id": match.id,
|
"match_id": match.id,
|
||||||
"match_type": match.match_type,
|
"match_type": match.match_type,
|
||||||
|
"game_kind": match.game_kind,
|
||||||
|
"game_label": match.get_game_kind_display(),
|
||||||
"is_owner": match.player_one_id == user.id,
|
"is_owner": match.player_one_id == user.id,
|
||||||
"challenge_code": (
|
"challenge_code": (
|
||||||
match.challenge_code
|
match.challenge_code
|
||||||
@@ -420,7 +521,11 @@ def match_payload(match, user):
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"status": match.status,
|
"status": match.status,
|
||||||
"contest": match.contest.title,
|
"contest": (
|
||||||
|
match.contest.title
|
||||||
|
if match.game_kind == RealtimeMatch.GameKind.QUIZ
|
||||||
|
else match.get_game_kind_display()
|
||||||
|
),
|
||||||
"duration_seconds": match.contest.duration_seconds,
|
"duration_seconds": match.contest.duration_seconds,
|
||||||
"expires_at": match.expires_at,
|
"expires_at": match.expires_at,
|
||||||
"started_at": match.started_at,
|
"started_at": match.started_at,
|
||||||
@@ -432,6 +537,13 @@ def match_payload(match, user):
|
|||||||
"score": opponent_attempt.score if reveal_results and opponent_attempt else None,
|
"score": opponent_attempt.score if reveal_results and opponent_attempt else None,
|
||||||
"correct_count": (
|
"correct_count": (
|
||||||
opponent_attempt.correct_count
|
opponent_attempt.correct_count
|
||||||
|
if reveal_results
|
||||||
|
and opponent_attempt
|
||||||
|
and match.game_kind == RealtimeMatch.GameKind.QUIZ
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"duration_ms": (
|
||||||
|
opponent_attempt.duration_ms
|
||||||
if reveal_results and opponent_attempt
|
if reveal_results and opponent_attempt
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
@@ -439,11 +551,7 @@ def match_payload(match, user):
|
|||||||
if opponent
|
if opponent
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"attempt": (
|
"attempt": attempt_data,
|
||||||
attempt_payload(attempt, include_results=reveal_results)
|
|
||||||
if attempt
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"result": (
|
"result": (
|
||||||
{
|
{
|
||||||
"winner": (
|
"winner": (
|
||||||
@@ -481,13 +589,35 @@ def refresh_match_state(match_id):
|
|||||||
elif match.status == RealtimeMatch.Status.ACTIVE and match.started_at:
|
elif match.status == RealtimeMatch.Status.ACTIVE and match.started_at:
|
||||||
deadline = match.started_at + timedelta(seconds=match.contest.duration_seconds)
|
deadline = match.started_at + timedelta(seconds=match.contest.duration_seconds)
|
||||||
if timezone.now() >= deadline:
|
if timezone.now() >= deadline:
|
||||||
elapsed_ms = match.contest.duration_seconds * 1000
|
now = timezone.now()
|
||||||
match.attempts.filter(status=ContestAttempt.Status.ACTIVE).update(
|
if match.game_kind == RealtimeMatch.GameKind.QUIZ:
|
||||||
status=ContestAttempt.Status.EXPIRED,
|
for attempt in match.attempts.filter(
|
||||||
duration_ms=elapsed_ms,
|
status=ContestAttempt.Status.ACTIVE
|
||||||
submitted_at=timezone.now(),
|
):
|
||||||
)
|
elapsed_ms = max(
|
||||||
match = finalize_match(match.id)
|
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)
|
||||||
|
else:
|
||||||
|
elapsed_ms = max(
|
||||||
|
0,
|
||||||
|
int((now - match.started_at).total_seconds() * 1000),
|
||||||
|
)
|
||||||
|
match.game_attempts.filter(
|
||||||
|
status=MathGameAttempt.Status.ACTIVE
|
||||||
|
).update(
|
||||||
|
status=MathGameAttempt.Status.FAILED,
|
||||||
|
duration_ms=elapsed_ms,
|
||||||
|
submitted_at=now,
|
||||||
|
)
|
||||||
|
match = finalize_game_match(match.id)
|
||||||
return match
|
return match
|
||||||
|
|
||||||
|
|
||||||
@@ -496,28 +626,7 @@ def _elo_delta(rating, opponent_rating, score, k=32):
|
|||||||
return round(k * (score - expected))
|
return round(k * (score - expected))
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
def _settle_match(match, first_result, second_result, winner_id):
|
||||||
def finalize_match(match_id):
|
|
||||||
match = RealtimeMatch.objects.select_for_update().get(id=match_id)
|
|
||||||
if match.status != RealtimeMatch.Status.ACTIVE:
|
|
||||||
return match
|
|
||||||
attempts = list(match.attempts.select_related("user").order_by("user_id"))
|
|
||||||
if len(attempts) != 2 or any(
|
|
||||||
attempt.status == ContestAttempt.Status.ACTIVE for attempt in attempts
|
|
||||||
):
|
|
||||||
return match
|
|
||||||
|
|
||||||
first = next(item for item in attempts if item.user_id == match.player_one_id)
|
|
||||||
second = next(item for item in attempts if item.user_id == match.player_two_id)
|
|
||||||
if first.score > second.score:
|
|
||||||
first_result, second_result = 1.0, 0.0
|
|
||||||
match.winner_id = first.user_id
|
|
||||||
elif second.score > first.score:
|
|
||||||
first_result, second_result = 0.0, 1.0
|
|
||||||
match.winner_id = second.user_id
|
|
||||||
else:
|
|
||||||
first_result = second_result = 0.5
|
|
||||||
|
|
||||||
users = {
|
users = {
|
||||||
user.id: user
|
user.id: user
|
||||||
for user in User.objects.select_for_update().filter(
|
for user in User.objects.select_for_update().filter(
|
||||||
@@ -541,8 +650,86 @@ def finalize_match(match_id):
|
|||||||
rating_after=user.rating,
|
rating_after=user.rating,
|
||||||
delta=delta,
|
delta=delta,
|
||||||
)
|
)
|
||||||
|
match.winner_id = winner_id
|
||||||
match.status = RealtimeMatch.Status.COMPLETED
|
match.status = RealtimeMatch.Status.COMPLETED
|
||||||
match.completed_at = timezone.now()
|
match.completed_at = timezone.now()
|
||||||
match.save(update_fields=["winner", "status", "completed_at"])
|
match.save(update_fields=["winner", "status", "completed_at"])
|
||||||
notify_match_on_commit(match.id, "completed")
|
notify_match_on_commit(match.id, "completed")
|
||||||
return match
|
return match
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def finalize_match(match_id):
|
||||||
|
match = RealtimeMatch.objects.select_for_update().get(id=match_id)
|
||||||
|
if match.status != RealtimeMatch.Status.ACTIVE:
|
||||||
|
return match
|
||||||
|
attempts = list(match.attempts.select_related("user").order_by("user_id"))
|
||||||
|
if len(attempts) != 2 or any(
|
||||||
|
attempt.status == ContestAttempt.Status.ACTIVE for attempt in attempts
|
||||||
|
):
|
||||||
|
return match
|
||||||
|
|
||||||
|
first = next(item for item in attempts if item.user_id == match.player_one_id)
|
||||||
|
second = next(item for item in attempts if item.user_id == match.player_two_id)
|
||||||
|
if first.score > second.score:
|
||||||
|
first_result, second_result = 1.0, 0.0
|
||||||
|
winner_id = first.user_id
|
||||||
|
elif second.score > first.score:
|
||||||
|
first_result, second_result = 0.0, 1.0
|
||||||
|
winner_id = second.user_id
|
||||||
|
else:
|
||||||
|
diff = first.duration_ms - second.duration_ms
|
||||||
|
if abs(diff) <= 100:
|
||||||
|
first_result = second_result = 0.5
|
||||||
|
winner_id = None
|
||||||
|
elif diff < 0:
|
||||||
|
first_result, second_result = 1.0, 0.0
|
||||||
|
winner_id = first.user_id
|
||||||
|
else:
|
||||||
|
first_result, second_result = 0.0, 1.0
|
||||||
|
winner_id = second.user_id
|
||||||
|
return _settle_match(
|
||||||
|
match,
|
||||||
|
first_result,
|
||||||
|
second_result,
|
||||||
|
winner_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def finalize_game_match(match_id):
|
||||||
|
match = RealtimeMatch.objects.select_for_update().get(id=match_id)
|
||||||
|
if match.status != RealtimeMatch.Status.ACTIVE:
|
||||||
|
return match
|
||||||
|
attempts = list(match.game_attempts.select_related("user").order_by("user_id"))
|
||||||
|
if len(attempts) != 2 or any(
|
||||||
|
attempt.status == MathGameAttempt.Status.ACTIVE for attempt in attempts
|
||||||
|
):
|
||||||
|
return match
|
||||||
|
|
||||||
|
first = next(item for item in attempts if item.user_id == match.player_one_id)
|
||||||
|
second = next(item for item in attempts if item.user_id == match.player_two_id)
|
||||||
|
first_completed = first.status == MathGameAttempt.Status.COMPLETED
|
||||||
|
second_completed = second.status == MathGameAttempt.Status.COMPLETED
|
||||||
|
if first_completed and not second_completed:
|
||||||
|
first_result, second_result, winner_id = 1.0, 0.0, first.user_id
|
||||||
|
elif second_completed and not first_completed:
|
||||||
|
first_result, second_result, winner_id = 0.0, 1.0, second.user_id
|
||||||
|
elif not first_completed and not second_completed:
|
||||||
|
first_result = second_result = 0.5
|
||||||
|
winner_id = None
|
||||||
|
else:
|
||||||
|
diff = first.duration_ms - second.duration_ms
|
||||||
|
if abs(diff) <= 100:
|
||||||
|
first_result = second_result = 0.5
|
||||||
|
winner_id = None
|
||||||
|
elif diff < 0:
|
||||||
|
first_result, second_result, winner_id = 1.0, 0.0, first.user_id
|
||||||
|
else:
|
||||||
|
first_result, second_result, winner_id = 0.0, 1.0, second.user_id
|
||||||
|
return _settle_match(
|
||||||
|
match,
|
||||||
|
first_result,
|
||||||
|
second_result,
|
||||||
|
winner_id,
|
||||||
|
)
|
||||||
|
|||||||
@@ -174,4 +174,5 @@ def test_math_game_api_目录公开但开局需要登录(client, game_user):
|
|||||||
assert {item["kind"] for item in catalog.json()} == {"sudoku", "twenty_four"}
|
assert {item["kind"] for item in catalog.json()} == {"sudoku", "twenty_four"}
|
||||||
assert anonymous_start.status_code in {401, 403}
|
assert anonymous_start.status_code in {401, 403}
|
||||||
assert authenticated_start.status_code == 201
|
assert authenticated_start.status_code == 201
|
||||||
|
assert authenticated_start.json()["difficulty"] == "standard"
|
||||||
assert "solution" not in authenticated_start.json()
|
assert "solution" not in authenticated_start.json()
|
||||||
|
|||||||
@@ -203,3 +203,54 @@ def test_challenge_api_已使用联机码返回具体原因(realtime_api_setup):
|
|||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.json()["error"]["message"] == "联机码已失效或已被使用"
|
assert response.json()["error"]["message"] == "联机码已失效或已被使用"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_challenge_api_24点竞速完整闭环(realtime_api_setup):
|
||||||
|
contest, first_client, second_client = realtime_api_setup
|
||||||
|
created = first_client.post(
|
||||||
|
f"/api/v1/contests/{contest.slug}/challenges/",
|
||||||
|
{"game_kind": "twenty_four"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
joined = second_client.post(
|
||||||
|
"/api/v1/contests/challenges/join/",
|
||||||
|
{"challenge_code": created.json()["challenge_code"]},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert joined.status_code == 200
|
||||||
|
assert joined.json()["game_kind"] == "twenty_four"
|
||||||
|
assert created.json()["attempt"] is None
|
||||||
|
first_state = first_client.get(
|
||||||
|
f"/api/v1/contests/matches/{joined.json()['match_id']}/"
|
||||||
|
).json()
|
||||||
|
assert first_state["attempt"]["puzzle"] == joined.json()["attempt"]["puzzle"]
|
||||||
|
|
||||||
|
solutions = {
|
||||||
|
(2, 3, 4, 9): "(2/3)*4*9",
|
||||||
|
(3, 5, 7, 13): "(7+5*13)/3",
|
||||||
|
(4, 7, 8, 8): "8*(4+7-8)",
|
||||||
|
}
|
||||||
|
expression = solutions[
|
||||||
|
tuple(sorted(joined.json()["attempt"]["puzzle"]["numbers"]))
|
||||||
|
]
|
||||||
|
first_submit = first_client.post(
|
||||||
|
f"/api/v1/contests/games/attempts/{first_state['attempt']['attempt_id']}/submit/",
|
||||||
|
{"expression": expression},
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_IDEMPOTENCY_KEY="api-game-first",
|
||||||
|
)
|
||||||
|
second_submit = second_client.post(
|
||||||
|
f"/api/v1/contests/games/attempts/{joined.json()['attempt']['attempt_id']}/submit/",
|
||||||
|
{"expression": expression},
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_IDEMPOTENCY_KEY="api-game-second",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first_submit.status_code == 200
|
||||||
|
assert first_submit.json()["status"] == "active"
|
||||||
|
assert first_submit.json()["attempt"]["status"] == "completed"
|
||||||
|
assert second_submit.status_code == 200
|
||||||
|
assert second_submit.json()["status"] == "completed"
|
||||||
|
assert second_submit.json()["result"]["winner"] in {"self", "opponent", "draw"}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from contest.management.commands.seed_contests import QUESTIONS
|
||||||
|
from contest.models import Question
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_question_pool_三个历史赛道题量充足():
|
||||||
|
assert len(QUESTIONS[Question.Track.BEGINNER]) == 400
|
||||||
|
assert len(QUESTIONS[Question.Track.STANDARD]) == 400
|
||||||
|
assert len(QUESTIONS[Question.Track.ADVANCED]) == 200
|
||||||
@@ -5,10 +5,12 @@ from django.utils import timezone
|
|||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
|
from contest.game_services import submit_game
|
||||||
from contest.models import (
|
from contest.models import (
|
||||||
Contest,
|
Contest,
|
||||||
ContestAttempt,
|
ContestAttempt,
|
||||||
ContestQuestion,
|
ContestQuestion,
|
||||||
|
MathGameAttempt,
|
||||||
Question,
|
Question,
|
||||||
QuestionVersion,
|
QuestionVersion,
|
||||||
RatingHistory,
|
RatingHistory,
|
||||||
@@ -17,6 +19,7 @@ from contest.models import (
|
|||||||
from contest.services import (
|
from contest.services import (
|
||||||
cancel_waiting_match,
|
cancel_waiting_match,
|
||||||
create_challenge,
|
create_challenge,
|
||||||
|
finalize_game_match,
|
||||||
finalize_match,
|
finalize_match,
|
||||||
find_match,
|
find_match,
|
||||||
join_challenge,
|
join_challenge,
|
||||||
@@ -190,6 +193,21 @@ def test_find_match_同分玩家配对并由_finalize_match_唯一结算_rating(
|
|||||||
track=Question.Track.STANDARD,
|
track=Question.Track.STANDARD,
|
||||||
status=Contest.Status.PUBLISHED,
|
status=Contest.Status.PUBLISHED,
|
||||||
)
|
)
|
||||||
|
question = Question.objects.create(
|
||||||
|
slug="realtime-rating-question",
|
||||||
|
track=Question.Track.STANDARD,
|
||||||
|
)
|
||||||
|
version = QuestionVersion.objects.create(
|
||||||
|
question=question,
|
||||||
|
version=1,
|
||||||
|
prompt="1 + 1",
|
||||||
|
answer="2",
|
||||||
|
)
|
||||||
|
ContestQuestion.objects.create(
|
||||||
|
contest=contest,
|
||||||
|
question_version=version,
|
||||||
|
order=1,
|
||||||
|
)
|
||||||
|
|
||||||
waiting = find_match(first, contest)
|
waiting = find_match(first, contest)
|
||||||
active = find_match(second, contest)
|
active = find_match(second, contest)
|
||||||
@@ -360,3 +378,104 @@ def test_challenge_owner_可取消等待中的联机码(realtime_contest):
|
|||||||
),
|
),
|
||||||
waiting.challenge_code,
|
waiting.challenge_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_practice_attempt_从完整题池保存随机快照(user):
|
||||||
|
contest = Contest.objects.create(
|
||||||
|
slug="random-practice",
|
||||||
|
title="随机练习",
|
||||||
|
kind=Contest.Kind.PRACTICE,
|
||||||
|
track=Question.Track.STANDARD,
|
||||||
|
status=Contest.Status.PUBLISHED,
|
||||||
|
)
|
||||||
|
for index in range(12):
|
||||||
|
question = Question.objects.create(
|
||||||
|
slug=f"random-question-{index}",
|
||||||
|
track=Question.Track.STANDARD,
|
||||||
|
)
|
||||||
|
version = QuestionVersion.objects.create(
|
||||||
|
question=question,
|
||||||
|
version=1,
|
||||||
|
prompt=f"{index} + 1",
|
||||||
|
answer=str(index + 1),
|
||||||
|
)
|
||||||
|
ContestQuestion.objects.create(
|
||||||
|
contest=contest,
|
||||||
|
question_version=version,
|
||||||
|
order=index + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
payloads = [start_attempt(user, contest) for _ in range(8)]
|
||||||
|
snapshots = {
|
||||||
|
tuple(
|
||||||
|
ContestAttempt.objects.get(id=payload["attempt_id"]).question_order
|
||||||
|
)
|
||||||
|
for payload in payloads
|
||||||
|
}
|
||||||
|
|
||||||
|
assert all(len(payload["questions"]) == 8 for payload in payloads)
|
||||||
|
assert len(snapshots) > 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_twenty_four_realtime_共享题面按完成时间结算_rating(realtime_contest):
|
||||||
|
first = User.objects.create_user(
|
||||||
|
username="game_match_one",
|
||||||
|
password="StrongPass_2026",
|
||||||
|
nickname="竞速玩家一",
|
||||||
|
)
|
||||||
|
second = User.objects.create_user(
|
||||||
|
username="game_match_two",
|
||||||
|
password="StrongPass_2026",
|
||||||
|
nickname="竞速玩家二",
|
||||||
|
)
|
||||||
|
waiting = create_challenge(
|
||||||
|
first,
|
||||||
|
realtime_contest,
|
||||||
|
RealtimeMatch.GameKind.TWENTY_FOUR,
|
||||||
|
)
|
||||||
|
match = join_challenge(second, waiting.challenge_code)
|
||||||
|
attempts = {
|
||||||
|
attempt.user_id: attempt for attempt in match.game_attempts.all()
|
||||||
|
}
|
||||||
|
first_attempt = attempts[first.id]
|
||||||
|
second_attempt = attempts[second.id]
|
||||||
|
assert first_attempt.puzzle == second_attempt.puzzle
|
||||||
|
assert first_attempt.solution == second_attempt.solution
|
||||||
|
|
||||||
|
solutions = {
|
||||||
|
(2, 3, 4, 9): "(2/3)*4*9",
|
||||||
|
(3, 5, 7, 13): "(7+5*13)/3",
|
||||||
|
(4, 7, 8, 8): "8*(4+7-8)",
|
||||||
|
}
|
||||||
|
expression = solutions[tuple(sorted(first_attempt.puzzle["numbers"]))]
|
||||||
|
MathGameAttempt.objects.filter(id=first_attempt.id).update(
|
||||||
|
started_at=timezone.now() - timedelta(seconds=2)
|
||||||
|
)
|
||||||
|
MathGameAttempt.objects.filter(id=second_attempt.id).update(
|
||||||
|
started_at=timezone.now() - timedelta(seconds=5)
|
||||||
|
)
|
||||||
|
submit_game(
|
||||||
|
first,
|
||||||
|
first_attempt.id,
|
||||||
|
{"expression": expression},
|
||||||
|
"game-race-first",
|
||||||
|
)
|
||||||
|
assert finalize_game_match(match.id).status == RealtimeMatch.Status.ACTIVE
|
||||||
|
submit_game(
|
||||||
|
second,
|
||||||
|
second_attempt.id,
|
||||||
|
{"expression": expression},
|
||||||
|
"game-race-second",
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = finalize_game_match(match.id)
|
||||||
|
first.refresh_from_db()
|
||||||
|
second.refresh_from_db()
|
||||||
|
|
||||||
|
assert completed.status == RealtimeMatch.Status.COMPLETED
|
||||||
|
assert completed.winner_id == first.id
|
||||||
|
assert first.rating == 1016
|
||||||
|
assert second.rating == 984
|
||||||
|
assert RatingHistory.objects.filter(match=match).count() == 2
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -8,9 +8,11 @@ from .models import Contest, ContestAttempt, MathGameAttempt, RealtimeMatch
|
|||||||
from .services import (
|
from .services import (
|
||||||
cancel_waiting_match,
|
cancel_waiting_match,
|
||||||
create_challenge,
|
create_challenge,
|
||||||
|
finalize_game_match,
|
||||||
find_match,
|
find_match,
|
||||||
join_challenge,
|
join_challenge,
|
||||||
match_payload,
|
match_payload,
|
||||||
|
notify_match_on_commit,
|
||||||
refresh_match_state,
|
refresh_match_state,
|
||||||
start_attempt,
|
start_attempt,
|
||||||
submit_attempt,
|
submit_attempt,
|
||||||
@@ -64,7 +66,11 @@ class AttemptSubmitView(APIView):
|
|||||||
class MatchmakingView(APIView):
|
class MatchmakingView(APIView):
|
||||||
def post(self, request, slug):
|
def post(self, request, slug):
|
||||||
contest = get_object_or_404(Contest, slug=slug)
|
contest = get_object_or_404(Contest, slug=slug)
|
||||||
match = find_match(request.user, contest)
|
match = find_match(
|
||||||
|
request.user,
|
||||||
|
contest,
|
||||||
|
request.data.get("game_kind", RealtimeMatch.GameKind.QUIZ),
|
||||||
|
)
|
||||||
return Response(match_payload(match, request.user), status=status.HTTP_202_ACCEPTED)
|
return Response(match_payload(match, request.user), status=status.HTTP_202_ACCEPTED)
|
||||||
|
|
||||||
|
|
||||||
@@ -83,7 +89,11 @@ class MatchStateView(APIView):
|
|||||||
class ChallengeCreateView(APIView):
|
class ChallengeCreateView(APIView):
|
||||||
def post(self, request, slug):
|
def post(self, request, slug):
|
||||||
contest = get_object_or_404(Contest, slug=slug)
|
contest = get_object_or_404(Contest, slug=slug)
|
||||||
match = create_challenge(request.user, contest)
|
match = create_challenge(
|
||||||
|
request.user,
|
||||||
|
contest,
|
||||||
|
request.data.get("game_kind", RealtimeMatch.GameKind.QUIZ),
|
||||||
|
)
|
||||||
return Response(match_payload(match, request.user), status=status.HTTP_201_CREATED)
|
return Response(match_payload(match, request.user), status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@@ -158,19 +168,29 @@ class MathGameStartView(APIView):
|
|||||||
payload = start_game(
|
payload = start_game(
|
||||||
request.user,
|
request.user,
|
||||||
kind,
|
kind,
|
||||||
request.data.get("difficulty", MathGameAttempt.Difficulty.STANDARD),
|
MathGameAttempt.Difficulty.STANDARD,
|
||||||
)
|
)
|
||||||
return Response(payload, status=status.HTTP_201_CREATED)
|
return Response(payload, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
class MathGameSubmitView(APIView):
|
class MathGameSubmitView(APIView):
|
||||||
def post(self, request, attempt_id):
|
def post(self, request, attempt_id):
|
||||||
|
attempt = get_object_or_404(
|
||||||
|
MathGameAttempt,
|
||||||
|
id=attempt_id,
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
payload = submit_game(
|
payload = submit_game(
|
||||||
request.user,
|
request.user,
|
||||||
attempt_id,
|
attempt_id,
|
||||||
request.data,
|
request.data,
|
||||||
request.headers.get("Idempotency-Key"),
|
request.headers.get("Idempotency-Key"),
|
||||||
)
|
)
|
||||||
|
if attempt.match_id:
|
||||||
|
match = finalize_game_match(attempt.match_id)
|
||||||
|
if match.status != RealtimeMatch.Status.COMPLETED:
|
||||||
|
notify_match_on_commit(match.id, "submitted")
|
||||||
|
return Response(match_payload(match, request.user))
|
||||||
return Response(payload)
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from .models import (
|
|||||||
SkillPackage,
|
SkillPackage,
|
||||||
Story,
|
Story,
|
||||||
StoryChoice,
|
StoryChoice,
|
||||||
|
StoryMark,
|
||||||
StoryRun,
|
StoryRun,
|
||||||
StoryVersion,
|
StoryVersion,
|
||||||
UserRelationship,
|
UserRelationship,
|
||||||
@@ -52,5 +53,6 @@ admin.site.register(MathBTIAssessment)
|
|||||||
admin.site.register(MathBTIResult)
|
admin.site.register(MathBTIResult)
|
||||||
admin.site.register(Character)
|
admin.site.register(Character)
|
||||||
admin.site.register(StoryChoice)
|
admin.site.register(StoryChoice)
|
||||||
|
admin.site.register(StoryMark)
|
||||||
admin.site.register(UserRelationship)
|
admin.site.register(UserRelationship)
|
||||||
admin.site.register(SkillPackage)
|
admin.site.register(SkillPackage)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from math_life.models import (
|
|||||||
StoryVersion,
|
StoryVersion,
|
||||||
)
|
)
|
||||||
from math_life.services import validate_story_content
|
from math_life.services import validate_story_content
|
||||||
|
from math_life.story_v12 import V12_IDENTITY_SCORES, build_v12_story
|
||||||
|
|
||||||
DISCIPLINE_ICONS = {
|
DISCIPLINE_ICONS = {
|
||||||
"人工智能": "🤖",
|
"人工智能": "🤖",
|
||||||
@@ -157,12 +158,15 @@ class Command(BaseCommand):
|
|||||||
"clan": result["clan_name"],
|
"clan": result["clan_name"],
|
||||||
"mathematician": result["mathematician"],
|
"mathematician": result["mathematician"],
|
||||||
"description": result["description"],
|
"description": result["description"],
|
||||||
"initial_abilities": result.get("stats5", {}),
|
"initial_abilities": V12_IDENTITY_SCORES.get(
|
||||||
|
code,
|
||||||
|
result.get("stats5", {}),
|
||||||
|
),
|
||||||
"portrait": result.get("portrait", ""),
|
"portrait": result.get("portrait", ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
story_document = load_json(story_path)
|
story_document = build_v12_story(load_json(story_path))
|
||||||
errors = validate_story_content(story_document)
|
errors = validate_story_content(story_document)
|
||||||
if errors:
|
if errors:
|
||||||
raise CommandError("; ".join(errors))
|
raise CommandError("; ".join(errors))
|
||||||
@@ -176,9 +180,12 @@ class Command(BaseCommand):
|
|||||||
"is_visible": True,
|
"is_visible": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
StoryVersion.objects.filter(story=flagship).exclude(version=2).update(
|
||||||
|
is_published=False
|
||||||
|
)
|
||||||
StoryVersion.objects.update_or_create(
|
StoryVersion.objects.update_or_create(
|
||||||
story=flagship,
|
story=flagship,
|
||||||
version=1,
|
version=2,
|
||||||
defaults={
|
defaults={
|
||||||
"content": story_document,
|
"content": story_document,
|
||||||
"is_published": True,
|
"is_published": True,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 4.2.23 on 2026-08-09 16:51
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('math_life', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StoryMark',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('code', models.CharField(max_length=60)),
|
||||||
|
('name', models.CharField(max_length=80)),
|
||||||
|
('domain', models.CharField(max_length=40)),
|
||||||
|
('acquired_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('source_run', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acquired_marks', to='math_life.storyrun')),
|
||||||
|
('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collected_marks', to='math_life.story')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='story_marks', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['acquired_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='storymark',
|
||||||
|
constraint=models.UniqueConstraint(fields=('user', 'story', 'code'), name='unique_user_story_mark'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from math_life.story_v12 import V12_IDENTITY_SCORES, build_v12_story
|
||||||
|
|
||||||
|
|
||||||
|
def publish_marks_story(apps, schema_editor):
|
||||||
|
MathIdentity = apps.get_model("math_life", "MathIdentity")
|
||||||
|
Story = apps.get_model("math_life", "Story")
|
||||||
|
StoryRun = apps.get_model("math_life", "StoryRun")
|
||||||
|
StoryVersion = apps.get_model("math_life", "StoryVersion")
|
||||||
|
|
||||||
|
for code, scores in V12_IDENTITY_SCORES.items():
|
||||||
|
MathIdentity.objects.filter(code=code).update(initial_abilities=scores)
|
||||||
|
|
||||||
|
story = Story.objects.filter(slug="believer").first()
|
||||||
|
if story is None:
|
||||||
|
story = Story.objects.filter(slug="believer-math-teen").first()
|
||||||
|
if story is None:
|
||||||
|
return
|
||||||
|
if story.slug != "believer":
|
||||||
|
story.slug = "believer"
|
||||||
|
story.save(update_fields=["slug"])
|
||||||
|
source = (
|
||||||
|
StoryVersion.objects.filter(story=story)
|
||||||
|
.order_by("-version")
|
||||||
|
.values_list("content", flat=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not source:
|
||||||
|
return
|
||||||
|
document = (
|
||||||
|
source
|
||||||
|
if source.get("system_version") == "marks-v1"
|
||||||
|
else build_v12_story(source)
|
||||||
|
)
|
||||||
|
old_version_ids = list(
|
||||||
|
StoryVersion.objects.filter(story=story)
|
||||||
|
.exclude(version=2)
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
if old_version_ids:
|
||||||
|
StoryRun.objects.filter(
|
||||||
|
story_version_id__in=old_version_ids,
|
||||||
|
status="active",
|
||||||
|
).update(status="abandoned")
|
||||||
|
StoryVersion.objects.filter(story=story).update(is_published=False)
|
||||||
|
StoryVersion.objects.update_or_create(
|
||||||
|
story=story,
|
||||||
|
version=2,
|
||||||
|
defaults={
|
||||||
|
"content": document,
|
||||||
|
"is_published": True,
|
||||||
|
"published_at": timezone.now(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("math_life", "0002_storymark_storymark_unique_user_story_mark"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
publish_marks_story,
|
||||||
|
migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -127,6 +127,42 @@ class StoryChoice(models.Model):
|
|||||||
ordering = ["sequence"]
|
ordering = ["sequence"]
|
||||||
|
|
||||||
|
|
||||||
|
class StoryMark(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="story_marks",
|
||||||
|
)
|
||||||
|
story = models.ForeignKey(
|
||||||
|
Story,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="collected_marks",
|
||||||
|
)
|
||||||
|
source_run = models.ForeignKey(
|
||||||
|
StoryRun,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="acquired_marks",
|
||||||
|
)
|
||||||
|
code = models.CharField(max_length=60)
|
||||||
|
name = models.CharField(max_length=80)
|
||||||
|
domain = models.CharField(max_length=40)
|
||||||
|
acquired_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("user", "story", "code"),
|
||||||
|
name="unique_user_story_mark",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
ordering = ["acquired_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} · {self.name}"
|
||||||
|
|
||||||
|
|
||||||
class UserRelationship(models.Model):
|
class UserRelationship(models.Model):
|
||||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
character = models.ForeignKey(Character, on_delete=models.CASCADE)
|
character = models.ForeignKey(Character, on_delete=models.CASCADE)
|
||||||
|
|||||||
+112
-11
@@ -4,7 +4,7 @@ from django.db import transaction
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from .models import StoryChoice, StoryRun, StoryVersion
|
from .models import StoryChoice, StoryMark, StoryRun, StoryVersion
|
||||||
|
|
||||||
|
|
||||||
def score_mathbti(definition, answer_indexes):
|
def score_mathbti(definition, answer_indexes):
|
||||||
@@ -40,6 +40,11 @@ def validate_story_content(content):
|
|||||||
errors.append("start_node 不存在")
|
errors.append("start_node 不存在")
|
||||||
|
|
||||||
referenced = set()
|
referenced = set()
|
||||||
|
mark_codes = {
|
||||||
|
item.get("code")
|
||||||
|
for item in content.get("mark_definitions", [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
for node_id, node in nodes.items():
|
for node_id, node in nodes.items():
|
||||||
if not isinstance(node.get("choices", []), list):
|
if not isinstance(node.get("choices", []), list):
|
||||||
errors.append(f"{node_id}.choices 必须是数组")
|
errors.append(f"{node_id}.choices 必须是数组")
|
||||||
@@ -52,6 +57,16 @@ def validate_story_content(content):
|
|||||||
errors.append(f"{node_id} 引用了不存在的节点 {target}")
|
errors.append(f"{node_id} 引用了不存在的节点 {target}")
|
||||||
else:
|
else:
|
||||||
referenced.add(target)
|
referenced.add(target)
|
||||||
|
required = choice.get("requires_marks", [])
|
||||||
|
if not isinstance(required, list) or any(
|
||||||
|
code not in mark_codes for code in required
|
||||||
|
):
|
||||||
|
errors.append(f"{node_id} 的印记条件无效")
|
||||||
|
effects = choice.get("effects", {})
|
||||||
|
if content.get("system_version") == "marks-v1" and any(
|
||||||
|
key not in {"marks", "easter_eggs"} for key in effects
|
||||||
|
):
|
||||||
|
errors.append(f"{node_id} 仍包含旧数值效果")
|
||||||
|
|
||||||
if start in nodes:
|
if start in nodes:
|
||||||
reachable = set()
|
reachable = set()
|
||||||
@@ -66,6 +81,9 @@ def validate_story_content(content):
|
|||||||
for choice in nodes[node_id].get("choices", [])
|
for choice in nodes[node_id].get("choices", [])
|
||||||
if choice.get("next") in nodes
|
if choice.get("next") in nodes
|
||||||
)
|
)
|
||||||
|
ending_rules = content.get("ending_rules", {})
|
||||||
|
if node_id == ending_rules.get("trigger_node"):
|
||||||
|
pending.extend(ending_rules.get("domains", {}).values())
|
||||||
unreachable = sorted(set(nodes) - reachable)
|
unreachable = sorted(set(nodes) - reachable)
|
||||||
if unreachable:
|
if unreachable:
|
||||||
errors.append(f"存在不可达节点: {', '.join(unreachable[:10])}")
|
errors.append(f"存在不可达节点: {', '.join(unreachable[:10])}")
|
||||||
@@ -77,6 +95,9 @@ def _merge_effects(state, effects):
|
|||||||
for key, value in effects.items():
|
for key, value in effects.items():
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
result[key] = _merge_effects(result.get(key, {}), value)
|
result[key] = _merge_effects(result.get(key, {}), value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
current = result.get(key, [])
|
||||||
|
result[key] = list(dict.fromkeys([*current, *value]))
|
||||||
elif isinstance(value, (int, float)):
|
elif isinstance(value, (int, float)):
|
||||||
result[key] = result.get(key, 0) + value
|
result[key] = result.get(key, 0) + value
|
||||||
else:
|
else:
|
||||||
@@ -84,12 +105,67 @@ def _merge_effects(state, effects):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _node_payload(run):
|
def _mark_lookup(content):
|
||||||
node = run.story_version.content["nodes"][run.current_node]
|
return {
|
||||||
choices = [
|
item["code"]: item
|
||||||
{"index": index, "text": choice.get("text", "")}
|
for item in content.get("mark_definitions", [])
|
||||||
for index, choice in enumerate(node.get("choices", []))
|
if isinstance(item, dict) and item.get("code")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_mark_counts(state, content):
|
||||||
|
result = deepcopy(state)
|
||||||
|
lookup = _mark_lookup(content)
|
||||||
|
counts = {
|
||||||
|
domain: 0 for domain in content.get("domain_labels", {})
|
||||||
|
}
|
||||||
|
for code in result.get("marks", []):
|
||||||
|
mark = lookup.get(code)
|
||||||
|
if mark:
|
||||||
|
counts[mark["domain"]] = counts.get(mark["domain"], 0) + 1
|
||||||
|
result["mark_counts"] = counts
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _available_choices(run, node):
|
||||||
|
marks = set(run.state.get("marks", []))
|
||||||
|
return [
|
||||||
|
choice
|
||||||
|
for choice in node.get("choices", [])
|
||||||
|
if set(choice.get("requires_marks", [])).issubset(marks)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ending(content, state, node_id):
|
||||||
|
rules = content.get("ending_rules", {})
|
||||||
|
if node_id != rules.get("trigger_node"):
|
||||||
|
return node_id
|
||||||
|
counts = state.get("mark_counts", {})
|
||||||
|
order = rules.get("tie_order", [])
|
||||||
|
domain = max(order, key=lambda item: counts.get(item, 0))
|
||||||
|
return rules.get("domains", {}).get(domain, node_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_payload(run):
|
||||||
|
content = run.story_version.content
|
||||||
|
node = content["nodes"][run.current_node]
|
||||||
|
available_choices = _available_choices(run, node)
|
||||||
|
choices = [
|
||||||
|
{
|
||||||
|
"index": index,
|
||||||
|
"text": choice.get("text", ""),
|
||||||
|
"special": bool(choice.get("requires_marks")),
|
||||||
|
}
|
||||||
|
for index, choice in enumerate(available_choices)
|
||||||
|
]
|
||||||
|
chapter_code = node.get("chapter") or run.current_node.split("_", 1)[0]
|
||||||
|
chapter = content.get("chapters", {}).get(chapter_code, {})
|
||||||
|
collection = list(
|
||||||
|
StoryMark.objects.filter(
|
||||||
|
user=run.user,
|
||||||
|
story=run.story_version.story,
|
||||||
|
).values("code", "name", "domain", "acquired_at")
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"run_id": run.id,
|
"run_id": run.id,
|
||||||
"story": run.story_version.story.title,
|
"story": run.story_version.story.title,
|
||||||
@@ -97,6 +173,10 @@ def _node_payload(run):
|
|||||||
"status": run.status,
|
"status": run.status,
|
||||||
"current_node": run.current_node,
|
"current_node": run.current_node,
|
||||||
"state": run.state,
|
"state": run.state,
|
||||||
|
"chapter": chapter,
|
||||||
|
"mark_definitions": content.get("mark_definitions", []),
|
||||||
|
"domain_labels": content.get("domain_labels", {}),
|
||||||
|
"collection": collection,
|
||||||
"node": {
|
"node": {
|
||||||
"scene": node.get("scene", ""),
|
"scene": node.get("scene", ""),
|
||||||
"character": node.get("character", ""),
|
"character": node.get("character", ""),
|
||||||
@@ -125,7 +205,11 @@ def start_story(user, story):
|
|||||||
user=user,
|
user=user,
|
||||||
story_version=version,
|
story_version=version,
|
||||||
current_node=version.content["start_node"],
|
current_node=version.content["start_node"],
|
||||||
state={},
|
state=(
|
||||||
|
_refresh_mark_counts({"marks": [], "easter_eggs": []}, version.content)
|
||||||
|
if version.content.get("system_version") == "marks-v1"
|
||||||
|
else {}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return _node_payload(run)
|
return _node_payload(run)
|
||||||
|
|
||||||
@@ -151,8 +235,9 @@ def make_choice(*, run_id, user, choice_index, idempotency_key=None):
|
|||||||
raise ValidationError("该人生已经结束")
|
raise ValidationError("该人生已经结束")
|
||||||
|
|
||||||
node_id = run.current_node
|
node_id = run.current_node
|
||||||
node = run.story_version.content["nodes"][node_id]
|
content = run.story_version.content
|
||||||
choices = node.get("choices", [])
|
node = content["nodes"][node_id]
|
||||||
|
choices = _available_choices(run, node)
|
||||||
try:
|
try:
|
||||||
normalized_index = int(choice_index)
|
normalized_index = int(choice_index)
|
||||||
if normalized_index < 0 or normalized_index >= len(choices):
|
if normalized_index < 0 or normalized_index >= len(choices):
|
||||||
@@ -173,8 +258,24 @@ def make_choice(*, run_id, user, choice_index, idempotency_key=None):
|
|||||||
effects=effects,
|
effects=effects,
|
||||||
)
|
)
|
||||||
run.state = _merge_effects(run.state, effects)
|
run.state = _merge_effects(run.state, effects)
|
||||||
run.current_node = choice["next"]
|
if content.get("system_version") == "marks-v1":
|
||||||
next_node = run.story_version.content["nodes"][run.current_node]
|
run.state = _refresh_mark_counts(run.state, content)
|
||||||
|
mark_lookup = _mark_lookup(content)
|
||||||
|
for mark_code in effects.get("marks", []):
|
||||||
|
mark = mark_lookup.get(mark_code)
|
||||||
|
if mark:
|
||||||
|
StoryMark.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
story=run.story_version.story,
|
||||||
|
code=mark_code,
|
||||||
|
defaults={
|
||||||
|
"source_run": run,
|
||||||
|
"name": mark["name"],
|
||||||
|
"domain": mark["domain"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
run.current_node = _resolve_ending(content, run.state, choice["next"])
|
||||||
|
next_node = content["nodes"][run.current_node]
|
||||||
if not next_node.get("choices"):
|
if not next_node.get("choices"):
|
||||||
run.status = StoryRun.Status.COMPLETED
|
run.status = StoryRun.Status.COMPLETED
|
||||||
run.ending_code = run.current_node
|
run.ending_code = run.current_node
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
MARK_DEFINITIONS = [
|
||||||
|
{"code": "prime_margin", "name": "素数页边注", "domain": "number_theory"},
|
||||||
|
{"code": "one_to_hundred", "name": "1到100的两行", "domain": "number_theory"},
|
||||||
|
{
|
||||||
|
"code": "compass_straightedge",
|
||||||
|
"name": "圆规与直尺",
|
||||||
|
"domain": "number_theory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "seventeen_gon_night",
|
||||||
|
"name": "正十七边形的夜",
|
||||||
|
"domain": "number_theory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fundamental_arithmetic",
|
||||||
|
"name": "算术基本定理",
|
||||||
|
"domain": "number_theory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "skipped_lemma",
|
||||||
|
"name": "跳步的引理",
|
||||||
|
"domain": "algebraic_geometry",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "shadow_of_sheaf",
|
||||||
|
"name": "层的影子",
|
||||||
|
"domain": "algebraic_geometry",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "denied_cubic",
|
||||||
|
"name": "被否定的三次",
|
||||||
|
"domain": "algebraic_geometry",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "fourth_continue",
|
||||||
|
"name": "第四次继续",
|
||||||
|
"domain": "algebraic_geometry",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ideal_and_ring",
|
||||||
|
"name": "理想与环",
|
||||||
|
"domain": "algebraic_geometry",
|
||||||
|
},
|
||||||
|
{"code": "late_draft", "name": "深夜的草稿", "domain": "analysis"},
|
||||||
|
{"code": "epsilon_promise", "name": "ε-δ 的承诺", "domain": "analysis"},
|
||||||
|
{"code": "counting_rod", "name": "一根算筹", "domain": "analysis"},
|
||||||
|
{"code": "pi_seventh_digit", "name": "π 的第七位", "domain": "analysis"},
|
||||||
|
{"code": "limit_definition", "name": "极限的定义", "domain": "analysis"},
|
||||||
|
{
|
||||||
|
"code": "first_model",
|
||||||
|
"name": "第一次建模",
|
||||||
|
"domain": "applied_mathematics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "cafeteria_queue",
|
||||||
|
"name": "食堂排队模型",
|
||||||
|
"domain": "applied_mathematics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "five_constants",
|
||||||
|
"name": "五个常数",
|
||||||
|
"domain": "applied_mathematics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "beauty_in_time",
|
||||||
|
"name": "美在时间中",
|
||||||
|
"domain": "applied_mathematics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "optimal_solution",
|
||||||
|
"name": "最优解",
|
||||||
|
"domain": "applied_mathematics",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
MARK_PLACEMENTS = {
|
||||||
|
("c1_notice", 0): ["prime_margin"],
|
||||||
|
("c1_notice", 1): ["compass_straightedge"],
|
||||||
|
("c1_gauss_try", 0): ["one_to_hundred"],
|
||||||
|
("c1_gauss_keep", 0): ["seventeen_gon_night"],
|
||||||
|
("c5_start", 0): ["fundamental_arithmetic"],
|
||||||
|
("c3_start", 0): ["skipped_lemma"],
|
||||||
|
("c3_start", 1): ["skipped_lemma"],
|
||||||
|
("c3_paper", 0): ["denied_cubic"],
|
||||||
|
("c3_paper", 1): ["shadow_of_sheaf"],
|
||||||
|
("c3_denied", 0): ["fourth_continue"],
|
||||||
|
("c5_start", 1): ["ideal_and_ring"],
|
||||||
|
("c1_midterm", 1): ["late_draft"],
|
||||||
|
("c1_analysis", 0): ["epsilon_promise"],
|
||||||
|
("c1_analysis", 1): ["epsilon_promise"],
|
||||||
|
("c1_zu_enter", 0): ["counting_rod"],
|
||||||
|
("c1_zu_enter", 1): ["pi_seventh_digit"],
|
||||||
|
("c4_start", 0): ["limit_definition"],
|
||||||
|
("c2_model", 0): ["first_model"],
|
||||||
|
("c2_model_yes", 0): ["cafeteria_queue"],
|
||||||
|
("c4_sleep", 0): ["five_constants"],
|
||||||
|
("c4_boundary", 0): ["beauty_in_time"],
|
||||||
|
("c4_balance", 0): ["optimal_solution"],
|
||||||
|
}
|
||||||
|
|
||||||
|
EASTER_EGGS = [
|
||||||
|
{
|
||||||
|
"code": "gauss",
|
||||||
|
"name": "高斯草稿",
|
||||||
|
"trigger": "c1_gauss_merge",
|
||||||
|
"required_marks": ["prime_margin", "one_to_hundred"],
|
||||||
|
"node": "egg_gauss",
|
||||||
|
"return_to": "c1_report",
|
||||||
|
"scene": "你翻回录取通知书旁的页边注。两行数字首尾相加,像一座刚刚亮起的桥。高斯没有给你答案,只把铅笔推回你的手中。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "zu_chongzhi",
|
||||||
|
"name": "祖冲之草稿",
|
||||||
|
"trigger": "c1_zu_merge",
|
||||||
|
"required_marks": ["late_draft", "epsilon_promise"],
|
||||||
|
"node": "egg_zu_chongzhi",
|
||||||
|
"return_to": "c1_final",
|
||||||
|
"scene": "深夜草稿上的 ε-δ 与那根算筹叠在一起。精确不是冷冰冰的限制,而是你对下一步作出的承诺。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "euler",
|
||||||
|
"name": "欧拉草稿",
|
||||||
|
"trigger": "c2_end",
|
||||||
|
"required_marks": ["first_model", "cafeteria_queue"],
|
||||||
|
"node": "egg_euler",
|
||||||
|
"return_to": "c3_start",
|
||||||
|
"scene": "食堂队伍在草稿上变成变量、约束与目标函数。欧拉在页角写下五个常数:数学的美并不排斥现实,它能让现实获得结构。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "noether",
|
||||||
|
"name": "诺特草稿",
|
||||||
|
"trigger": "c3_cited",
|
||||||
|
"required_marks": ["skipped_lemma", "shadow_of_sheaf"],
|
||||||
|
"node": "egg_noether",
|
||||||
|
"return_to": "c4_start",
|
||||||
|
"scene": "你重新看见那个被跳过的引理,结构的影子从局部延伸到整体。诺特说:真正重要的不是补上一步,而是知道这一步为何必须存在。",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ENDING_RULES = {
|
||||||
|
"trigger_node": "c8_phd",
|
||||||
|
"tie_order": [
|
||||||
|
"number_theory",
|
||||||
|
"algebraic_geometry",
|
||||||
|
"analysis",
|
||||||
|
"applied_mathematics",
|
||||||
|
],
|
||||||
|
"domains": {
|
||||||
|
"number_theory": "ending_number_theory",
|
||||||
|
"algebraic_geometry": "ending_algebraic_geometry",
|
||||||
|
"analysis": "ending_analysis",
|
||||||
|
"applied_mathematics": "ending_applied_mathematics",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ENDING_NODES = {
|
||||||
|
"ending_number_theory": {
|
||||||
|
"character": "导师",
|
||||||
|
"scene": "你获得统一直博资格,方向选择数论。录取材料最上方,是你一路留下的素数页边注与算术结构。你没有成为第二个高斯,你开始提出自己的问题。",
|
||||||
|
"choices": [],
|
||||||
|
},
|
||||||
|
"ending_algebraic_geometry": {
|
||||||
|
"character": "导师",
|
||||||
|
"scene": "你获得统一直博资格,方向选择代数几何。讨论班里被跳过的引理,最终变成你研究理想、环与几何结构的入口。",
|
||||||
|
"choices": [],
|
||||||
|
},
|
||||||
|
"ending_analysis": {
|
||||||
|
"character": "导师",
|
||||||
|
"scene": "你获得统一直博资格,方向选择分析。深夜草稿、ε-δ 的承诺和极限定义,成为你继续逼近未知的方式。",
|
||||||
|
"choices": [],
|
||||||
|
},
|
||||||
|
"ending_applied_mathematics": {
|
||||||
|
"character": "导师",
|
||||||
|
"scene": "你获得统一直博资格,方向选择应用数学。从食堂排队到最优解,你决定继续研究数学如何在现实中承担责任。",
|
||||||
|
"choices": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DOMAIN_LABELS = {
|
||||||
|
"number_theory": "数论",
|
||||||
|
"algebraic_geometry": "代数几何",
|
||||||
|
"analysis": "分析",
|
||||||
|
"applied_mathematics": "应用数学",
|
||||||
|
}
|
||||||
|
|
||||||
|
V12_IDENTITY_SCORES = {
|
||||||
|
"0000": {"眼光": 88, "人文": 95, "侦探": 84, "建模": 78, "联结": 87},
|
||||||
|
"0001": {"眼光": 98, "人文": 88, "侦探": 94, "建模": 91, "联结": 99},
|
||||||
|
"0010": {"眼光": 90, "人文": 86, "侦探": 92, "建模": 88, "联结": 84},
|
||||||
|
"0011": {"眼光": 89, "人文": 94, "侦探": 94, "建模": 99, "联结": 92},
|
||||||
|
"0100": {"眼光": 91, "人文": 86, "侦探": 96, "建模": 98, "联结": 90},
|
||||||
|
"0101": {"眼光": 99, "人文": 86, "侦探": 96, "建模": 100, "联结": 100},
|
||||||
|
"0110": {"眼光": 99, "人文": 82, "侦探": 98, "建模": 99, "联结": 100},
|
||||||
|
"0111": {"眼光": 98, "人文": 92, "侦探": 99, "建模": 100, "联结": 99},
|
||||||
|
"1000": {"眼光": 100, "人文": 80, "侦探": 100, "建模": 99, "联结": 99},
|
||||||
|
"1001": {"眼光": 99, "人文": 91, "侦探": 98, "建模": 98, "联结": 100},
|
||||||
|
"1010": {"眼光": 93, "人文": 88, "侦探": 96, "建模": 99, "联结": 91},
|
||||||
|
"1011": {"眼光": 100, "人文": 94, "侦探": 99, "建模": 82, "联结": 100},
|
||||||
|
"1100": {"眼光": 90, "人文": 96, "侦探": 84, "建模": 93, "联结": 98},
|
||||||
|
"1101": {"眼光": 98, "人文": 91, "侦探": 100, "建模": 76, "联结": 99},
|
||||||
|
"1110": {"眼光": 96, "人文": 98, "侦探": 98, "建模": 99, "联结": 95},
|
||||||
|
"1111": {"眼光": 98, "人文": 95, "侦探": 90, "建模": 94, "联结": 100},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_v12_story(source):
|
||||||
|
document = deepcopy(source)
|
||||||
|
document["title"] = "信仰者人生:从小镇到直博"
|
||||||
|
document["description"] = "用印记记录每一次数学抉择,在四个研究方向中找到自己的长期问题。"
|
||||||
|
document["system_version"] = "marks-v1"
|
||||||
|
document["mark_definitions"] = MARK_DEFINITIONS
|
||||||
|
document["domain_labels"] = DOMAIN_LABELS
|
||||||
|
document["ending_rules"] = ENDING_RULES
|
||||||
|
document["chapters"] = {
|
||||||
|
f"c{index}": {"number": index, "title": title}
|
||||||
|
for index, title in enumerate(
|
||||||
|
[
|
||||||
|
"没有竞赛奖状的夏天",
|
||||||
|
"第一次完整证明",
|
||||||
|
"被跳过的引理",
|
||||||
|
"数学之外的时间",
|
||||||
|
"选择一个长期问题",
|
||||||
|
"机器到来之后",
|
||||||
|
"把自己的问题写出来",
|
||||||
|
"统一直博的第一页",
|
||||||
|
],
|
||||||
|
start=1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for node in document["nodes"].values():
|
||||||
|
for choice in node.get("choices", []):
|
||||||
|
choice["effects"] = {}
|
||||||
|
for (node_id, choice_index), mark_codes in MARK_PLACEMENTS.items():
|
||||||
|
choice = document["nodes"][node_id]["choices"][choice_index]
|
||||||
|
choice["effects"] = {"marks": mark_codes}
|
||||||
|
|
||||||
|
for egg in EASTER_EGGS:
|
||||||
|
document["nodes"][egg["trigger"]]["choices"].append(
|
||||||
|
{
|
||||||
|
"text": "✦ 你忽然想起那页草稿……",
|
||||||
|
"next": egg["node"],
|
||||||
|
"requires_marks": egg["required_marks"],
|
||||||
|
"effects": {"easter_eggs": [egg["code"]]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
document["nodes"][egg["node"]] = {
|
||||||
|
"character": egg["name"],
|
||||||
|
"scene": egg["scene"],
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"text": "把这一页收进数学档案",
|
||||||
|
"next": egg["return_to"],
|
||||||
|
"effects": {},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
document["nodes"].update(ENDING_NODES)
|
||||||
|
return document
|
||||||
@@ -1,14 +1,28 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
from math_life.models import Story, StoryChoice, StoryRun, StoryVersion
|
from math_life.models import (
|
||||||
|
Story,
|
||||||
|
StoryChoice,
|
||||||
|
StoryMark,
|
||||||
|
StoryRun,
|
||||||
|
StoryVersion,
|
||||||
|
)
|
||||||
from math_life.services import (
|
from math_life.services import (
|
||||||
|
get_run_payload,
|
||||||
make_choice,
|
make_choice,
|
||||||
score_mathbti,
|
score_mathbti,
|
||||||
start_story,
|
start_story,
|
||||||
validate_story_content,
|
validate_story_content,
|
||||||
)
|
)
|
||||||
|
from math_life.story_v12 import (
|
||||||
|
EASTER_EGGS,
|
||||||
|
MARK_DEFINITIONS,
|
||||||
|
V12_IDENTITY_SCORES,
|
||||||
|
build_v12_story,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -142,3 +156,111 @@ def test_make_choice_负数索引必须拒绝且存档不变(story_setup):
|
|||||||
run = StoryRun.objects.get(id=started["run_id"])
|
run = StoryRun.objects.get(id=started["run_id"])
|
||||||
assert run.current_node == "start"
|
assert run.current_node == "start"
|
||||||
assert StoryChoice.objects.count() == 0
|
assert StoryChoice.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def v12_story_document():
|
||||||
|
import json
|
||||||
|
|
||||||
|
path = settings.PROJECT_ROOT / "docs" / "信仰者线_story.json"
|
||||||
|
return build_v12_story(json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
|
||||||
|
def test_v12_story_包含20印记且完全移除旧数值体系():
|
||||||
|
document = v12_story_document()
|
||||||
|
acquired = {
|
||||||
|
code
|
||||||
|
for node in document["nodes"].values()
|
||||||
|
for choice in node.get("choices", [])
|
||||||
|
for code in choice.get("effects", {}).get("marks", [])
|
||||||
|
}
|
||||||
|
effect_keys = {
|
||||||
|
key
|
||||||
|
for node in document["nodes"].values()
|
||||||
|
for choice in node.get("choices", [])
|
||||||
|
for key in choice.get("effects", {})
|
||||||
|
}
|
||||||
|
|
||||||
|
assert len(MARK_DEFINITIONS) == 20
|
||||||
|
assert acquired == {item["code"] for item in MARK_DEFINITIONS}
|
||||||
|
assert not {"xinzhi", "shuli", "xiayi"} & effect_keys
|
||||||
|
assert validate_story_content(document) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_v12_数学家五维评分均有高分且不使用过低分():
|
||||||
|
assert len(V12_IDENTITY_SCORES) == 16
|
||||||
|
for scores in V12_IDENTITY_SCORES.values():
|
||||||
|
assert set(scores) == {"眼光", "人文", "侦探", "建模", "联结"}
|
||||||
|
assert min(scores.values()) >= 75
|
||||||
|
assert max(scores.values()) >= 90
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_v12_story_印记永久收集_彩蛋开放并按领域分发结局():
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="marks_story_user",
|
||||||
|
password="StrongPass_2026",
|
||||||
|
nickname="印记玩家",
|
||||||
|
)
|
||||||
|
story = Story.objects.create(slug="marks-story", title="印记人生")
|
||||||
|
document = v12_story_document()
|
||||||
|
StoryVersion.objects.create(
|
||||||
|
story=story,
|
||||||
|
version=2,
|
||||||
|
is_published=True,
|
||||||
|
content=document,
|
||||||
|
)
|
||||||
|
started = start_story(user, story)
|
||||||
|
first = make_choice(
|
||||||
|
run_id=started["run_id"],
|
||||||
|
user=user,
|
||||||
|
choice_index=0,
|
||||||
|
idempotency_key="mark-first",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "prime_margin" in first["state"]["marks"]
|
||||||
|
assert StoryMark.objects.filter(
|
||||||
|
user=user,
|
||||||
|
story=story,
|
||||||
|
code="prime_margin",
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
run = StoryRun.objects.get(id=started["run_id"])
|
||||||
|
gauss = next(item for item in EASTER_EGGS if item["code"] == "gauss")
|
||||||
|
run.current_node = gauss["trigger"]
|
||||||
|
run.state = {
|
||||||
|
"marks": gauss["required_marks"],
|
||||||
|
"easter_eggs": [],
|
||||||
|
"mark_counts": {
|
||||||
|
"number_theory": 2,
|
||||||
|
"algebraic_geometry": 0,
|
||||||
|
"analysis": 0,
|
||||||
|
"applied_mathematics": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
run.save(update_fields=["current_node", "state"])
|
||||||
|
payload = get_run_payload(run)
|
||||||
|
assert any(choice["special"] for choice in payload["node"]["choices"])
|
||||||
|
|
||||||
|
run.current_node = "c8_noether_merge"
|
||||||
|
run.state = {
|
||||||
|
"marks": ["epsilon_promise", "late_draft", "limit_definition"],
|
||||||
|
"easter_eggs": [],
|
||||||
|
"mark_counts": {
|
||||||
|
"number_theory": 0,
|
||||||
|
"algebraic_geometry": 0,
|
||||||
|
"analysis": 3,
|
||||||
|
"applied_mathematics": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
run.save(update_fields=["current_node", "state"])
|
||||||
|
ending = make_choice(
|
||||||
|
run_id=run.id,
|
||||||
|
user=user,
|
||||||
|
choice_index=0,
|
||||||
|
idempotency_key="analysis-ending",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ending["status"] == StoryRun.Status.COMPLETED
|
||||||
|
assert ending["current_node"] == "ending_analysis"
|
||||||
|
assert ending["chapter"] == {}
|
||||||
|
assert ending["collection"]
|
||||||
|
|||||||
@@ -1,2 +1,60 @@
|
|||||||
|
|
||||||
# Create your tests here.
|
import pytest
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from accounts.models import User
|
||||||
|
from contest.models import Contest, Question, RatingHistory, RealtimeMatch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_profile_返回实时比赛历史及_rating_变化(client):
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="profile_player",
|
||||||
|
password="StrongPass_2026",
|
||||||
|
nickname="档案玩家",
|
||||||
|
rating=1016,
|
||||||
|
)
|
||||||
|
opponent = User.objects.create_user(
|
||||||
|
username="profile_opponent",
|
||||||
|
password="StrongPass_2026",
|
||||||
|
nickname="对局对手",
|
||||||
|
rating=984,
|
||||||
|
)
|
||||||
|
contest = Contest.objects.create(
|
||||||
|
slug="profile-realtime",
|
||||||
|
title="档案实时赛",
|
||||||
|
kind=Contest.Kind.REALTIME,
|
||||||
|
track=Question.Track.STANDARD,
|
||||||
|
status=Contest.Status.PUBLISHED,
|
||||||
|
)
|
||||||
|
match = RealtimeMatch.objects.create(
|
||||||
|
contest=contest,
|
||||||
|
player_one=user,
|
||||||
|
player_two=opponent,
|
||||||
|
player_one_rating=1000,
|
||||||
|
player_two_rating=1000,
|
||||||
|
winner=user,
|
||||||
|
status=RealtimeMatch.Status.COMPLETED,
|
||||||
|
completed_at=timezone.now(),
|
||||||
|
)
|
||||||
|
RatingHistory.objects.create(
|
||||||
|
user=user,
|
||||||
|
match=match,
|
||||||
|
rating_before=1000,
|
||||||
|
rating_after=1016,
|
||||||
|
delta=16,
|
||||||
|
)
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get("/api/v1/progression/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
history = response.json()["recent_matches"]
|
||||||
|
assert len(history) == 1
|
||||||
|
assert history[0]["match_id"] == str(match.id)
|
||||||
|
assert history[0]["contest"] == "档案实时赛"
|
||||||
|
assert history[0]["opponent"] == "对局对手"
|
||||||
|
assert history[0]["result"] == "win"
|
||||||
|
assert history[0]["rating_delta"] == 16
|
||||||
|
assert history[0]["rating_after"] == 1016
|
||||||
|
assert history[0]["completed_at"]
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
from django.db.models import Prefetch, Q
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from contest.models import RatingHistory, RealtimeMatch
|
||||||
|
|
||||||
from .models import UserAbility, UserPet
|
from .models import UserAbility, UserPet
|
||||||
|
|
||||||
|
|
||||||
@@ -21,6 +24,54 @@ class ProgressionProfileView(APIView):
|
|||||||
"fragments": ability.fragments,
|
"fragments": ability.fragments,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
matches = (
|
||||||
|
RealtimeMatch.objects.filter(
|
||||||
|
Q(player_one=request.user) | Q(player_two=request.user),
|
||||||
|
status=RealtimeMatch.Status.COMPLETED,
|
||||||
|
)
|
||||||
|
.select_related("contest", "player_one", "player_two", "winner")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"rating_changes",
|
||||||
|
queryset=RatingHistory.objects.filter(user=request.user),
|
||||||
|
to_attr="viewer_rating_changes",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-completed_at")[:10]
|
||||||
|
)
|
||||||
|
recent_matches = []
|
||||||
|
for match in matches:
|
||||||
|
opponent = (
|
||||||
|
match.player_two
|
||||||
|
if match.player_one_id == request.user.id
|
||||||
|
else match.player_one
|
||||||
|
)
|
||||||
|
rating_change = (
|
||||||
|
match.viewer_rating_changes[0]
|
||||||
|
if match.viewer_rating_changes
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
recent_matches.append(
|
||||||
|
{
|
||||||
|
"match_id": match.id,
|
||||||
|
"contest": match.contest.title,
|
||||||
|
"opponent": opponent.nickname if opponent else "未知对手",
|
||||||
|
"result": (
|
||||||
|
"draw"
|
||||||
|
if match.winner_id is None
|
||||||
|
else "win"
|
||||||
|
if match.winner_id == request.user.id
|
||||||
|
else "loss"
|
||||||
|
),
|
||||||
|
"rating_delta": rating_change.delta if rating_change else 0,
|
||||||
|
"rating_after": (
|
||||||
|
rating_change.rating_after
|
||||||
|
if rating_change
|
||||||
|
else request.user.rating
|
||||||
|
),
|
||||||
|
"completed_at": match.completed_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"pet": {
|
"pet": {
|
||||||
@@ -52,5 +103,16 @@ class ProgressionProfileView(APIView):
|
|||||||
}
|
}
|
||||||
for attempt in request.user.math_game_attempts.all()[:10]
|
for attempt in request.user.math_game_attempts.all()[:10]
|
||||||
],
|
],
|
||||||
|
"recent_matches": recent_matches,
|
||||||
|
"story_marks": [
|
||||||
|
{
|
||||||
|
"code": mark.code,
|
||||||
|
"name": mark.name,
|
||||||
|
"domain": mark.domain,
|
||||||
|
"story": mark.story.title,
|
||||||
|
"acquired_at": mark.acquired_at,
|
||||||
|
}
|
||||||
|
for mark in request.user.story_marks.select_related("story")
|
||||||
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+87
-19
@@ -50,6 +50,7 @@ button { color: inherit; }
|
|||||||
.sidebar-foot { margin-top: auto; display: grid; gap: 15px; }
|
.sidebar-foot { margin-top: auto; display: grid; gap: 15px; }
|
||||||
.system-status { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em; }
|
.system-status { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em; }
|
||||||
.system-status i { width: 7px; height: 7px; margin-right: 7px; display: inline-block; border-radius: 50%; background: #5bb67c; box-shadow: 0 0 0 4px rgba(91,182,124,.13); }
|
.system-status i { width: 7px; height: 7px; margin-right: 7px; display: inline-block; border-radius: 50%; background: #5bb67c; box-shadow: 0 0 0 4px rgba(91,182,124,.13); }
|
||||||
|
.app-version { color: var(--muted); font-size: 9px; letter-spacing: .1em; }
|
||||||
.ghost-button, .text-button {
|
.ghost-button, .text-button {
|
||||||
background: transparent; border: 1px solid var(--line); border-radius: 10px; padding: 11px 14px; cursor: pointer;
|
background: transparent; border: 1px solid var(--line); border-radius: 10px; padding: 11px 14px; cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -79,6 +80,7 @@ button { color: inherit; }
|
|||||||
content: ""; position: absolute; width: 330px; height: 330px; right: -100px; bottom: -150px;
|
content: ""; position: absolute; width: 330px; height: 330px; right: -100px; bottom: -150px;
|
||||||
border: 55px solid rgba(25,101,72,.09); border-radius: 50%;
|
border: 55px solid rgba(25,101,72,.09); border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
.wechat-entry { display: flex; align-items: center; justify-content: space-between; gap: 28px; margin-top: 18px; padding: 28px 32px; border: 1px solid rgba(25,101,72,.16); border-radius: 20px; background: linear-gradient(120deg, rgba(25,101,72,.08), rgba(204,232,91,.18)); }.wechat-entry h2 { margin: 8px 0; font: 28px Georgia, serif; }.wechat-entry p { margin: 0; color: var(--muted); line-height: 1.7; }.wechat-entry-actions { display: flex; gap: 9px; flex-shrink: 0; }.wechat-entry-actions a { display: inline-flex; align-items: center; text-decoration: none; }
|
||||||
.kicker { color: var(--green); font-size: 10px; font-weight: 700; letter-spacing: .24em; }
|
.kicker { color: var(--green); font-size: 10px; font-weight: 700; letter-spacing: .24em; }
|
||||||
.hero h1, .page-title h1 { margin: 20px 0 18px; font: 500 clamp(42px, 5vw, 76px)/1.03 Georgia, "Songti SC", serif; letter-spacing: -.04em; }
|
.hero h1, .page-title h1 { margin: 20px 0 18px; font: 500 clamp(42px, 5vw, 76px)/1.03 Georgia, "Songti SC", serif; letter-spacing: -.04em; }
|
||||||
.hero h1 em { color: var(--green); font-weight: inherit; }
|
.hero h1 em { color: var(--green); font-weight: inherit; }
|
||||||
@@ -132,6 +134,10 @@ button { color: inherit; }
|
|||||||
.track-switch { display: flex; gap: 5px; margin-bottom: 22px; }
|
.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 { border: 1px solid var(--line); padding: 9px 18px; border-radius: 99px; background: transparent; cursor: pointer; }
|
||||||
.track-switch button.active { background: var(--ink); color: white; }
|
.track-switch button.active { background: var(--ink); color: white; }
|
||||||
|
.match-mode-switch { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 22px; }
|
||||||
|
.match-mode-switch button { border: 1px solid var(--line); border-radius: 14px; padding: 15px 17px; background: rgba(255,255,252,.75); text-align: left; cursor: pointer; }
|
||||||
|
.match-mode-switch b, .match-mode-switch small { display: block; }.match-mode-switch small { margin-top: 5px; color: var(--muted); }
|
||||||
|
.match-mode-switch button.active { border-color: var(--green); background: var(--ink); color: white; }.match-mode-switch button.active small { color: #b9c2bc; }
|
||||||
.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; }
|
.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-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; }
|
.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; }
|
||||||
@@ -139,6 +145,8 @@ button { color: inherit; }
|
|||||||
.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-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-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-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); }
|
.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; }
|
.math-games-section { margin-top: 60px; }
|
||||||
.game-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
.game-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||||
@@ -148,7 +156,7 @@ button { color: inherit; }
|
|||||||
.game-card-top { display: flex; justify-content: space-between; align-items: start; }.game-card-top > span { display: grid; place-items: center; width: 58px; height: 58px; border-radius: 16px; background: var(--ink); color: var(--lime); font: 700 20px Georgia, serif; }.game-card-top small { color: var(--muted); }
|
.game-card-top { display: flex; justify-content: space-between; align-items: start; }.game-card-top > span { display: grid; place-items: center; width: 58px; height: 58px; border-radius: 16px; background: var(--ink); color: var(--lime); font: 700 20px Georgia, serif; }.game-card-top small { color: var(--muted); }
|
||||||
.game-card > b { margin-top: 24px; color: var(--green); font-size: 9px; letter-spacing: .18em; }.game-card h3 { margin: 8px 0; font: 28px Georgia, serif; }.game-card p { margin: 0; color: var(--muted); line-height: 1.7; }
|
.game-card > b { margin-top: 24px; color: var(--green); font-size: 9px; letter-spacing: .18em; }.game-card h3 { margin: 8px 0; font: 28px Georgia, serif; }.game-card p { margin: 0; color: var(--muted); line-height: 1.7; }
|
||||||
.game-card-controls { display: flex; gap: 10px; margin-top: auto; }.game-card-controls select { min-width: 100px; border: 1px solid var(--line); border-radius: 10px; padding: 11px; background: white; }.game-card-controls .primary-button { margin-left: auto; }
|
.game-card-controls { display: flex; gap: 10px; margin-top: auto; }.game-card-controls select { min-width: 100px; border: 1px solid var(--line); border-radius: 10px; padding: 11px; background: white; }.game-card-controls .primary-button { margin-left: auto; }
|
||||||
.twenty-four-numbers { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0; }.twenty-four-numbers button { aspect-ratio: 1; border: 1px solid var(--line); border-radius: 18px; background: var(--ink); color: var(--lime); font: 36px Georgia, serif; cursor: pointer; }
|
.twenty-four-numbers { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0; }.twenty-four-numbers button, .twenty-four-numbers > span { display: grid; place-items: center; aspect-ratio: 1; border: 1px solid var(--line); border-radius: 18px; background: var(--ink); color: var(--lime); font: 36px Georgia, serif; cursor: pointer; }
|
||||||
.twenty-four-form { display: grid; gap: 12px; }.game-keypad { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }.game-keypad button { border: 1px solid var(--line); border-radius: 9px; padding: 10px; background: white; cursor: pointer; }
|
.twenty-four-form { display: grid; gap: 12px; }.game-keypad { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }.game-keypad button { border: 1px solid var(--line); border-radius: 9px; padding: 10px; background: white; cursor: pointer; }
|
||||||
.sudoku-board { width: min(100%, 540px); margin: 22px auto; display: grid; grid-template-columns: repeat(9, 1fr); border: 3px solid var(--ink); background: var(--ink); gap: 1px; }
|
.sudoku-board { width: min(100%, 540px); margin: 22px auto; display: grid; grid-template-columns: repeat(9, 1fr); border: 3px solid var(--ink); background: var(--ink); gap: 1px; }
|
||||||
.sudoku-board input { width: 100%; min-width: 0; aspect-ratio: 1; border: 0; border-radius: 0; background: white; color: var(--green); text-align: center; font: 600 21px Georgia, serif; outline: 2px solid transparent; outline-offset: -2px; }
|
.sudoku-board input { width: 100%; min-width: 0; aspect-ratio: 1; border: 0; border-radius: 0; background: white; color: var(--green); text-align: center; font: 600 21px Georgia, serif; outline: 2px solid transparent; outline-offset: -2px; }
|
||||||
@@ -160,19 +168,35 @@ button { color: inherit; }
|
|||||||
.editor-pane { background: #1d2721; color: white; }
|
.editor-pane { background: #1d2721; color: white; }
|
||||||
.editor-pane label, .preview-pane > span { display: block; color: #9eaaa2; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; }
|
.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; }
|
.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; }
|
.preview-pane { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; }
|
||||||
#latex-preview { margin: auto; max-width: 100%; overflow-wrap: anywhere; font: 28px/1.6 Georgia, serif; }
|
#latex-preview { align-self: center; width: 100%; min-width: 0; max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 18px 4px; font-size: 28px; line-height: 1.6; }
|
||||||
.preview-pane button { align-self: flex-end; }
|
#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: end; justify-self: end; margin-top: 24px; }
|
||||||
.profile-panel { padding: 35px; }
|
.profile-panel { padding: 35px; }
|
||||||
.metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
|
.metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
|
||||||
.metric { background: rgba(25,101,72,.06); border-radius: 14px; padding: 17px; }
|
.metric { background: rgba(25,101,72,.06); border-radius: 14px; padding: 17px; }
|
||||||
.metric b, .metric span { display: block; }.metric b { font: 28px Georgia, serif; }.metric span { margin-top: 5px; color: var(--muted); font-size: 11px; }
|
.metric b, .metric span { display: block; }.metric b { font: 28px Georgia, serif; }.metric span { margin-top: 5px; color: var(--muted); font-size: 11px; }
|
||||||
.profile-subtitle { margin: 30px 0 12px; font: 24px Georgia, serif; }.profile-game-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 9px; }.profile-game-list > div { padding: 13px; border-radius: 11px; background: #eef1eb; }.profile-game-list b, .profile-game-list span { display: block; }.profile-game-list span { margin-top: 5px; color: var(--muted); font-size: 11px; }
|
.profile-subtitle { margin: 30px 0 12px; font: 24px Georgia, serif; }.profile-game-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 9px; }.profile-game-list > div { padding: 13px; border-radius: 11px; background: #eef1eb; }.profile-game-list b, .profile-game-list span { display: block; }.profile-game-list span { margin-top: 5px; color: var(--muted); font-size: 11px; }
|
||||||
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 22px; }
|
.profile-mark-list { display: flex; flex-wrap: wrap; gap: 8px; }.profile-mark-list span { border: 1px solid rgba(25,101,72,.2); border-radius: 99px; padding: 7px 10px; background: rgba(204,232,91,.12); color: var(--green); font-size: 10px; }
|
||||||
|
.home-match-history[hidden] { display: none; }
|
||||||
|
.match-history-list { display: grid; gap: 10px; }
|
||||||
|
.match-history-item { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 18px; border: 1px solid var(--line); border-left: 4px solid var(--green); border-radius: 13px; background: rgba(255,255,252,.8); }
|
||||||
|
.match-history-item.result-loss { border-left-color: #c05245; }.match-history-item.result-draw { border-left-color: #9a8455; }
|
||||||
|
.match-history-item > div, .match-history-item b, .match-history-item span, .match-history-item strong { display: block; }
|
||||||
|
.match-history-item > div:last-child { text-align: right; }.match-history-item span { margin-top: 5px; color: var(--muted); font-size: 11px; }.match-history-item strong { color: var(--green); }
|
||||||
|
.tool-groups { display: grid; grid-template-columns: 1.15fr 1fr .78fr; gap: 14px; margin-bottom: 24px; align-items: stretch; }
|
||||||
|
.tool-group { min-width: 0; padding: 18px; border: 1px solid var(--line); border-radius: 20px; background: rgba(255,255,252,.52); }
|
||||||
|
.tool-group > header { display: flex; align-items: center; gap: 12px; min-height: 52px; margin-bottom: 14px; }
|
||||||
|
.tool-group > header > span { display: grid; place-items: center; width: 35px; height: 35px; flex: 0 0 35px; border-radius: 11px; background: var(--ink); color: var(--lime); font: 11px Georgia, serif; }
|
||||||
|
.tool-group > header b, .tool-group > header small { display: block; }.tool-group > header b { font: 18px Georgia, serif; }.tool-group > header small { margin-top: 4px; color: var(--muted); font-size: 10px; }
|
||||||
|
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 22px; }
|
||||||
|
.tool-group .tool-grid { height: calc(100% - 66px); margin-bottom: 0; }
|
||||||
.tool-card {
|
.tool-card {
|
||||||
min-height: 165px; border: 1px solid var(--line); border-radius: 18px; padding: 22px;
|
min-height: 165px; border: 1px solid var(--line); border-radius: 18px; padding: 22px;
|
||||||
background: rgba(255,255,252,.7); text-align: left; cursor: pointer; transition: .2s ease;
|
background: rgba(255,255,252,.7); text-align: left; cursor: pointer; transition: .2s ease;
|
||||||
}
|
}
|
||||||
|
.tool-group .tool-card { min-height: 148px; padding: 18px; }
|
||||||
.tool-card:hover, .tool-card.active { transform: translateY(-3px); border-color: rgba(25,101,72,.48); box-shadow: 0 16px 35px rgba(38,48,40,.08); }
|
.tool-card:hover, .tool-card.active { transform: translateY(-3px); border-color: rgba(25,101,72,.48); box-shadow: 0 16px 35px rgba(38,48,40,.08); }
|
||||||
.tool-card.active { background: var(--ink); color: white; }
|
.tool-card.active { background: var(--ink); color: white; }
|
||||||
.tool-card > span { display: grid; place-items: center; width: 52px; height: 52px; border-radius: 15px; background: rgba(25,101,72,.08); color: var(--green); font: 700 17px Georgia, serif; }
|
.tool-card > span { display: grid; place-items: center; width: 52px; height: 52px; border-radius: 15px; background: rgba(25,101,72,.08); color: var(--green); font: 700 17px Georgia, serif; }
|
||||||
@@ -184,28 +208,54 @@ button { color: inherit; }
|
|||||||
.workspace-heading { display: flex; justify-content: space-between; gap: 30px; align-items: end; margin-bottom: 25px; }
|
.workspace-heading { display: flex; justify-content: space-between; gap: 30px; align-items: end; margin-bottom: 25px; }
|
||||||
.workspace-heading h2 { margin: 7px 0 0; font: 31px Georgia, serif; }.workspace-heading p { max-width: 480px; color: var(--muted); font-size: 13px; }
|
.workspace-heading h2 { margin: 7px 0 0; font: 31px Georgia, serif; }.workspace-heading p { max-width: 480px; color: var(--muted); font-size: 13px; }
|
||||||
.calculator-shell { max-width: 980px; margin: auto; }
|
.calculator-shell { max-width: 980px; margin: auto; }
|
||||||
|
.advanced-calculator { max-width: 1120px; }
|
||||||
|
.calc-category-tabs { display: flex; gap: 5px; margin-bottom: 16px; padding: 5px; border-radius: 13px; background: #e9ede7; overflow-x: auto; scrollbar-width: thin; }
|
||||||
|
.calc-category-tabs button { flex: 1 0 max-content; border: 0; border-radius: 9px; padding: 10px 14px; background: transparent; color: var(--muted); cursor: pointer; font-size: 12px; font-weight: 650; }
|
||||||
|
.calc-category-tabs button.active { background: var(--ink); color: white; box-shadow: 0 5px 14px rgba(23,33,27,.13); }
|
||||||
.calculator-display { min-height: 145px; border-radius: 18px; padding: 25px; background: var(--ink); color: white; display: flex; flex-direction: column; justify-content: space-between; text-align: right; }
|
.calculator-display { min-height: 145px; border-radius: 18px; padding: 25px; background: var(--ink); color: white; display: flex; flex-direction: column; justify-content: space-between; text-align: right; }
|
||||||
.calculator-display small { color: #9eaaa2; }.calculator-display output { color: var(--lime); font: 48px/1 Georgia, serif; overflow-wrap: anywhere; }
|
.calculator-display small { color: #9eaaa2; }.calculator-display output { color: var(--lime); font: 48px/1 Georgia, serif; overflow-wrap: anywhere; }
|
||||||
.formula-input, .search-input { width: 100%; border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; background: white; outline-color: var(--green); }
|
.formula-input, .search-input { width: 100%; border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; background: white; outline-color: var(--green); }
|
||||||
.calculator-shell > .formula-input { margin: 14px 0; font: 18px "SFMono-Regular", Consolas, monospace; }
|
.calculator-shell > .formula-input { margin: 14px 0; font: 18px "SFMono-Regular", Consolas, monospace; }
|
||||||
.calculator-actions { display: flex; flex-wrap: wrap; gap: 8px; }.calculator-actions button { border: 1px solid var(--line); border-radius: 10px; padding: 11px 15px; background: white; cursor: pointer; }.calculator-actions .primary-button { margin-left: auto; color: white; background: var(--green); border: 0; }
|
.calculator-actions { display: flex; flex-wrap: wrap; gap: 8px; }.calculator-actions button { border: 1px solid var(--line); border-radius: 10px; padding: 11px 15px; background: white; cursor: pointer; }.calculator-actions .primary-button { margin-left: auto; color: white; background: var(--green); border: 0; }
|
||||||
.calculator-controls { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; margin-bottom: 14px; }.calculator-controls label, .calculator-expression-label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; }.calculator-controls select, .calculator-controls input { width: 100%; border: 1px solid var(--line); border-radius: 10px; padding: 11px; background: white; }
|
.calculator-controls { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; margin-bottom: 14px; }.calculator-controls label, .calculator-expression-label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; }.calculator-controls select, .calculator-controls input { width: 100%; border: 1px solid var(--line); border-radius: 10px; padding: 11px; background: white; }
|
||||||
|
.advanced-calculator .calculator-controls > label:first-child { grid-column: span 2; }
|
||||||
.calculator-controls [hidden] { display: none !important; }
|
.calculator-controls [hidden] { display: none !important; }
|
||||||
.calculator-expression-label textarea { min-height: 92px; margin: 0 0 12px; resize: vertical; font: 17px/1.6 "SFMono-Regular", Consolas, monospace; }
|
.calculator-expression-label textarea { min-height: 92px; margin: 0 0 12px; resize: vertical; font: 17px/1.6 "SFMono-Regular", Consolas, monospace; }
|
||||||
|
.calc-hint { margin: -4px 0 14px; color: var(--muted); font-size: 11px; line-height: 1.65; }
|
||||||
.calculator-result { margin-top: 17px; }.calc-result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }.calc-result-grid > div { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: white; }.calc-result-grid span { display: block; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; }.calc-result-grid code { display: block; margin-top: 8px; overflow-wrap: anywhere; color: var(--green); }
|
.calculator-result { margin-top: 17px; }.calc-result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }.calc-result-grid > div { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: white; }.calc-result-grid span { display: block; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; }.calc-result-grid code { display: block; margin-top: 8px; overflow-wrap: anywhere; color: var(--green); }
|
||||||
.calc-steps { margin: 13px 0 0; padding: 14px 14px 14px 34px; border-radius: 12px; background: #eef1eb; color: var(--muted); font-size: 12px; line-height: 1.8; }.calc-steps:empty { display: none; }
|
.calc-steps { margin: 13px 0 0; padding: 14px 14px 14px 34px; border-radius: 12px; background: #eef1eb; color: var(--muted); font-size: 12px; line-height: 1.8; }.calc-steps:empty { display: none; }
|
||||||
.search-input { width: min(370px, 100%); }
|
.search-input { width: min(370px, 100%); }
|
||||||
.symbol-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
.symbol-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||||
.symbol-card { border: 1px solid var(--line); border-radius: 15px; padding: 18px; background: white; cursor: pointer; text-align: left; }
|
.symbol-card { border: 1px solid var(--line); border-radius: 15px; padding: 18px; background: white; cursor: pointer; text-align: left; }
|
||||||
.symbol-card strong { display: block; color: var(--green); font: 29px Georgia, serif; }.symbol-card b { display: block; margin-top: 10px; }.symbol-card small { display: block; margin-top: 5px; color: var(--muted); }.symbol-card code { display: inline-block; margin-top: 12px; padding: 4px 7px; border-radius: 6px; background: #f0f1eb; color: #405048; }
|
.symbol-card strong { display: block; color: var(--green); font: 29px Georgia, serif; }.symbol-card b { display: block; margin-top: 10px; }.symbol-card small { display: block; margin-top: 5px; color: var(--muted); }.symbol-card code { display: inline-block; margin-top: 12px; padding: 4px 7px; border-radius: 6px; background: #f0f1eb; color: #405048; }
|
||||||
.graph-shell { display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 20px; }
|
.graph-shell { display: grid; grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); gap: 18px; align-items: start; }
|
||||||
.graph-controls { display: flex; flex-direction: column; gap: 17px; }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls p { color: #b84136; font-size: 12px; }.graph-controls textarea { min-height: 100px; resize: vertical; font: 13px/1.6 "SFMono-Regular", Consolas, monospace; }
|
.graph-controls { display: flex; min-width: 0; flex-direction: column; gap: 14px; padding: 18px; border: 1px solid var(--line); border-radius: 17px; background: rgba(247,248,243,.86); }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls > p { min-height: 17px; margin: 0; color: #b84136; font-size: 12px; }
|
||||||
|
.function-syntax-help { border: 1px solid var(--line); border-radius: 11px; padding: 12px 14px; background: #f5f7f1; }.function-syntax-help summary { color: var(--green); font-weight: 700; cursor: pointer; }.function-syntax-help p { margin: 9px 0 0; color: var(--muted); line-height: 1.7; }.function-syntax-help code { color: var(--ink); }
|
||||||
.check-row { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 8px !important; }.check-row input { margin: 0; }
|
.check-row { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 8px !important; }.check-row input { margin: 0; }
|
||||||
.graph-analysis { padding: 11px; border-radius: 10px; background: #eef1eb; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
.graph-functions-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }.graph-functions-heading b { font-size: 13px; }.graph-functions-heading button, .graph-actions button { border: 1px solid var(--line); border-radius: 9px; padding: 8px 10px; background: white; color: var(--green); cursor: pointer; font-size: 11px; font-weight: 650; }
|
||||||
#graph-canvas { width: 100%; height: auto; border: 1px solid var(--line); border-radius: 17px; background: #fbfbf7; }
|
.graph-function-list { display: grid; gap: 8px; }
|
||||||
|
.graph-function-row { display: grid; grid-template-columns: 18px 29px auto minmax(70px, 1fr) 29px; gap: 7px; align-items: center; min-width: 0; }
|
||||||
|
.graph-function-row > input[type="checkbox"] { width: 16px; height: 16px; margin: 0; accent-color: var(--green); }
|
||||||
|
.graph-function-row > input[type="color"] { width: 29px; height: 29px; padding: 2px; border: 1px solid var(--line); border-radius: 8px; background: white; cursor: pointer; }
|
||||||
|
.graph-function-row > span { color: var(--muted); font: 11px "SFMono-Regular", Consolas, monospace; white-space: nowrap; }
|
||||||
|
.graph-function-row .formula-input { min-width: 0; padding: 9px 10px; border-radius: 9px; font: 12px "SFMono-Regular", Consolas, monospace; }
|
||||||
|
.graph-function-row > button { width: 29px; height: 29px; border: 1px solid var(--line); border-radius: 8px; background: white; color: #a5443b; cursor: pointer; font-size: 18px; line-height: 1; }.graph-function-row > button:disabled { color: #b8beb9; cursor: not-allowed; }
|
||||||
|
.graph-parameter-control { display: grid; gap: 8px; padding-top: 3px; }.graph-parameter-control label { display: flex; justify-content: space-between; }.graph-parameter-control output { color: var(--green); font-weight: 700; }
|
||||||
|
.graph-viewport-controls { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 12px 0; }.graph-viewport-controls summary { color: var(--ink); font-size: 12px; font-weight: 700; cursor: pointer; }.graph-viewport-controls > .check-row { margin-top: 12px; }
|
||||||
|
.graph-range-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 11px; }.graph-range-grid label { gap: 5px; font-size: 10px; }.graph-range-grid input { min-width: 0; width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: 8px; background: white; color: var(--ink); }
|
||||||
|
.graph-option-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
|
||||||
|
.graph-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }.graph-actions .primary-button { grid-column: 1 / -1; border-color: var(--green); background: var(--green); color: white; }
|
||||||
|
.graph-stage { position: relative; min-width: 0; padding: 10px; border: 1px solid var(--line); border-radius: 19px; background: rgba(255,255,252,.72); overflow: hidden; }
|
||||||
|
.graph-legend { min-height: 29px; display: flex; flex-wrap: wrap; gap: 6px; padding: 0 3px 9px; }.graph-legend span { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 5px 8px; border-radius: 99px; background: #eef1eb; color: var(--muted); font: 10px "SFMono-Regular", Consolas, monospace; overflow-wrap: anywhere; }.graph-legend i { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; }
|
||||||
|
.graph-analysis { margin-top: 9px; padding: 11px; border-radius: 10px; background: #eef1eb; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||||
|
.graph-stage-hint { padding: 8px 4px 0; color: #8b958e; font-size: 10px; text-align: center; }
|
||||||
|
#graph-canvas { display: block; width: 100%; height: auto; aspect-ratio: 16 / 10; border: 1px solid var(--line); border-radius: 14px; background: #fbfbf7; cursor: grab; touch-action: none; user-select: none; }#graph-canvas.dragging { cursor: grabbing; }
|
||||||
.drawing-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; }.drawing-toolbar button, .drawing-toolbar label { border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; background: white; color: var(--muted); cursor: pointer; }.drawing-toolbar button.active { border-color: var(--ink); background: var(--ink); color: white; }.drawing-toolbar .primary-button { margin-left: auto; border: 0; background: var(--green); color: white; }.drawing-toolbar label { display: flex; align-items: center; gap: 7px; font-size: 11px; }.drawing-toolbar input[type=color] { width: 28px; height: 24px; padding: 0; border: 0; background: transparent; }
|
.drawing-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; }.drawing-toolbar button, .drawing-toolbar label { border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; background: white; color: var(--muted); cursor: pointer; }.drawing-toolbar button.active { border-color: var(--ink); background: var(--ink); color: white; }.drawing-toolbar .primary-button { margin-left: auto; border: 0; background: var(--green); color: white; }.drawing-toolbar label { display: flex; align-items: center; gap: 7px; font-size: 11px; }.drawing-toolbar input[type=color] { width: 28px; height: 24px; padding: 0; border: 0; background: transparent; }
|
||||||
.toolbar-text-input { flex: 1 1 190px; min-width: 150px; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.drawing-toolbar .file-tool input { display: none; }
|
.toolbar-text-input { flex: 1 1 190px; min-width: 150px; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.drawing-toolbar .file-tool input { display: none; }
|
||||||
.canvas-stage { width: 100%; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: white; box-shadow: inset 0 0 0 1px rgba(255,255,255,.6); }.canvas-stage canvas { display: block; width: 100%; height: auto; touch-action: none; cursor: crosshair; }.geometry-stage { background: #fbfbf7; }.canvas-hint { color: var(--muted); font-size: 11px; text-align: center; }
|
.canvas-stage { width: 100%; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: white; box-shadow: inset 0 0 0 1px rgba(255,255,255,.6); }.canvas-stage canvas { display: block; width: 100%; height: auto; touch-action: none; cursor: crosshair; }.geometry-stage { background: #fbfbf7; }.canvas-hint { color: var(--muted); font-size: 11px; text-align: center; }
|
||||||
|
.board-mode-switch { display: flex; gap: 8px; margin-bottom: 12px; }.board-mode-switch button { border: 1px solid var(--line); border-radius: 99px; padding: 9px 16px; background: white; cursor: pointer; }.board-mode-switch button.active { border-color: var(--ink); background: var(--ink); color: white; }
|
||||||
|
.board-online-panel { display: grid; grid-template-columns: auto minmax(250px, 1fr); gap: 10px 16px; align-items: center; margin-bottom: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(120deg, rgba(25,101,72,.06), rgba(204,232,91,.1)); }.board-online-panel > div, .board-online-panel form { display: flex; gap: 8px; }.board-online-panel select, .board-online-panel input { min-width: 0; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.board-online-panel p { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: 11px; }.board-online-panel > strong { grid-column: 1 / -1; color: var(--green); }.board-online-panel [hidden] { display: none !important; }
|
||||||
|
.board-pane { display: none; }.board-pane.active { display: block; }
|
||||||
.discover-title { padding-bottom: 24px; }
|
.discover-title { padding-bottom: 24px; }
|
||||||
.ability-map { border: 1px solid var(--line); border-radius: var(--radius); padding: 28px; background: rgba(255,255,252,.64); overflow: hidden; }
|
.ability-map { border: 1px solid var(--line); border-radius: var(--radius); padding: 28px; background: rgba(255,255,252,.64); overflow: hidden; }
|
||||||
.map-heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; }.map-heading h2 { margin: 0 0 6px; font: 30px Georgia, serif; }.map-heading p { margin: 0; color: var(--muted); font-size: 13px; }
|
.map-heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; }.map-heading h2 { margin: 0 0 6px; font: 30px Georgia, serif; }.map-heading p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||||
@@ -219,10 +269,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 { 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); }
|
.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; }
|
.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; }
|
.video-browser { margin-top: 28px; }.filter-row { display: flex; flex-wrap: wrap; gap: 10px; 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-dropdown { position: relative; }
|
||||||
.filter-chip.active { background: var(--ink); border-color: var(--ink); color: white; }
|
.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; }
|
||||||
.discipline-filters .filter-chip { border-radius: 9px; }
|
.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-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-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; }
|
.video-card { border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,252,.82); overflow: hidden; cursor: pointer; transition: .2s ease; }
|
||||||
@@ -244,6 +300,16 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
|
|||||||
.hidden { display: none !important; }.form-error { min-height: 18px; color: #b84136; font-size: 12px; }
|
.hidden { display: none !important; }.form-error { min-height: 18px; color: #b84136; font-size: 12px; }
|
||||||
.experience-dialog { width: min(760px, calc(100% - 30px)); }
|
.experience-dialog { width: min(760px, calc(100% - 30px)); }
|
||||||
.experience-dialog h2 { font: 34px Georgia, serif; }.experience-dialog .scene { white-space: pre-line; line-height: 1.9; color: #3e4942; }
|
.experience-dialog h2 { font: 34px Georgia, serif; }.experience-dialog .scene { white-space: pre-line; line-height: 1.9; color: #3e4942; }
|
||||||
|
.story-open { overflow: hidden; }
|
||||||
|
.story-experience { position: fixed; inset: 0; z-index: 120; overflow-y: auto; padding: 28px clamp(20px, 5vw, 78px) 60px; background: radial-gradient(circle at 86% 8%, rgba(204,232,91,.18), transparent 30%), linear-gradient(145deg, #f7f6ef, #ecefe8); }
|
||||||
|
.story-experience.hidden { display: none; }
|
||||||
|
.story-experience-header { position: sticky; top: 0; z-index: 3; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 0 18px; background: linear-gradient(#f7f6ef 72%, transparent); }.story-experience-header h1 { margin: 7px 0 0; font: clamp(30px, 4vw, 52px) Georgia, serif; }
|
||||||
|
.story-chapter-bar { display: grid; grid-template-columns: minmax(210px, .4fr) 1fr; align-items: end; gap: 25px; margin: 12px 0 24px; }.story-chapter-bar span, .story-chapter-bar b { display: block; }.story-chapter-bar span { color: var(--green); font-size: 11px; letter-spacing: .12em; }.story-chapter-bar b { margin-top: 5px; font: 20px Georgia, serif; }
|
||||||
|
.story-experience-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 340px); gap: 24px; align-items: start; }
|
||||||
|
.story-scene-panel { min-height: min(620px, calc(100dvh - 210px)); padding: clamp(28px, 5vw, 64px); border: 1px solid var(--line); border-radius: 24px; background: rgba(255,255,252,.9); box-shadow: 0 28px 80px rgba(38,48,40,.08); }.story-scene-panel h2 { margin: 13px 0 20px; font: clamp(30px, 4vw, 48px) Georgia, serif; }.story-scene-panel .scene { min-height: 180px; white-space: pre-line; color: #354139; font-size: clamp(16px, 1.8vw, 20px); line-height: 2; }.story-scene-panel .choice-list { margin-top: 36px; }
|
||||||
|
.story-special-choice { border-color: #a47b20 !important; background: #fff4c7 !important; color: #74520c !important; box-shadow: 0 8px 24px rgba(164,123,32,.12); }
|
||||||
|
.story-mark-panel { position: sticky; top: 112px; padding: 24px; border-radius: 20px; background: var(--ink); color: white; }.story-mark-panel h2 { margin: 8px 0 20px; font: 28px Georgia, serif; }.story-domain-counts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 18px; }.story-domain-counts div { padding: 9px 4px; border-radius: 9px; background: rgba(255,255,255,.07); text-align: center; }.story-domain-counts b, .story-domain-counts span { display: block; }.story-domain-counts b { color: var(--lime); font-size: 20px; }.story-domain-counts span { margin-top: 3px; color: #aeb9b1; font-size: 8px; }
|
||||||
|
.story-mark-list { display: flex; flex-wrap: wrap; gap: 7px; }.story-mark { border: 1px solid rgba(255,255,255,.12); border-radius: 99px; padding: 6px 9px; color: #748078; font-size: 9px; }.story-mark.collected { border-color: rgba(204,232,91,.45); color: var(--lime); }.story-mark.current-run { background: rgba(204,232,91,.12); }
|
||||||
.choice-list { display: grid; gap: 9px; margin-top: 25px; }
|
.choice-list { display: grid; gap: 9px; margin-top: 25px; }
|
||||||
.choice-list button { text-align: left; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: white; cursor: pointer; }
|
.choice-list button { text-align: left; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: white; cursor: pointer; }
|
||||||
.choice-list button:hover { border-color: var(--green); }
|
.choice-list button:hover { border-color: var(--green); }
|
||||||
@@ -259,7 +325,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
|
|||||||
.app-shell { grid-template-columns: 80px 1fr; }.sidebar { padding: 25px 13px; }.brand span:last-child, .nav-item:not(.active) span, .nav-item { font-size: 0; }
|
.app-shell { grid-template-columns: 80px 1fr; }.sidebar { padding: 25px 13px; }.brand span:last-child, .nav-item:not(.active) span, .nav-item { font-size: 0; }
|
||||||
.nav-item { text-align: center; }.nav-item span { width: auto; font-size: 11px !important; }.sidebar-foot { display: none; }
|
.nav-item { text-align: center; }.nav-item span { width: auto; font-size: 11px !important; }.sidebar-foot { display: none; }
|
||||||
.hero-grid { grid-template-columns: 1fr; }.daily-card { min-height: 350px; }.spirit-grid { grid-template-columns: 1fr 1fr; }.content-grid { grid-template-columns: 1fr 1fr; }
|
.hero-grid { grid-template-columns: 1fr; }.daily-card { min-height: 350px; }.spirit-grid { grid-template-columns: 1fr 1fr; }.content-grid { grid-template-columns: 1fr 1fr; }
|
||||||
.tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 1fr; }.calculator-controls { grid-template-columns: repeat(2, 1fr); }
|
.tool-groups, .graph-shell { grid-template-columns: 1fr; }.tool-group .tool-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 1fr; }.calculator-controls { grid-template-columns: repeat(2, 1fr); }
|
||||||
}
|
}
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.app-shell { display: block; }.sidebar { position: fixed; top: auto; bottom: 0; width: 100%; height: 68px; z-index: 10; border: 0; border-top: 1px solid var(--line); padding: 8px; }
|
.app-shell { display: block; }.sidebar { position: fixed; top: auto; bottom: 0; width: 100%; height: 68px; z-index: 10; border: 0; border-top: 1px solid var(--line); padding: 8px; }
|
||||||
@@ -268,7 +334,12 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
|
|||||||
.hero { min-height: 500px; padding: 35px 25px; }.hero h1 { font-size: 44px; }.hero-actions { align-items: stretch; flex-direction: column; }
|
.hero { min-height: 500px; padding: 35px 25px; }.hero h1 { font-size: 44px; }.hero-actions { align-items: stretch; flex-direction: column; }
|
||||||
.section-heading { display: block; }.section-heading p { margin-top: 12px; }.spirit-grid, .content-grid, .editor-shell, .metric-grid, .tool-grid, .symbol-grid, .video-grid, .graph-shell, .game-card-grid, .calc-result-grid { grid-template-columns: 1fr; }
|
.section-heading { display: block; }.section-heading p { margin-top: 12px; }.spirit-grid, .content-grid, .editor-shell, .metric-grid, .tool-grid, .symbol-grid, .video-grid, .graph-shell, .game-card-grid, .calc-result-grid { grid-template-columns: 1fr; }
|
||||||
.content-grid { gap: 11px; }.editor-shell { min-height: 700px; }.page-title { padding-top: 42px; }.page-title h1 { font-size: 45px; }
|
.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; }
|
.preview-pane { min-height: 320px; }.match-history-item { align-items: flex-start; flex-direction: column; }.match-history-item > div:last-child { text-align: left; }
|
||||||
|
.match-mode-switch { grid-template-columns: 1fr; }
|
||||||
|
.board-online-panel { grid-template-columns: 1fr; }.board-online-panel > div, .board-online-panel form { display: grid; grid-template-columns: 1fr; }.board-online-panel p, .board-online-panel > strong { grid-column: 1; }
|
||||||
|
.story-experience { padding: 14px 14px 40px; }.story-experience-header { align-items: flex-start; }.story-experience-header .ghost-button { width: auto; padding: 9px 11px; font-size: 10px; }.story-chapter-bar, .story-experience-layout { grid-template-columns: 1fr; }.story-scene-panel { min-height: 0; padding: 25px 20px; }.story-scene-panel .scene { min-height: 120px; }.story-mark-panel { position: static; }.story-domain-counts { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.wechat-entry { align-items: flex-start; flex-direction: column; padding: 24px; }.wechat-entry-actions { width: 100%; display: grid; grid-template-columns: 1fr 1fr; }.wechat-entry-actions > * { justify-content: center; text-align: center; }
|
||||||
|
.tool-group { padding: 14px; }.tool-group .tool-grid { grid-template-columns: 1fr; }.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; }.advanced-calculator .calculator-controls > label:first-child { grid-column: 1 / -1; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.graph-controls { padding: 14px; }.graph-function-row { grid-template-columns: 17px 27px auto minmax(60px, 1fr) 27px; gap: 5px; }.graph-function-row > input[type="color"], .graph-function-row > button { width: 27px; height: 27px; }.graph-range-grid { grid-template-columns: 1fr 1fr; }#graph-canvas { aspect-ratio: 4 / 3; }.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; }
|
.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; }
|
.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-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; }
|
||||||
@@ -282,9 +353,6 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
|
|||||||
.life-map { margin-top: 28px; }
|
.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-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-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 {
|
.route-card {
|
||||||
position: relative; width: 100%; padding: 28px 26px 24px; border-radius: 22px; z-index: 2;
|
position: relative; width: 100%; padding: 28px 26px 24px; border-radius: 22px; z-index: 2;
|
||||||
@@ -338,7 +406,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
|
|||||||
|
|
||||||
@media (max-width: 1050px) {
|
@media (max-width: 1050px) {
|
||||||
.life-map-canvas { grid-template-columns: 1fr; }
|
.life-map-canvas { grid-template-columns: 1fr; }
|
||||||
.life-map-lines, .life-map-center { display: none; }
|
.life-map-lines { display: none; }
|
||||||
.alumni-entry-content { flex-direction: column; align-items: flex-start; }
|
.alumni-entry-content { flex-direction: column; align-items: flex-start; }
|
||||||
.alumni-entry-action { align-items: flex-start; }
|
.alumni-entry-action { align-items: flex-start; }
|
||||||
.skill-grid { grid-template-columns: 1fr 1fr; }
|
.skill-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
|||||||
+248
-111
@@ -29,7 +29,7 @@ const state = {
|
|||||||
user: null,
|
user: null,
|
||||||
stories: [],
|
stories: [],
|
||||||
contests: [],
|
contests: [],
|
||||||
track: "standard",
|
matchMode: "quiz",
|
||||||
videoCatalog: null,
|
videoCatalog: null,
|
||||||
videos: [],
|
videos: [],
|
||||||
videoAbility: "",
|
videoAbility: "",
|
||||||
@@ -83,6 +83,7 @@ function collectApiErrorMessages(value, messages = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function logApiError(error) {
|
function logApiError(error) {
|
||||||
|
if (error.status === 401 || error.status === 403) return;
|
||||||
console.error("[Hulumath API]", {
|
console.error("[Hulumath API]", {
|
||||||
method: error.method,
|
method: error.method,
|
||||||
path: error.path,
|
path: error.path,
|
||||||
@@ -147,6 +148,16 @@ function showToast(message) {
|
|||||||
window.setTimeout(() => toast.classList.remove("show"), 2600);
|
window.setTimeout(() => toast.classList.remove("show"), 2600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyWechatName() {
|
||||||
|
const name = $("#wechat-copy").dataset.wechatName;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(name);
|
||||||
|
showToast(`已复制公众号名称:${name}`);
|
||||||
|
} catch {
|
||||||
|
showToast(`请在微信内搜索:${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function navigate(view) {
|
function navigate(view) {
|
||||||
$$(".nav-item").forEach((button) => button.classList.toggle("active", button.dataset.view === view));
|
$$(".nav-item").forEach((button) => button.classList.toggle("active", button.dataset.view === view));
|
||||||
$$(".view").forEach((section) => section.classList.toggle("active", section.id === `view-${view}`));
|
$$(".view").forEach((section) => section.classList.toggle("active", section.id === `view-${view}`));
|
||||||
@@ -177,6 +188,7 @@ function updateUserUI() {
|
|||||||
chip.append(avatar, name);
|
chip.append(avatar, name);
|
||||||
$("#auth-button").textContent = state.user ? "退出登录" : "登录 / 注册";
|
$("#auth-button").textContent = state.user ? "退出登录" : "登录 / 注册";
|
||||||
$("#admin-entry").hidden = !state.user?.is_staff;
|
$("#admin-entry").hidden = !state.user?.is_staff;
|
||||||
|
if (!state.user) $("#home-match-history").hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUser() {
|
async function loadUser() {
|
||||||
@@ -187,6 +199,7 @@ async function loadUser() {
|
|||||||
state.user = null;
|
state.user = null;
|
||||||
}
|
}
|
||||||
updateUserUI();
|
updateUserUI();
|
||||||
|
await loadHomeMatchHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function card({ meta, title, body, foot, action, onClick, disabled = false }) {
|
function card({ meta, title, body, foot, action, onClick, disabled = false }) {
|
||||||
@@ -310,18 +323,65 @@ async function beginStory(slug) {
|
|||||||
try {
|
try {
|
||||||
const run = await api(`math-life/stories/${slug}/start/`, { method: "POST", body: {} });
|
const run = await api(`math-life/stories/${slug}/start/`, { method: "POST", body: {} });
|
||||||
renderStoryNode(run);
|
renderStoryNode(run);
|
||||||
$("#experience-dialog").showModal();
|
$("#story-experience").classList.remove("hidden");
|
||||||
|
document.body.classList.add("story-open");
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message);
|
showToast(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeStoryExperience() {
|
||||||
|
$("#story-experience").classList.add("hidden");
|
||||||
|
document.body.classList.remove("story-open");
|
||||||
|
navigate("life");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStoryMarks(run) {
|
||||||
|
const collected = new Set(run.collection.map((mark) => mark.code));
|
||||||
|
const current = new Set(run.state.marks || []);
|
||||||
|
const labels = run.domain_labels || {};
|
||||||
|
const counts = $("#story-domain-counts");
|
||||||
|
counts.replaceChildren(
|
||||||
|
...Object.entries(labels).map(([domain, label]) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
const value = document.createElement("b");
|
||||||
|
value.textContent = run.state.mark_counts?.[domain] || 0;
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.textContent = label;
|
||||||
|
item.append(value, name);
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const list = $("#story-mark-list");
|
||||||
|
list.replaceChildren(
|
||||||
|
...run.mark_definitions.map((mark) => {
|
||||||
|
const item = document.createElement("span");
|
||||||
|
item.className = [
|
||||||
|
"story-mark",
|
||||||
|
collected.has(mark.code) ? "collected" : "",
|
||||||
|
current.has(mark.code) ? "current-run" : "",
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
item.dataset.domain = mark.domain;
|
||||||
|
item.textContent = mark.name;
|
||||||
|
item.title = `${labels[mark.domain] || mark.domain}印记`;
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderStoryNode(run) {
|
function renderStoryNode(run) {
|
||||||
const root = $("#experience-content");
|
const root = $("#story-experience-content");
|
||||||
root.replaceChildren();
|
root.replaceChildren();
|
||||||
|
$("#story-experience-title").textContent = run.story;
|
||||||
|
const chapterNumber = run.chapter?.number || (run.status === "completed" ? 8 : 1);
|
||||||
|
$("#story-chapter-number").textContent = `第 ${chapterNumber} 章`;
|
||||||
|
$("#story-chapter-title").textContent = run.chapter?.title || "直博方向";
|
||||||
|
$("#story-chapter-progress").style.width = `${Math.min(100, chapterNumber / 8 * 100)}%`;
|
||||||
|
renderStoryMarks(run);
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.className = "kicker";
|
label.className = "kicker";
|
||||||
label.textContent = `${run.story} · ${run.current_node}`;
|
label.textContent = `CHAPTER ${String(chapterNumber).padStart(2, "0")} · ${run.current_node}`;
|
||||||
const title = document.createElement("h2");
|
const title = document.createElement("h2");
|
||||||
title.textContent = run.node.character || "旁白";
|
title.textContent = run.node.character || "旁白";
|
||||||
const scene = document.createElement("p");
|
const scene = document.createElement("p");
|
||||||
@@ -330,13 +390,20 @@ function renderStoryNode(run) {
|
|||||||
const choices = document.createElement("div");
|
const choices = document.createElement("div");
|
||||||
choices.className = "choice-list";
|
choices.className = "choice-list";
|
||||||
if (run.status === "completed") {
|
if (run.status === "completed") {
|
||||||
|
root.classList.add("completed");
|
||||||
const ending = document.createElement("p");
|
const ending = document.createElement("p");
|
||||||
ending.textContent = "这段人生已经抵达结局,路径已写入你的数学档案。";
|
ending.textContent = "统一直博的方向已经确定。这段人生、结局与全部印记已写入你的数学档案。";
|
||||||
choices.append(ending);
|
const back = document.createElement("button");
|
||||||
|
back.className = "primary-button";
|
||||||
|
back.textContent = "返回数学人生大厅";
|
||||||
|
back.addEventListener("click", closeStoryExperience);
|
||||||
|
choices.append(ending, back);
|
||||||
} else {
|
} else {
|
||||||
|
root.classList.remove("completed");
|
||||||
run.node.choices.forEach((choice) => {
|
run.node.choices.forEach((choice) => {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.textContent = choice.text;
|
button.textContent = choice.text;
|
||||||
|
if (choice.special) button.classList.add("story-special-choice");
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try {
|
try {
|
||||||
@@ -443,12 +510,21 @@ async function loadContests() {
|
|||||||
|
|
||||||
function renderContests() {
|
function renderContests() {
|
||||||
const root = $("#contest-list");
|
const root = $("#contest-list");
|
||||||
const contests = state.contests.filter((contest) => contest.track === state.track);
|
const contests = ["realtime", "daily", "practice"]
|
||||||
|
.map((kind) => {
|
||||||
|
const candidates = state.contests.filter((contest) => contest.kind === kind);
|
||||||
|
return (
|
||||||
|
candidates.find((contest) => contest.track === "open") ||
|
||||||
|
candidates.find((contest) => contest.track === "standard") ||
|
||||||
|
candidates[0]
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
root.classList.remove("loading");
|
root.classList.remove("loading");
|
||||||
root.replaceChildren(...contests.map((contest) => card({
|
root.replaceChildren(...contests.map((contest) => card({
|
||||||
meta: contest.kind === "realtime" ? "实时竞技" : contest.kind === "daily" ? "今日挑战" : "自由练习",
|
meta: contest.kind === "realtime" ? "实时竞技" : contest.kind === "daily" ? "今日挑战" : "自由练习",
|
||||||
title: contest.title,
|
title: contest.title.replace(/^(入门|标准|进阶)/, ""),
|
||||||
body: contest.kind === "realtime" ? "Rating 匹配,同题序列,服务端计时和唯一结算。" : "完成整组题目,正式答案只在提交后显示。",
|
body: contest.kind === "realtime" ? "统一玩家池,支持口算、数独与 24 点,服务端计时和 Rating 结算。" : "每次随机抽取题目,正式答案只在提交后显示。",
|
||||||
foot: `${contest.duration_seconds} 秒`,
|
foot: `${contest.duration_seconds} 秒`,
|
||||||
action: contest.kind === "realtime" ? "开始匹配 →" : "开始答题 →",
|
action: contest.kind === "realtime" ? "开始匹配 →" : "开始答题 →",
|
||||||
onClick: () => beginContest(contest),
|
onClick: () => beginContest(contest),
|
||||||
@@ -526,27 +602,46 @@ function renderAttempt(attempt) {
|
|||||||
root.append(label, title, form);
|
root.append(label, title, form);
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterChip(label, value, type, active) {
|
function filterOption(label, value, type, active) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.className = `filter-chip${active ? " active" : ""}`;
|
button.className = `filter-option${active ? " active" : ""}`;
|
||||||
button.textContent = label;
|
button.textContent = label;
|
||||||
button.dataset[type] = value;
|
button.dataset[type] = value;
|
||||||
|
button.setAttribute("role", "option");
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
if (type === "ability") state.videoAbility = value;
|
if (type === "ability") state.videoAbility = value;
|
||||||
if (type === "discipline") state.videoDiscipline = value;
|
if (type === "discipline") state.videoDiscipline = value;
|
||||||
renderVideoFilters();
|
renderVideoFilters();
|
||||||
renderVideos();
|
renderVideos();
|
||||||
|
closeFilterDropdowns();
|
||||||
});
|
});
|
||||||
return button;
|
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() {
|
function renderVideoFilters() {
|
||||||
if (!state.videoCatalog) return;
|
if (!state.videoCatalog) return;
|
||||||
const abilityRoot = $("#ability-filters");
|
|
||||||
abilityRoot.replaceChildren(
|
const abilityPanel = $("#ability-panel");
|
||||||
filterChip(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility),
|
abilityPanel.replaceChildren(
|
||||||
|
filterOption(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility),
|
||||||
...state.videoCatalog.abilities.map((ability) =>
|
...state.videoCatalog.abilities.map((ability) =>
|
||||||
filterChip(
|
filterOption(
|
||||||
`${ability.icon} ${ability.label} ${ability.count}`,
|
`${ability.icon} ${ability.label} ${ability.count}`,
|
||||||
ability.id,
|
ability.id,
|
||||||
"ability",
|
"ability",
|
||||||
@@ -554,11 +649,14 @@ function renderVideoFilters() {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const disciplineRoot = $("#discipline-filters");
|
const abilityLabel = state.videoCatalog.abilities.find((a) => a.id === state.videoAbility);
|
||||||
disciplineRoot.replaceChildren(
|
$("#ability-label").textContent = abilityLabel ? `${abilityLabel.icon} ${abilityLabel.label}` : "全部维度";
|
||||||
filterChip("全部专业", "", "discipline", !state.videoDiscipline),
|
|
||||||
|
const disciplinePanel = $("#discipline-panel");
|
||||||
|
disciplinePanel.replaceChildren(
|
||||||
|
filterOption("全部专业", "", "discipline", !state.videoDiscipline),
|
||||||
...state.videoCatalog.disciplines.map((discipline) =>
|
...state.videoCatalog.disciplines.map((discipline) =>
|
||||||
filterChip(
|
filterOption(
|
||||||
`${discipline.icon || "∑"} ${discipline.name}`,
|
`${discipline.icon || "∑"} ${discipline.name}`,
|
||||||
discipline.name,
|
discipline.name,
|
||||||
"discipline",
|
"discipline",
|
||||||
@@ -566,11 +664,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(
|
$("#video-stream-title").textContent = abilityLabel ? `${abilityLabel.label}视频流` : "全部视频";
|
||||||
(item) => item.id === state.videoAbility
|
|
||||||
);
|
|
||||||
$("#video-stream-title").textContent = selected ? `${selected.label}视频流` : "全部视频";
|
|
||||||
$$(".ability-node").forEach((node) => {
|
$$(".ability-node").forEach((node) => {
|
||||||
node.classList.toggle("active", node.dataset.ability === state.videoAbility);
|
node.classList.toggle("active", node.dataset.ability === state.videoAbility);
|
||||||
});
|
});
|
||||||
@@ -785,6 +882,29 @@ async function loadProfile() {
|
|||||||
metrics.append(item);
|
metrics.append(item);
|
||||||
});
|
});
|
||||||
root.append(heading, pet, metrics);
|
root.append(heading, pet, metrics);
|
||||||
|
if (profile.story_marks?.length) {
|
||||||
|
const markTitle = document.createElement("h3");
|
||||||
|
markTitle.className = "profile-subtitle";
|
||||||
|
markTitle.textContent = `数学人生印记 · ${profile.story_marks.length} / 20`;
|
||||||
|
const markList = document.createElement("div");
|
||||||
|
markList.className = "profile-mark-list";
|
||||||
|
profile.story_marks.forEach((mark) => {
|
||||||
|
const item = document.createElement("span");
|
||||||
|
item.textContent = mark.name;
|
||||||
|
item.dataset.domain = mark.domain;
|
||||||
|
markList.append(item);
|
||||||
|
});
|
||||||
|
root.append(markTitle, markList);
|
||||||
|
}
|
||||||
|
if (profile.recent_matches?.length) {
|
||||||
|
const matchTitle = document.createElement("h3");
|
||||||
|
matchTitle.className = "profile-subtitle";
|
||||||
|
matchTitle.textContent = "最近实时对局";
|
||||||
|
const matchList = document.createElement("div");
|
||||||
|
matchList.className = "match-history-list";
|
||||||
|
renderMatchHistory(matchList, profile.recent_matches);
|
||||||
|
root.append(matchTitle, matchList);
|
||||||
|
}
|
||||||
if (profile.recent_games?.length) {
|
if (profile.recent_games?.length) {
|
||||||
const gameTitle = document.createElement("h3");
|
const gameTitle = document.createElement("h3");
|
||||||
gameTitle.className = "profile-subtitle";
|
gameTitle.className = "profile-subtitle";
|
||||||
@@ -794,7 +914,7 @@ async function loadProfile() {
|
|||||||
profile.recent_games.forEach((game) => {
|
profile.recent_games.forEach((game) => {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
const name = document.createElement("b");
|
const name = document.createElement("b");
|
||||||
name.textContent = `${game.label} · ${game.difficulty}`;
|
name.textContent = game.label;
|
||||||
const detail = document.createElement("span");
|
const detail = document.createElement("span");
|
||||||
detail.textContent =
|
detail.textContent =
|
||||||
game.status === "completed"
|
game.status === "completed"
|
||||||
@@ -810,6 +930,51 @@ async function loadProfile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMatchHistory(root, matches) {
|
||||||
|
root.replaceChildren(
|
||||||
|
...matches.map((match) => {
|
||||||
|
const item = document.createElement("article");
|
||||||
|
item.className = `match-history-item result-${match.result}`;
|
||||||
|
const summary = document.createElement("div");
|
||||||
|
const title = document.createElement("b");
|
||||||
|
title.textContent = `${match.contest} · 对手 ${match.opponent}`;
|
||||||
|
const time = document.createElement("span");
|
||||||
|
time.textContent = new Date(match.completed_at).toLocaleString();
|
||||||
|
summary.append(title, time);
|
||||||
|
const result = document.createElement("div");
|
||||||
|
const resultLabel = document.createElement("strong");
|
||||||
|
resultLabel.textContent =
|
||||||
|
match.result === "win" ? "胜利" : match.result === "loss" ? "惜败" : "平局";
|
||||||
|
const delta = document.createElement("span");
|
||||||
|
const sign = match.rating_delta > 0 ? "+" : "";
|
||||||
|
delta.textContent = `Rating ${sign}${match.rating_delta} → ${match.rating_after}`;
|
||||||
|
result.append(resultLabel, delta);
|
||||||
|
item.append(summary, result);
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHomeMatchHistory() {
|
||||||
|
const section = $("#home-match-history");
|
||||||
|
if (!state.user) {
|
||||||
|
section.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const profile = await api("progression/me/");
|
||||||
|
if (!profile.recent_matches?.length) {
|
||||||
|
section.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderMatchHistory($("#home-match-list"), profile.recent_matches);
|
||||||
|
section.hidden = false;
|
||||||
|
} catch (error) {
|
||||||
|
section.hidden = true;
|
||||||
|
if (![401, 403].includes(error.status)) console.warn(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MATH_SYMBOLS = [
|
const MATH_SYMBOLS = [
|
||||||
["∑", "求和", "一组数或表达式的总和", "\\sum"],
|
["∑", "求和", "一组数或表达式的总和", "\\sum"],
|
||||||
["∏", "连乘", "一组数或表达式的乘积", "\\prod"],
|
["∏", "连乘", "一组数或表达式的乘积", "\\prod"],
|
||||||
@@ -944,18 +1109,6 @@ function evaluateExpression(source, variables = {}) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCalculator() {
|
|
||||||
const input = $("#calc-input").value;
|
|
||||||
try {
|
|
||||||
const result = evaluateExpression(input);
|
|
||||||
$("#calc-history").textContent = input;
|
|
||||||
$("#calc-output").textContent = Number(result.toPrecision(12)).toString();
|
|
||||||
} catch (error) {
|
|
||||||
$("#calc-history").textContent = error.message;
|
|
||||||
$("#calc-output").textContent = "错误";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSymbols(query = "") {
|
function renderSymbols(query = "") {
|
||||||
const keyword = query.trim().toLowerCase();
|
const keyword = query.trim().toLowerCase();
|
||||||
const matches = MATH_SYMBOLS.filter((symbol) =>
|
const matches = MATH_SYMBOLS.filter((symbol) =>
|
||||||
@@ -987,70 +1140,7 @@ function renderSymbols(query = "") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawGraph() {
|
|
||||||
const canvas = $("#graph-canvas");
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
const expression = $("#graph-expression").value;
|
|
||||||
const range = Number($("#graph-range").value);
|
|
||||||
const width = canvas.width;
|
|
||||||
const height = canvas.height;
|
|
||||||
$("#graph-range-label").textContent = `−${range} 到 ${range}`;
|
|
||||||
$("#graph-error").textContent = "";
|
|
||||||
context.clearRect(0, 0, width, height);
|
|
||||||
context.fillStyle = "#fbfbf7";
|
|
||||||
context.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
const toX = (x) => ((x + range) / (range * 2)) * width;
|
|
||||||
const toY = (y) => height / 2 - (y / range) * (height / 2);
|
|
||||||
context.strokeStyle = "#e4e5df";
|
|
||||||
context.lineWidth = 1;
|
|
||||||
for (let value = -range; value <= range; value += 1) {
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(toX(value), 0);
|
|
||||||
context.lineTo(toX(value), height);
|
|
||||||
context.stroke();
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(0, toY(value));
|
|
||||||
context.lineTo(width, toY(value));
|
|
||||||
context.stroke();
|
|
||||||
}
|
|
||||||
context.strokeStyle = "#718078";
|
|
||||||
context.lineWidth = 2;
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(0, height / 2);
|
|
||||||
context.lineTo(width, height / 2);
|
|
||||||
context.moveTo(width / 2, 0);
|
|
||||||
context.lineTo(width / 2, height);
|
|
||||||
context.stroke();
|
|
||||||
|
|
||||||
context.strokeStyle = "#196548";
|
|
||||||
context.lineWidth = 3;
|
|
||||||
context.beginPath();
|
|
||||||
let drawing = false;
|
|
||||||
try {
|
|
||||||
for (let pixel = 0; pixel <= width; pixel += 2) {
|
|
||||||
const x = (pixel / width) * range * 2 - range;
|
|
||||||
const y = evaluateExpression(expression, { x });
|
|
||||||
const screenY = toY(y);
|
|
||||||
if (screenY < -height * 2 || screenY > height * 3) {
|
|
||||||
drawing = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!drawing) context.moveTo(pixel, screenY);
|
|
||||||
else context.lineTo(pixel, screenY);
|
|
||||||
drawing = true;
|
|
||||||
}
|
|
||||||
context.stroke();
|
|
||||||
} catch (error) {
|
|
||||||
$("#graph-error").textContent = error.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchTool(tool) {
|
function switchTool(tool) {
|
||||||
if (tool === "mental") {
|
|
||||||
navigate("contest");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$$(".tool-card").forEach((card) => {
|
$$(".tool-card").forEach((card) => {
|
||||||
card.classList.toggle("active", card.dataset.tool === tool);
|
card.classList.toggle("active", card.dataset.tool === tool);
|
||||||
});
|
});
|
||||||
@@ -1060,15 +1150,40 @@ function switchTool(tool) {
|
|||||||
window.HuluToolbox?.activate(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) {
|
function renderLatexPreview(source) {
|
||||||
const target = $("#latex-preview");
|
const target = $("#latex-preview");
|
||||||
target.replaceChildren();
|
target.replaceChildren();
|
||||||
if (!source.trim()) return;
|
target.classList.remove("latex-preview-error");
|
||||||
if (typeof katex === "undefined") { target.textContent = source; return; }
|
const normalized = normalizeLatexSource(source);
|
||||||
|
if (!normalized) return;
|
||||||
|
if (typeof globalThis.katex?.render !== "function") {
|
||||||
|
target.classList.add("latex-preview-error");
|
||||||
|
target.textContent = "公式渲染组件加载失败,请刷新页面后重试。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
katex.render(source, target, { throwOnError: false, displayMode: true });
|
globalThis.katex.render(normalized, target, {
|
||||||
} catch {
|
displayMode: true,
|
||||||
target.textContent = source;
|
throwOnError: true,
|
||||||
|
trust: false,
|
||||||
|
maxExpand: 500,
|
||||||
|
maxSize: 20,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
target.classList.add("latex-preview-error");
|
||||||
|
target.textContent = `公式语法错误:${error.message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1077,7 +1192,10 @@ async function saveFormula() {
|
|||||||
try {
|
try {
|
||||||
await api("latex/documents/", {
|
await api("latex/documents/", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { title: `公式 ${new Date().toLocaleString()}`, source: $("#latex-source").value },
|
body: {
|
||||||
|
title: `公式 ${new Date().toLocaleString()}`,
|
||||||
|
source: normalizeLatexSource($("#latex-source").value),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
showToast("公式及首个版本已保存");
|
showToast("公式及首个版本已保存");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1090,6 +1208,8 @@ function bindUI() {
|
|||||||
$$("[data-jump]").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.jump)));
|
$$("[data-jump]").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.jump)));
|
||||||
$$("[data-open-auth]").forEach((button) => button.addEventListener("click", openAuth));
|
$$("[data-open-auth]").forEach((button) => button.addEventListener("click", openAuth));
|
||||||
$("#start-mathbti").addEventListener("click", startMathBTI);
|
$("#start-mathbti").addEventListener("click", startMathBTI);
|
||||||
|
$("#wechat-copy").addEventListener("click", copyWechatName);
|
||||||
|
$("#story-experience-close").addEventListener("click", closeStoryExperience);
|
||||||
$("#save-formula").addEventListener("click", saveFormula);
|
$("#save-formula").addEventListener("click", saveFormula);
|
||||||
$("#latex-source").addEventListener("input", (event) => { renderLatexPreview(event.target.value); });
|
$("#latex-source").addEventListener("input", (event) => { renderLatexPreview(event.target.value); });
|
||||||
$$(".tool-card").forEach((button) => {
|
$$(".tool-card").forEach((button) => {
|
||||||
@@ -1100,6 +1220,11 @@ function bindUI() {
|
|||||||
});
|
});
|
||||||
$("#enter-alumni").addEventListener("click", showLifeSkills);
|
$("#enter-alumni").addEventListener("click", showLifeSkills);
|
||||||
$("#back-to-hub").addEventListener("click", showLifeHub);
|
$("#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) => {
|
$$(".route-card").forEach((card) => {
|
||||||
card.addEventListener("click", (event) => {
|
card.addEventListener("click", (event) => {
|
||||||
if (event.target.closest(".route-enter")) return;
|
if (event.target.closest(".route-enter")) return;
|
||||||
@@ -1134,6 +1259,7 @@ function bindUI() {
|
|||||||
window.HuluRealtime?.reset();
|
window.HuluRealtime?.reset();
|
||||||
state.user = null;
|
state.user = null;
|
||||||
updateUserUI();
|
updateUserUI();
|
||||||
|
if ($("#view-profile").classList.contains("active")) await loadProfile();
|
||||||
showToast("已退出登录");
|
showToast("已退出登录");
|
||||||
});
|
});
|
||||||
$$("dialog .dialog-close").forEach((button) => button.addEventListener("click", () => button.closest("dialog").close()));
|
$$("dialog .dialog-close").forEach((button) => button.addEventListener("click", () => button.closest("dialog").close()));
|
||||||
@@ -1143,10 +1269,13 @@ function bindUI() {
|
|||||||
$("#register-form").classList.toggle("hidden", button.dataset.authTab !== "register");
|
$("#register-form").classList.toggle("hidden", button.dataset.authTab !== "register");
|
||||||
$("#auth-error").textContent = "";
|
$("#auth-error").textContent = "";
|
||||||
}));
|
}));
|
||||||
$$("#track-switch button").forEach((button) => button.addEventListener("click", () => {
|
$$("#match-mode-switch button").forEach((button) => button.addEventListener("click", () => {
|
||||||
state.track = button.dataset.track;
|
state.matchMode = button.dataset.matchMode;
|
||||||
$$("#track-switch button").forEach((item) => item.classList.toggle("active", item === button));
|
$$("#match-mode-switch button").forEach((item) => {
|
||||||
renderContests();
|
item.classList.toggle("active", item === button);
|
||||||
|
});
|
||||||
|
const label = button.querySelector("b").textContent;
|
||||||
|
$("#challenge-create").textContent = `创建${label}约战`;
|
||||||
}));
|
}));
|
||||||
$("#login-form").addEventListener("submit", async (event) => {
|
$("#login-form").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1154,7 +1283,11 @@ function bindUI() {
|
|||||||
state.user = await api("accounts/login/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) });
|
state.user = await api("accounts/login/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) });
|
||||||
$("#auth-dialog").close();
|
$("#auth-dialog").close();
|
||||||
updateUserUI();
|
updateUserUI();
|
||||||
await loadContent();
|
await Promise.all([
|
||||||
|
loadContent(),
|
||||||
|
loadHomeMatchHistory(),
|
||||||
|
$("#view-profile").classList.contains("active") ? loadProfile() : null,
|
||||||
|
]);
|
||||||
showToast("登录成功");
|
showToast("登录成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$("#auth-error").textContent = error.message;
|
$("#auth-error").textContent = error.message;
|
||||||
@@ -1166,7 +1299,11 @@ function bindUI() {
|
|||||||
state.user = await api("accounts/register/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) });
|
state.user = await api("accounts/register/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) });
|
||||||
$("#auth-dialog").close();
|
$("#auth-dialog").close();
|
||||||
updateUserUI();
|
updateUserUI();
|
||||||
await loadContent();
|
await Promise.all([
|
||||||
|
loadContent(),
|
||||||
|
loadHomeMatchHistory(),
|
||||||
|
$("#view-profile").classList.contains("active") ? loadProfile() : null,
|
||||||
|
]);
|
||||||
showToast("账号和数学档案已建立");
|
showToast("账号和数学档案已建立");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$("#auth-error").textContent = error.message;
|
$("#auth-error").textContent = error.message;
|
||||||
|
|||||||
@@ -14,23 +14,6 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function difficultySelect() {
|
|
||||||
const select = document.createElement("select");
|
|
||||||
select.className = "game-difficulty";
|
|
||||||
[
|
|
||||||
["easy", "入门"],
|
|
||||||
["standard", "标准"],
|
|
||||||
["hard", "进阶"],
|
|
||||||
].forEach(([value, label]) => {
|
|
||||||
const option = document.createElement("option");
|
|
||||||
option.value = value;
|
|
||||||
option.textContent = label;
|
|
||||||
if (value === "standard") option.selected = true;
|
|
||||||
select.append(option);
|
|
||||||
});
|
|
||||||
return select;
|
|
||||||
}
|
|
||||||
|
|
||||||
function gameCard(game) {
|
function gameCard(game) {
|
||||||
const meta = GAME_META[game.kind];
|
const meta = GAME_META[game.kind];
|
||||||
const article = document.createElement("article");
|
const article = document.createElement("article");
|
||||||
@@ -50,12 +33,11 @@
|
|||||||
summary.textContent = game.summary;
|
summary.textContent = game.summary;
|
||||||
const controls = document.createElement("div");
|
const controls = document.createElement("div");
|
||||||
controls.className = "game-card-controls";
|
controls.className = "game-card-controls";
|
||||||
const difficulty = difficultySelect();
|
|
||||||
const start = document.createElement("button");
|
const start = document.createElement("button");
|
||||||
start.className = "primary-button";
|
start.className = "primary-button";
|
||||||
start.textContent = meta.action;
|
start.textContent = meta.action;
|
||||||
start.addEventListener("click", () => startGame(game.kind, difficulty.value));
|
start.addEventListener("click", () => startGame(game.kind));
|
||||||
controls.append(difficulty, start);
|
controls.append(start);
|
||||||
article.append(top, kicker, title, summary, controls);
|
article.append(top, kicker, title, summary, controls);
|
||||||
return article;
|
return article;
|
||||||
}
|
}
|
||||||
@@ -70,12 +52,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startGame(kind, difficulty) {
|
async function startGame(kind) {
|
||||||
if (!requireAuth()) return;
|
if (!requireAuth()) return;
|
||||||
try {
|
try {
|
||||||
const attempt = await api(`contests/games/${kind}/start/`, {
|
const attempt = await api(`contests/games/${kind}/start/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { difficulty },
|
body: {},
|
||||||
});
|
});
|
||||||
renderGame(attempt);
|
renderGame(attempt);
|
||||||
$game("#experience-dialog").showModal();
|
$game("#experience-dialog").showModal();
|
||||||
@@ -88,8 +70,7 @@
|
|||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.className = "kicker";
|
label.className = "kicker";
|
||||||
label.textContent =
|
label.textContent = GAME_META[attempt.kind].kicker;
|
||||||
`${GAME_META[attempt.kind].kicker} · ${attempt.difficulty.toUpperCase()}`;
|
|
||||||
const title = document.createElement("h2");
|
const title = document.createElement("h2");
|
||||||
title.textContent = GAME_META[attempt.kind].title;
|
title.textContent = GAME_META[attempt.kind].title;
|
||||||
fragment.append(label, title);
|
fragment.append(label, title);
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
(function () {
|
||||||
|
const $graph = (selector, root = document) => root.querySelector(selector);
|
||||||
|
const COLORS = [
|
||||||
|
"#196548",
|
||||||
|
"#d86f45",
|
||||||
|
"#5d73e8",
|
||||||
|
"#8667b5",
|
||||||
|
"#c44f83",
|
||||||
|
"#198b9a",
|
||||||
|
"#b07b24",
|
||||||
|
"#3d4852",
|
||||||
|
];
|
||||||
|
const graph = {
|
||||||
|
functions: [],
|
||||||
|
nextId: 1,
|
||||||
|
viewport: { xMin: -10, xMax: 10, yMin: -6, yMax: 6 },
|
||||||
|
dragging: null,
|
||||||
|
frame: null,
|
||||||
|
initialized: false,
|
||||||
|
observer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (graph.frame) window.cancelAnimationFrame(graph.frame);
|
||||||
|
graph.frame = window.requestAnimationFrame(() => {
|
||||||
|
graph.frame = null;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeFunctions() {
|
||||||
|
return graph.functions.filter(
|
||||||
|
(item) => item.visible && item.expression.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFunction(expression = "", color) {
|
||||||
|
if (graph.functions.length >= 8) {
|
||||||
|
showToast("最多同时绘制 8 条曲线");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
graph.functions.push({
|
||||||
|
id: graph.nextId,
|
||||||
|
expression,
|
||||||
|
color: color || COLORS[(graph.nextId - 1) % COLORS.length],
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
graph.nextId += 1;
|
||||||
|
renderFunctionList();
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFunctionList() {
|
||||||
|
const root = $graph("#graph-function-list");
|
||||||
|
root.replaceChildren(
|
||||||
|
...graph.functions.map((item, index) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "graph-function-row";
|
||||||
|
const visible = document.createElement("input");
|
||||||
|
visible.type = "checkbox";
|
||||||
|
visible.checked = item.visible;
|
||||||
|
visible.setAttribute("aria-label", `显示曲线 ${index + 1}`);
|
||||||
|
visible.addEventListener("change", () => {
|
||||||
|
item.visible = visible.checked;
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
const color = document.createElement("input");
|
||||||
|
color.type = "color";
|
||||||
|
color.value = item.color;
|
||||||
|
color.setAttribute("aria-label", `曲线 ${index + 1} 颜色`);
|
||||||
|
color.addEventListener("input", () => {
|
||||||
|
item.color = color.value;
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
const prefix = document.createElement("span");
|
||||||
|
prefix.textContent = `f${index + 1}(x)`;
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.className = "formula-input";
|
||||||
|
input.value = item.expression;
|
||||||
|
input.placeholder = "例如 sin(x)";
|
||||||
|
input.setAttribute("aria-label", `函数 ${index + 1}`);
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
item.expression = input.value;
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.type = "button";
|
||||||
|
remove.textContent = "×";
|
||||||
|
remove.title = "删除曲线";
|
||||||
|
remove.disabled = graph.functions.length === 1;
|
||||||
|
remove.addEventListener("click", () => {
|
||||||
|
graph.functions = graph.functions.filter(
|
||||||
|
(candidate) => candidate.id !== item.id,
|
||||||
|
);
|
||||||
|
renderFunctionList();
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
row.append(visible, color, prefix, input, remove);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readViewport() {
|
||||||
|
const viewport = {
|
||||||
|
xMin: Number($graph("#graph-x-min").value),
|
||||||
|
xMax: Number($graph("#graph-x-max").value),
|
||||||
|
yMin: Number($graph("#graph-y-min").value),
|
||||||
|
yMax: Number($graph("#graph-y-max").value),
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!Object.values(viewport).every(Number.isFinite) ||
|
||||||
|
viewport.xMin >= viewport.xMax ||
|
||||||
|
viewport.yMin >= viewport.yMax ||
|
||||||
|
viewport.xMax - viewport.xMin > 1_000_000 ||
|
||||||
|
viewport.yMax - viewport.yMin > 1_000_000
|
||||||
|
) {
|
||||||
|
throw new Error("坐标范围必须有限、最小值小于最大值,跨度不超过 1,000,000");
|
||||||
|
}
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeViewport(viewport = graph.viewport) {
|
||||||
|
$graph("#graph-x-min").value = Number(viewport.xMin.toPrecision(8));
|
||||||
|
$graph("#graph-x-max").value = Number(viewport.xMax.toPrecision(8));
|
||||||
|
$graph("#graph-y-min").value = Number(viewport.yMin.toPrecision(8));
|
||||||
|
$graph("#graph-y-max").value = Number(viewport.yMax.toPrecision(8));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleFunction(item, viewport, count) {
|
||||||
|
const parameter = Number($graph("#graph-parameter").value);
|
||||||
|
const samples = [];
|
||||||
|
let lastError = null;
|
||||||
|
for (let index = 0; index <= count; index += 1) {
|
||||||
|
const x =
|
||||||
|
viewport.xMin +
|
||||||
|
(index / count) * (viewport.xMax - viewport.xMin);
|
||||||
|
let y = Number.NaN;
|
||||||
|
try {
|
||||||
|
y = evaluateExpression(item.expression, { x, a: parameter });
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
samples.push({ x, y: Number.isFinite(y) ? y : Number.NaN });
|
||||||
|
}
|
||||||
|
if (!samples.some((point) => Number.isFinite(point.y))) {
|
||||||
|
throw lastError || new Error(`函数 ${item.expression} 在当前视窗没有有效值`);
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoY(functions, viewport) {
|
||||||
|
const values = functions
|
||||||
|
.flatMap((item) => sampleFunction(item, viewport, 500))
|
||||||
|
.map((item) => item.y)
|
||||||
|
.filter(Number.isFinite)
|
||||||
|
.sort((left, right) => left - right);
|
||||||
|
if (!values.length) return { ...viewport, yMin: -6, yMax: 6 };
|
||||||
|
const low = values[Math.floor(values.length * 0.02)];
|
||||||
|
const high = values[Math.min(values.length - 1, Math.ceil(values.length * 0.98))];
|
||||||
|
const span = Math.max(high - low, Math.abs(high) * 0.1, 2);
|
||||||
|
return {
|
||||||
|
...viewport,
|
||||||
|
yMin: low - span * 0.12,
|
||||||
|
yMax: high + span * 0.12,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceStep(span, target = 9) {
|
||||||
|
const rough = span / target;
|
||||||
|
const power = 10 ** Math.floor(Math.log10(rough));
|
||||||
|
const fraction = rough / power;
|
||||||
|
return (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10) * power;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickLabel(value, step) {
|
||||||
|
if (Math.abs(value) < step * 0.001) return "0";
|
||||||
|
if (Math.abs(value) >= 10000 || Math.abs(value) < 0.001) {
|
||||||
|
return value.toExponential(1);
|
||||||
|
}
|
||||||
|
return value.toFixed(Math.min(6, Math.max(0, -Math.floor(Math.log10(step)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasSize() {
|
||||||
|
const canvas = $graph("#graph-canvas");
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
const width = Math.max(320, Math.round(rect.width));
|
||||||
|
const height = Math.max(320, Math.round(rect.height));
|
||||||
|
if (
|
||||||
|
canvas.width !== Math.round(width * ratio) ||
|
||||||
|
canvas.height !== Math.round(height * ratio)
|
||||||
|
) {
|
||||||
|
canvas.width = Math.round(width * ratio);
|
||||||
|
canvas.height = Math.round(height * ratio);
|
||||||
|
}
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||||
|
return { context, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(context, viewport, width, height, toX, toY) {
|
||||||
|
context.fillStyle = "#fcfcf8";
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
if (!$graph("#graph-grid").checked) return;
|
||||||
|
const xStep = niceStep(viewport.xMax - viewport.xMin);
|
||||||
|
const yStep = niceStep(viewport.yMax - viewport.yMin);
|
||||||
|
context.font = "11px system-ui, sans-serif";
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.strokeStyle = "#e1e6e0";
|
||||||
|
context.fillStyle = "#6d796f";
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "top";
|
||||||
|
for (
|
||||||
|
let value = Math.ceil(viewport.xMin / xStep) * xStep;
|
||||||
|
value <= viewport.xMax + xStep * 0.001;
|
||||||
|
value += xStep
|
||||||
|
) {
|
||||||
|
const x = toX(value);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, 0);
|
||||||
|
context.lineTo(x, height);
|
||||||
|
context.stroke();
|
||||||
|
const labelY =
|
||||||
|
viewport.yMin <= 0 && viewport.yMax >= 0
|
||||||
|
? Math.min(height - 18, Math.max(4, toY(0) + 6))
|
||||||
|
: height - 18;
|
||||||
|
context.fillText(tickLabel(value, xStep), x, labelY);
|
||||||
|
}
|
||||||
|
context.textAlign = "left";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
for (
|
||||||
|
let value = Math.ceil(viewport.yMin / yStep) * yStep;
|
||||||
|
value <= viewport.yMax + yStep * 0.001;
|
||||||
|
value += yStep
|
||||||
|
) {
|
||||||
|
const y = toY(value);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(0, y);
|
||||||
|
context.lineTo(width, y);
|
||||||
|
context.stroke();
|
||||||
|
const labelX =
|
||||||
|
viewport.xMin <= 0 && viewport.xMax >= 0
|
||||||
|
? Math.min(width - 50, Math.max(6, toX(0) + 7))
|
||||||
|
: 7;
|
||||||
|
context.fillText(tickLabel(value, yStep), labelX, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAxes(context, viewport, width, height, toX, toY) {
|
||||||
|
context.strokeStyle = "#263b30";
|
||||||
|
context.fillStyle = "#263b30";
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.beginPath();
|
||||||
|
if (viewport.yMin <= 0 && viewport.yMax >= 0) {
|
||||||
|
const y = toY(0);
|
||||||
|
context.moveTo(0, y);
|
||||||
|
context.lineTo(width, y);
|
||||||
|
context.moveTo(width - 9, y - 5);
|
||||||
|
context.lineTo(width, y);
|
||||||
|
context.lineTo(width - 9, y + 5);
|
||||||
|
}
|
||||||
|
if (viewport.xMin <= 0 && viewport.xMax >= 0) {
|
||||||
|
const x = toX(0);
|
||||||
|
context.moveTo(x, height);
|
||||||
|
context.lineTo(x, 0);
|
||||||
|
context.moveTo(x - 5, 9);
|
||||||
|
context.lineTo(x, 0);
|
||||||
|
context.lineTo(x + 5, 9);
|
||||||
|
}
|
||||||
|
context.stroke();
|
||||||
|
context.font = "700 12px system-ui, sans-serif";
|
||||||
|
context.fillText("x", width - 16, Math.min(height - 16, Math.max(14, toY(0) - 15)));
|
||||||
|
context.fillText("y", Math.min(width - 18, Math.max(10, toX(0) + 10)), 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPath(context, samples, color, viewport, toX, toY) {
|
||||||
|
const ySpan = viewport.yMax - viewport.yMin;
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.lineWidth = 2.6;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
let drawing = false;
|
||||||
|
let previous;
|
||||||
|
samples.forEach((item) => {
|
||||||
|
const discontinuity =
|
||||||
|
previous &&
|
||||||
|
Number.isFinite(previous.y) &&
|
||||||
|
Number.isFinite(item.y) &&
|
||||||
|
Math.abs(item.y - previous.y) > ySpan * 3;
|
||||||
|
if (
|
||||||
|
!Number.isFinite(item.y) ||
|
||||||
|
item.y < viewport.yMin - ySpan ||
|
||||||
|
item.y > viewport.yMax + ySpan ||
|
||||||
|
discontinuity
|
||||||
|
) {
|
||||||
|
drawing = false;
|
||||||
|
} else if (!drawing) {
|
||||||
|
context.moveTo(toX(item.x), toY(item.y));
|
||||||
|
drawing = true;
|
||||||
|
} else {
|
||||||
|
context.lineTo(toX(item.x), toY(item.y));
|
||||||
|
}
|
||||||
|
previous = item;
|
||||||
|
});
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyze(samples, xSpan) {
|
||||||
|
const roots = [];
|
||||||
|
let extrema = 0;
|
||||||
|
for (let index = 1; index < samples.length; index += 1) {
|
||||||
|
const before = samples[index - 1];
|
||||||
|
const current = samples[index];
|
||||||
|
if (!Number.isFinite(before.y) || !Number.isFinite(current.y)) continue;
|
||||||
|
if (before.y === 0 || before.y * current.y < 0) {
|
||||||
|
const root = (before.x + current.x) / 2;
|
||||||
|
if (!roots.length || Math.abs(root - roots.at(-1)) > xSpan / 300) {
|
||||||
|
roots.push(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index > 1) {
|
||||||
|
const older = samples[index - 2];
|
||||||
|
if (!Number.isFinite(older.y)) continue;
|
||||||
|
if ((before.y - older.y) * (current.y - before.y) < 0) extrema += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { roots: roots.slice(0, 8), extrema };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLegend(functions) {
|
||||||
|
$graph("#graph-legend").replaceChildren(
|
||||||
|
...functions.map((item) => {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
const dot = document.createElement("i");
|
||||||
|
dot.style.background = item.color;
|
||||||
|
badge.append(dot, document.createTextNode(item.expression));
|
||||||
|
return badge;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const canvas = $graph("#graph-canvas");
|
||||||
|
if (!canvas) return;
|
||||||
|
const functions = activeFunctions();
|
||||||
|
const { context, width, height } = canvasSize();
|
||||||
|
$graph("#graph-error").textContent = "";
|
||||||
|
$graph("#graph-parameter-label").textContent =
|
||||||
|
Number($graph("#graph-parameter").value).toFixed(1);
|
||||||
|
try {
|
||||||
|
let viewport = readViewport();
|
||||||
|
if ($graph("#graph-auto-y").checked && functions.length) {
|
||||||
|
viewport = autoY(functions, viewport);
|
||||||
|
$graph("#graph-y-min").value = Number(viewport.yMin.toPrecision(7));
|
||||||
|
$graph("#graph-y-max").value = Number(viewport.yMax.toPrecision(7));
|
||||||
|
}
|
||||||
|
graph.viewport = viewport;
|
||||||
|
const toX = (x) =>
|
||||||
|
((x - viewport.xMin) / (viewport.xMax - viewport.xMin)) * width;
|
||||||
|
const toY = (y) =>
|
||||||
|
height - ((y - viewport.yMin) / (viewport.yMax - viewport.yMin)) * height;
|
||||||
|
drawGrid(context, viewport, width, height, toX, toY);
|
||||||
|
drawAxes(context, viewport, width, height, toX, toY);
|
||||||
|
if (!functions.length) {
|
||||||
|
$graph("#graph-error").textContent = "请至少输入并启用一个函数";
|
||||||
|
renderLegend([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = Math.max(500, Math.min(1600, Math.round(width * 1.5)));
|
||||||
|
const sampled = functions.map((item) => ({
|
||||||
|
item,
|
||||||
|
samples: sampleFunction(item, viewport, count),
|
||||||
|
}));
|
||||||
|
const first = sampled[0].samples;
|
||||||
|
if ($graph("#graph-integral").checked) {
|
||||||
|
context.fillStyle = `${functions[0].color}22`;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(toX(viewport.xMin), toY(0));
|
||||||
|
first.forEach((point) => {
|
||||||
|
if (Number.isFinite(point.y)) context.lineTo(toX(point.x), toY(point.y));
|
||||||
|
});
|
||||||
|
context.lineTo(toX(viewport.xMax), toY(0));
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
sampled.forEach(({ item, samples }) => {
|
||||||
|
drawPath(context, samples, item.color, viewport, toX, toY);
|
||||||
|
});
|
||||||
|
if ($graph("#graph-derivative").checked) {
|
||||||
|
const derivative = first.slice(1, -1).map((point, index) => {
|
||||||
|
const before = first[index];
|
||||||
|
const after = first[index + 2];
|
||||||
|
return { x: point.x, y: (after.y - before.y) / (after.x - before.x) };
|
||||||
|
});
|
||||||
|
context.setLineDash([8, 6]);
|
||||||
|
drawPath(context, derivative, "#111827", viewport, toX, toY);
|
||||||
|
context.setLineDash([]);
|
||||||
|
}
|
||||||
|
const analysis = analyze(first, viewport.xMax - viewport.xMin);
|
||||||
|
const rootText = analysis.roots.length
|
||||||
|
? analysis.roots.map((item) => item.toPrecision(4)).join("、")
|
||||||
|
: "当前视窗未发现";
|
||||||
|
$graph("#graph-analysis").textContent =
|
||||||
|
`第一条曲线:近似零点 ${rootText};检测到 ${analysis.extrema} 个极值转折。`;
|
||||||
|
renderLegend(functions);
|
||||||
|
} catch (error) {
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
$graph("#graph-error").textContent = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setViewport(viewport, disableAutoY = true) {
|
||||||
|
graph.viewport = viewport;
|
||||||
|
if (disableAutoY) $graph("#graph-auto-y").checked = false;
|
||||||
|
writeViewport(viewport);
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetViewport() {
|
||||||
|
$graph("#graph-auto-y").checked = true;
|
||||||
|
setViewport({ xMin: -10, xMax: 10, yMin: -6, yMax: 6 }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoFit() {
|
||||||
|
$graph("#graph-auto-y").checked = true;
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindCanvasNavigation() {
|
||||||
|
const canvas = $graph("#graph-canvas");
|
||||||
|
canvas.addEventListener("pointerdown", (event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
graph.dragging = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
viewport: { ...graph.viewport },
|
||||||
|
};
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
canvas.classList.add("dragging");
|
||||||
|
});
|
||||||
|
canvas.addEventListener("pointermove", (event) => {
|
||||||
|
if (!graph.dragging) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const start = graph.dragging;
|
||||||
|
const dx =
|
||||||
|
((event.clientX - start.x) / rect.width) *
|
||||||
|
(start.viewport.xMax - start.viewport.xMin);
|
||||||
|
const dy =
|
||||||
|
((event.clientY - start.y) / rect.height) *
|
||||||
|
(start.viewport.yMax - start.viewport.yMin);
|
||||||
|
setViewport({
|
||||||
|
xMin: start.viewport.xMin - dx,
|
||||||
|
xMax: start.viewport.xMax - dx,
|
||||||
|
yMin: start.viewport.yMin + dy,
|
||||||
|
yMax: start.viewport.yMax + dy,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const stopDragging = () => {
|
||||||
|
graph.dragging = null;
|
||||||
|
canvas.classList.remove("dragging");
|
||||||
|
};
|
||||||
|
canvas.addEventListener("pointerup", stopDragging);
|
||||||
|
canvas.addEventListener("pointercancel", stopDragging);
|
||||||
|
canvas.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const viewport = graph.viewport;
|
||||||
|
const xRatio = (event.clientX - rect.left) / rect.width;
|
||||||
|
const yRatio = 1 - (event.clientY - rect.top) / rect.height;
|
||||||
|
const centerX = viewport.xMin + xRatio * (viewport.xMax - viewport.xMin);
|
||||||
|
const centerY = viewport.yMin + yRatio * (viewport.yMax - viewport.yMin);
|
||||||
|
const factor = event.deltaY > 0 ? 1.14 : 0.88;
|
||||||
|
setViewport({
|
||||||
|
xMin: centerX + (viewport.xMin - centerX) * factor,
|
||||||
|
xMax: centerX + (viewport.xMax - centerX) * factor,
|
||||||
|
yMin: centerY + (viewport.yMin - centerY) * factor,
|
||||||
|
yMax: centerY + (viewport.yMax - centerY) * factor,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
canvas.addEventListener("dblclick", autoFit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (graph.initialized) return;
|
||||||
|
graph.initialized = true;
|
||||||
|
addFunction("sin(x) + a*x/3", COLORS[0]);
|
||||||
|
addFunction("0.08*x^2 - 2", COLORS[1]);
|
||||||
|
$graph("#graph-add-function").addEventListener("click", () => addFunction());
|
||||||
|
$graph("#graph-run").addEventListener("click", draw);
|
||||||
|
$graph("#graph-auto-fit").addEventListener("click", autoFit);
|
||||||
|
$graph("#graph-reset-view").addEventListener("click", resetViewport);
|
||||||
|
[
|
||||||
|
"#graph-parameter",
|
||||||
|
"#graph-x-min",
|
||||||
|
"#graph-x-max",
|
||||||
|
"#graph-auto-y",
|
||||||
|
"#graph-grid",
|
||||||
|
"#graph-derivative",
|
||||||
|
"#graph-integral",
|
||||||
|
].forEach((selector) => {
|
||||||
|
$graph(selector).addEventListener("input", schedule);
|
||||||
|
});
|
||||||
|
["#graph-y-min", "#graph-y-max"].forEach((selector) => {
|
||||||
|
$graph(selector).addEventListener("input", () => {
|
||||||
|
$graph("#graph-auto-y").checked = false;
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
bindCanvasNavigation();
|
||||||
|
if ("ResizeObserver" in window) {
|
||||||
|
graph.observer = new ResizeObserver(schedule);
|
||||||
|
graph.observer.observe($graph(".graph-stage"));
|
||||||
|
} else {
|
||||||
|
window.addEventListener("resize", schedule);
|
||||||
|
}
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.HuluGraph = { init, draw, resize: schedule, addFunction };
|
||||||
|
})();
|
||||||
+197
-31
@@ -7,6 +7,11 @@
|
|||||||
opponentProgress: 0,
|
opponentProgress: 0,
|
||||||
reconnectTimer: null,
|
reconnectTimer: null,
|
||||||
};
|
};
|
||||||
|
const GAME_LABELS = {
|
||||||
|
quiz: "口算竞速",
|
||||||
|
sudoku: "数独 Timerun",
|
||||||
|
twenty_four: "24 点竞速",
|
||||||
|
};
|
||||||
|
|
||||||
function stopTimers() {
|
function stopTimers() {
|
||||||
if (realtime.pollTimer) window.clearInterval(realtime.pollTimer);
|
if (realtime.pollTimer) window.clearInterval(realtime.pollTimer);
|
||||||
@@ -90,7 +95,7 @@
|
|||||||
match.opponent?.status &&
|
match.opponent?.status &&
|
||||||
match.opponent.status !== "active"
|
match.opponent.status !== "active"
|
||||||
) {
|
) {
|
||||||
realtime.opponentProgress = match.attempt?.questions.length || 0;
|
realtime.opponentProgress = matchProgressTotal(match);
|
||||||
updateProgressUI();
|
updateProgressUI();
|
||||||
updateConnectionStatus("对手已提交,完成后将立即结算");
|
updateConnectionStatus("对手已提交,完成后将立即结算");
|
||||||
}
|
}
|
||||||
@@ -130,7 +135,7 @@
|
|||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.className = "kicker";
|
label.className = "kicker";
|
||||||
label.textContent =
|
label.textContent =
|
||||||
`${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · REALTIME`;
|
`${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · ${GAME_LABELS[match.game_kind] || "实时竞技"}`;
|
||||||
const title = document.createElement("h2");
|
const title = document.createElement("h2");
|
||||||
title.textContent = match.contest;
|
title.textContent = match.contest;
|
||||||
const status = document.createElement("div");
|
const status = document.createElement("div");
|
||||||
@@ -182,7 +187,7 @@
|
|||||||
message.textContent =
|
message.textContent =
|
||||||
match.match_type === "challenge"
|
match.match_type === "challenge"
|
||||||
? "把联机码发给朋友。对方加入后会自动开始,无需再次点击。"
|
? "把联机码发给朋友。对方加入后会自动开始,无需再次点击。"
|
||||||
: "正在寻找同赛道、相近 Rating 的玩家。";
|
: `正在寻找${GAME_LABELS[match.game_kind] || "同玩法"}对手。`;
|
||||||
panel.append(pulse, message);
|
panel.append(pulse, message);
|
||||||
if (match.challenge_code) {
|
if (match.challenge_code) {
|
||||||
const code = document.createElement("button");
|
const code = document.createElement("button");
|
||||||
@@ -217,30 +222,41 @@
|
|||||||
updateClock();
|
updateClock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchProgressTotal(match) {
|
||||||
|
return (
|
||||||
|
match.game_kind === "sudoku"
|
||||||
|
? 81
|
||||||
|
: match.game_kind === "twenty_four"
|
||||||
|
? 1
|
||||||
|
: match.attempt?.questions.length || 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function progressPanel(root, match) {
|
function progressPanel(root, match) {
|
||||||
|
const total = matchProgressTotal(match);
|
||||||
const panel = document.createElement("div");
|
const panel = document.createElement("div");
|
||||||
panel.className = "realtime-progress-panel";
|
panel.className = "realtime-progress-panel";
|
||||||
const self = document.createElement("div");
|
const self = document.createElement("div");
|
||||||
self.innerHTML = `<span>你</span><b id="self-progress">0 / ${match.attempt.questions.length}</b>`;
|
self.innerHTML = `<span>你</span><b id="self-progress">0 / ${total}</b>`;
|
||||||
const opponent = document.createElement("div");
|
const opponent = document.createElement("div");
|
||||||
opponent.innerHTML =
|
opponent.innerHTML =
|
||||||
`<span>${match.opponent?.nickname || "对手"}</span>` +
|
`<span>${match.opponent?.nickname || "对手"}</span>` +
|
||||||
`<b id="opponent-progress">${realtime.opponentProgress} / ${match.attempt.questions.length}</b>`;
|
`<b id="opponent-progress">${realtime.opponentProgress} / ${total}</b>`;
|
||||||
panel.append(self, opponent);
|
panel.append(self, opponent);
|
||||||
root.append(panel);
|
root.append(panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProgressUI(selfCount) {
|
function updateProgressUI(selfCount) {
|
||||||
|
const total = matchProgressTotal(realtime.match);
|
||||||
if (Number.isInteger(selfCount)) {
|
if (Number.isInteger(selfCount)) {
|
||||||
const self = document.querySelector("#self-progress");
|
const self = document.querySelector("#self-progress");
|
||||||
if (self && realtime.match?.attempt) {
|
if (self && realtime.match?.attempt) {
|
||||||
self.textContent = `${selfCount} / ${realtime.match.attempt.questions.length}`;
|
self.textContent = `${selfCount} / ${total}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const opponent = document.querySelector("#opponent-progress");
|
const opponent = document.querySelector("#opponent-progress");
|
||||||
if (opponent && realtime.match?.attempt) {
|
if (opponent && realtime.match?.attempt) {
|
||||||
opponent.textContent =
|
opponent.textContent = `${realtime.opponentProgress} / ${total}`;
|
||||||
`${realtime.opponentProgress} / ${realtime.match.attempt.questions.length}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +268,109 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitRealtimeGame(attempt, payload) {
|
||||||
|
realtime.match = await api(
|
||||||
|
`contests/games/attempts/${attempt.attempt_id}/submit/`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Idempotency-Key": createIdempotencyKey() },
|
||||||
|
body: payload,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
renderMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTwentyFourGame(root, match) {
|
||||||
|
const attempt = match.attempt;
|
||||||
|
const numbers = document.createElement("div");
|
||||||
|
numbers.className = "twenty-four-numbers";
|
||||||
|
attempt.puzzle.numbers.forEach((number) => {
|
||||||
|
const tile = document.createElement("span");
|
||||||
|
tile.textContent = number;
|
||||||
|
numbers.append(tile);
|
||||||
|
});
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.className = "twenty-four-form";
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.className = "formula-input";
|
||||||
|
input.placeholder = "四个数字各用一次,例如:6/(1-3/4)";
|
||||||
|
input.autocomplete = "off";
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
sendProgress(input.value.trim() ? 1 : 0);
|
||||||
|
updateProgressUI(input.value.trim() ? 1 : 0);
|
||||||
|
});
|
||||||
|
const error = document.createElement("p");
|
||||||
|
error.className = "form-error";
|
||||||
|
const submit = document.createElement("button");
|
||||||
|
submit.className = "primary-button";
|
||||||
|
submit.type = "submit";
|
||||||
|
submit.textContent = "验证并锁定答案";
|
||||||
|
form.append(input, error, submit);
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submit.disabled = true;
|
||||||
|
error.textContent = "";
|
||||||
|
try {
|
||||||
|
await submitRealtimeGame(attempt, { expression: input.value });
|
||||||
|
} catch (requestError) {
|
||||||
|
error.textContent = requestError.message;
|
||||||
|
submit.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.append(numbers, form);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSudokuGame(root, match) {
|
||||||
|
const attempt = match.attempt;
|
||||||
|
const board = document.createElement("div");
|
||||||
|
board.className = "sudoku-board";
|
||||||
|
attempt.puzzle.grid.forEach((rowValues, row) => {
|
||||||
|
rowValues.forEach((givenValue, column) => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.inputMode = "numeric";
|
||||||
|
input.pattern = "[1-9]";
|
||||||
|
input.maxLength = 1;
|
||||||
|
input.dataset.row = row;
|
||||||
|
input.dataset.column = column;
|
||||||
|
input.value = givenValue || "";
|
||||||
|
input.readOnly = Boolean(givenValue);
|
||||||
|
input.className = givenValue ? "given" : "";
|
||||||
|
input.setAttribute("aria-label", `第 ${row + 1} 行第 ${column + 1} 列`);
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
input.value = input.value.replace(/[^1-9]/g, "").slice(0, 1);
|
||||||
|
const count = [...board.querySelectorAll("input")].filter(
|
||||||
|
(item) => item.value
|
||||||
|
).length;
|
||||||
|
sendProgress(count);
|
||||||
|
updateProgressUI(count);
|
||||||
|
});
|
||||||
|
board.append(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const error = document.createElement("p");
|
||||||
|
error.className = "form-error";
|
||||||
|
const submit = document.createElement("button");
|
||||||
|
submit.className = "primary-button";
|
||||||
|
submit.type = "button";
|
||||||
|
submit.textContent = "检查并锁定数独";
|
||||||
|
submit.addEventListener("click", async () => {
|
||||||
|
submit.disabled = true;
|
||||||
|
error.textContent = "";
|
||||||
|
const grid = Array.from({ length: 9 }, () => Array(9).fill(0));
|
||||||
|
board.querySelectorAll("input").forEach((input) => {
|
||||||
|
grid[Number(input.dataset.row)][Number(input.dataset.column)] =
|
||||||
|
Number(input.value || 0);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await submitRealtimeGame(attempt, { grid });
|
||||||
|
} catch (requestError) {
|
||||||
|
error.textContent = requestError.message;
|
||||||
|
submit.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.append(board, error, submit);
|
||||||
|
}
|
||||||
|
|
||||||
function renderActive(root, match) {
|
function renderActive(root, match) {
|
||||||
progressPanel(root, match);
|
progressPanel(root, match);
|
||||||
const attempt = match.attempt;
|
const attempt = match.attempt;
|
||||||
@@ -264,6 +383,16 @@
|
|||||||
updateClock();
|
updateClock();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (match.game_kind === "twenty_four") {
|
||||||
|
renderTwentyFourGame(root, match);
|
||||||
|
updateClock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (match.game_kind === "sudoku") {
|
||||||
|
renderSudokuGame(root, match);
|
||||||
|
updateClock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const form = document.createElement("form");
|
const form = document.createElement("form");
|
||||||
form.className = "choice-list realtime-answer-form";
|
form.className = "choice-list realtime-answer-form";
|
||||||
attempt.questions.forEach((question) => {
|
attempt.questions.forEach((question) => {
|
||||||
@@ -328,30 +457,64 @@
|
|||||||
: "平局";
|
: "平局";
|
||||||
const score = document.createElement("p");
|
const score = document.createElement("p");
|
||||||
score.textContent =
|
score.textContent =
|
||||||
`你 ${match.attempt.score} 分 · ${match.opponent.score} 分 ${match.opponent.nickname}`;
|
match.game_kind === "quiz"
|
||||||
|
? `你 ${match.attempt.score} 分 · ${match.opponent.score} 分 ${match.opponent.nickname}`
|
||||||
|
: `${GAME_LABELS[match.game_kind]} · ${
|
||||||
|
match.attempt.status === "completed" ? "你已完成" : "你未完成"
|
||||||
|
} · ${
|
||||||
|
match.opponent.status === "completed"
|
||||||
|
? `${match.opponent.nickname} 已完成`
|
||||||
|
: `${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.result.winner !== "draw" &&
|
||||||
|
(match.game_kind !== "quiz" ||
|
||||||
|
match.attempt.score === match.opponent.score)
|
||||||
|
) {
|
||||||
|
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 rating = document.createElement("b");
|
||||||
const sign = match.result.rating_delta > 0 ? "+" : "";
|
const sign = match.result.rating_delta > 0 ? "+" : "";
|
||||||
rating.textContent =
|
rating.textContent =
|
||||||
`Rating ${sign}${match.result.rating_delta} → ${match.result.rating_after}`;
|
`Rating ${sign}${match.result.rating_delta} → ${match.result.rating_after}`;
|
||||||
result.append(outcome, score, rating);
|
result.append(outcome, score, timeLine, rating);
|
||||||
root.append(result);
|
root.append(result);
|
||||||
|
|
||||||
const review = document.createElement("div");
|
if (state.user) {
|
||||||
review.className = "realtime-review";
|
state.user.rating = match.result.rating_after;
|
||||||
match.attempt.questions.forEach((question) => {
|
updateUserUI();
|
||||||
const item = document.createElement("article");
|
}
|
||||||
const title = document.createElement("b");
|
loadUser();
|
||||||
title.textContent = `${question.order}. ${question.prompt}`;
|
|
||||||
const answer = document.createElement("p");
|
if (match.game_kind === "quiz") {
|
||||||
answer.textContent =
|
const review = document.createElement("div");
|
||||||
`你的答案:${question.submitted_answer || "未作答"} · 正确答案:${question.correct_answer}`;
|
review.className = "realtime-review";
|
||||||
const explanation = document.createElement("small");
|
match.attempt.questions.forEach((question) => {
|
||||||
explanation.textContent = question.explanation || "";
|
const item = document.createElement("article");
|
||||||
item.className = question.is_correct ? "correct" : "incorrect";
|
const title = document.createElement("b");
|
||||||
item.append(title, answer, explanation);
|
title.textContent = `${question.order}. ${question.prompt}`;
|
||||||
review.append(item);
|
const answer = document.createElement("p");
|
||||||
});
|
answer.textContent =
|
||||||
root.append(review);
|
`你的答案:${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() {
|
function renderMatch() {
|
||||||
@@ -371,15 +534,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentRealtimeContest() {
|
function currentRealtimeContest() {
|
||||||
return state.contests.find(
|
const contests = state.contests.filter((contest) => contest.kind === "realtime");
|
||||||
(contest) => contest.kind === "realtime" && contest.track === state.track
|
return (
|
||||||
|
contests.find((contest) => contest.track === "open") ||
|
||||||
|
contests.find((contest) => contest.track === "standard") ||
|
||||||
|
contests[0]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startRandom(contest) {
|
async function startRandom(contest) {
|
||||||
const match = await api(`contests/${contest.slug}/matchmaking/`, {
|
const match = await api(`contests/${contest.slug}/matchmaking/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: { game_kind: state.matchMode },
|
||||||
});
|
});
|
||||||
openMatch(match);
|
openMatch(match);
|
||||||
}
|
}
|
||||||
@@ -388,13 +554,13 @@
|
|||||||
if (!requireAuth()) return;
|
if (!requireAuth()) return;
|
||||||
const contest = currentRealtimeContest();
|
const contest = currentRealtimeContest();
|
||||||
if (!contest) {
|
if (!contest) {
|
||||||
showToast("当前赛道没有可用的实时比赛");
|
showToast("当前没有可用的实时比赛");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const match = await api(`contests/${contest.slug}/challenges/`, {
|
const match = await api(`contests/${contest.slug}/challenges/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: { game_kind: state.matchMode },
|
||||||
});
|
});
|
||||||
openMatch(match);
|
openMatch(match);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+376
-193
@@ -5,25 +5,107 @@
|
|||||||
const CALC_FIELDS = {
|
const CALC_FIELDS = {
|
||||||
derivative: ["order"],
|
derivative: ["order"],
|
||||||
integral: ["bounds"],
|
integral: ["bounds"],
|
||||||
limit: ["point"],
|
limit: ["point", "direction"],
|
||||||
|
series: ["point", "order"],
|
||||||
|
solve_system: ["variables"],
|
||||||
|
gradient: ["variables"],
|
||||||
|
hessian: ["variables"],
|
||||||
base: ["base"],
|
base: ["base"],
|
||||||
unit: ["unit"],
|
unit: ["unit"],
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatCalculatorResult(result) {
|
const CALC_OPERATION_META = {
|
||||||
if (Array.isArray(result)) {
|
statistics: {
|
||||||
return {
|
placeholder: "12, 15, 18, 21, 24",
|
||||||
exact: result.map((item) => item.exact).join(", "),
|
hint: "使用逗号分隔数据,返回四分位数、方差、标准差、极差等统计量。",
|
||||||
decimal: result.map((item) => item.decimal).join(", "),
|
},
|
||||||
latex: result.map((item) => item.latex).join(", "),
|
base: {
|
||||||
};
|
placeholder: "FF",
|
||||||
|
hint: "输入不带前缀的整数,原进制与目标进制均支持 2 到 36。",
|
||||||
|
},
|
||||||
|
solve: {
|
||||||
|
placeholder: "x^2 - 5*x + 6 = 0",
|
||||||
|
hint: "可省略等号右侧;通过“变量”指定要求解的未知量。",
|
||||||
|
},
|
||||||
|
solve_system: {
|
||||||
|
placeholder: "x + y = 5; x - y = 1",
|
||||||
|
hint: "使用分号分隔方程,通过“变量列表”指定未知量,最多 4 个变量、6 个方程。",
|
||||||
|
},
|
||||||
|
polynomial_roots: {
|
||||||
|
placeholder: "x^5 - x + 1 = 0",
|
||||||
|
hint: "返回至多 12 次单变量多项式的全部高精度数值根,包括复根。",
|
||||||
|
},
|
||||||
|
series: {
|
||||||
|
placeholder: "exp(x) * cos(x)",
|
||||||
|
hint: "在指定点附近展开;“阶数 6”表示保留到 5 阶并显示余项。",
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
gradient: {
|
||||||
|
placeholder: "x^2*y + sin(y)",
|
||||||
|
hint: "变量列表示例:x,y。结果按该顺序组成梯度列向量。",
|
||||||
|
},
|
||||||
|
hessian: {
|
||||||
|
placeholder: "x^2 + x*y + y^2",
|
||||||
|
hint: "变量列表示例:x,y。结果为二阶偏导组成的 Hessian 矩阵。",
|
||||||
|
},
|
||||||
|
matrix_det: { placeholder: "1,2;3,4", hint: "逗号分列、分号分行,最多 36 个元素。" },
|
||||||
|
matrix_inverse: { placeholder: "1,2;3,4", hint: "逆矩阵要求方阵且行列式非零。" },
|
||||||
|
matrix_rref: { placeholder: "1,2,3;2,4,6", hint: "返回矩阵的行最简形。" },
|
||||||
|
matrix_transpose: { placeholder: "1,2,3;4,5,6", hint: "交换矩阵的行与列。" },
|
||||||
|
matrix_rank: { placeholder: "1,2,3;2,4,6", hint: "通过精确行变换计算矩阵秩。" },
|
||||||
|
matrix_nullspace: { placeholder: "1,2;2,4", hint: "返回齐次方程组对应零空间的一组基。" },
|
||||||
|
matrix_eigenvalues: { placeholder: "2,1;1,2", hint: "要求方阵,返回特征值及其代数重数。" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function calculatorCategoryFor(operation) {
|
||||||
|
const option = [...$tool("#calc-operation").options].find(
|
||||||
|
(item) => item.value === operation,
|
||||||
|
);
|
||||||
|
return option?.parentElement?.dataset.calcCategoryOptions || "algebra";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCalculatorCategory(category, selectFirst = true) {
|
||||||
|
const select = $tool("#calc-operation");
|
||||||
|
$$tool("[data-calc-category-options]").forEach((group) => {
|
||||||
|
const active = group.dataset.calcCategoryOptions === category;
|
||||||
|
group.hidden = !active;
|
||||||
|
group.disabled = !active;
|
||||||
|
});
|
||||||
|
$$tool("[data-calc-category]").forEach((button) => {
|
||||||
|
button.classList.toggle(
|
||||||
|
"active",
|
||||||
|
button.dataset.calcCategory === category,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (selectFirst) {
|
||||||
|
const group = $tool(
|
||||||
|
`[data-calc-category-options="${category}"]`,
|
||||||
|
);
|
||||||
|
if (group?.querySelector("option")) {
|
||||||
|
select.value = group.querySelector("option").value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (result && typeof result === "object" && "exact" in result) return result;
|
updateCalculatorFields();
|
||||||
const entries = Object.entries(result || {});
|
}
|
||||||
|
|
||||||
|
function resultText(value, field = "exact") {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => resultText(item, field)).join("; ");
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
if ("exact" in value) return value[field] || value.exact;
|
||||||
|
return Object.entries(value)
|
||||||
|
.map(([key, item]) => `${key}: ${resultText(item, field)}`)
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
return String(value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCalculatorResult(result) {
|
||||||
return {
|
return {
|
||||||
exact: entries.map(([key, value]) => `${key}: ${value}`).join(" · "),
|
exact: resultText(result, "exact"),
|
||||||
decimal: entries.map(([key, value]) => `${key}: ${value}`).join(" · "),
|
decimal: resultText(result, "decimal"),
|
||||||
latex: entries.map(([key, value]) => `${key}=${value}`).join(", "),
|
latex: resultText(result, "latex"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,17 +115,13 @@
|
|||||||
$$tool("[data-calc-field]").forEach((field) => {
|
$$tool("[data-calc-field]").forEach((field) => {
|
||||||
field.hidden = !visible.has(field.dataset.calcField);
|
field.hidden = !visible.has(field.dataset.calcField);
|
||||||
});
|
});
|
||||||
const input = $tool("#calc-input");
|
const meta = CALC_OPERATION_META[operation] || {};
|
||||||
const placeholders = {
|
$tool("#calc-input").placeholder = meta.placeholder || "例如:sqrt(2) + 1/3";
|
||||||
statistics: "12, 15, 18, 21, 24",
|
$tool("#calc-hint").textContent =
|
||||||
base: "FF",
|
meta.hint || "支持精确常量、受限数学函数和变量;按 Command/Ctrl + Enter 运行。";
|
||||||
matrix_det: "1,2;3,4",
|
$tool("#calc-order").max = operation === "derivative" ? 5 : 12;
|
||||||
matrix_inverse: "1,2;3,4",
|
if (meta.order) $tool("#calc-order").value = meta.order;
|
||||||
matrix_rref: "1,2,3;2,4,6",
|
else if (operation === "derivative") $tool("#calc-order").value = 1;
|
||||||
matrix_transpose: "1,2,3;4,5,6",
|
|
||||||
solve: "x^2 - 5*x + 6 = 0",
|
|
||||||
};
|
|
||||||
input.placeholder = placeholders[operation] || "例如:sqrt(2) + 1/3";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCalculator() {
|
async function runCalculator() {
|
||||||
@@ -59,10 +137,12 @@
|
|||||||
operation,
|
operation,
|
||||||
expression,
|
expression,
|
||||||
variable: $tool("#calc-variable").value,
|
variable: $tool("#calc-variable").value,
|
||||||
|
variables: $tool("#calc-variables").value,
|
||||||
order: $tool("#calc-order").value,
|
order: $tool("#calc-order").value,
|
||||||
lower: $tool("#calc-lower").value,
|
lower: $tool("#calc-lower").value,
|
||||||
upper: $tool("#calc-upper").value,
|
upper: $tool("#calc-upper").value,
|
||||||
point: $tool("#calc-point").value,
|
point: $tool("#calc-point").value,
|
||||||
|
direction: $tool("#calc-direction").value,
|
||||||
from_base: $tool("#calc-from-base").value,
|
from_base: $tool("#calc-from-base").value,
|
||||||
to_base: $tool("#calc-to-base").value,
|
to_base: $tool("#calc-to-base").value,
|
||||||
from_unit: $tool("#calc-from-unit").value,
|
from_unit: $tool("#calc-from-unit").value,
|
||||||
@@ -92,163 +172,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const GRAPH_COLORS = ["#196548", "#d86f45", "#5d73e8", "#8667b5"];
|
|
||||||
|
|
||||||
function drawFunctionPath(context, expression, variables, range, width, height, color) {
|
|
||||||
const toY = (value) => height / 2 - (value / range) * (height / 2);
|
|
||||||
context.strokeStyle = color;
|
|
||||||
context.lineWidth = 3;
|
|
||||||
context.beginPath();
|
|
||||||
let drawing = false;
|
|
||||||
const samples = [];
|
|
||||||
for (let pixel = 0; pixel <= width; pixel += 2) {
|
|
||||||
const x = (pixel / width) * range * 2 - range;
|
|
||||||
let y;
|
|
||||||
try {
|
|
||||||
y = evaluateExpression(expression, { ...variables, x });
|
|
||||||
} catch (error) {
|
|
||||||
if (pixel === 0) throw error;
|
|
||||||
drawing = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
samples.push({ x, y });
|
|
||||||
const screenY = toY(y);
|
|
||||||
if (!Number.isFinite(screenY) || screenY < -height * 2 || screenY > height * 3) {
|
|
||||||
drawing = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!drawing) context.moveTo(pixel, screenY);
|
|
||||||
else context.lineTo(pixel, screenY);
|
|
||||||
drawing = true;
|
|
||||||
}
|
|
||||||
context.stroke();
|
|
||||||
return samples;
|
|
||||||
}
|
|
||||||
|
|
||||||
function graphAnalysis(samples) {
|
|
||||||
const roots = [];
|
|
||||||
let extrema = 0;
|
|
||||||
for (let index = 1; index < samples.length; index += 1) {
|
|
||||||
const before = samples[index - 1];
|
|
||||||
const current = samples[index];
|
|
||||||
if (before.y === 0 || before.y * current.y < 0) {
|
|
||||||
const root = (before.x + current.x) / 2;
|
|
||||||
if (!roots.length || Math.abs(root - roots.at(-1)) > 0.15) roots.push(root);
|
|
||||||
}
|
|
||||||
if (index > 1) {
|
|
||||||
const previousSlope = before.y - samples[index - 2].y;
|
|
||||||
const currentSlope = current.y - before.y;
|
|
||||||
if (previousSlope * currentSlope < 0) extrema += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { roots: roots.slice(0, 8), extrema };
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGraph() {
|
|
||||||
const canvas = $tool("#graph-canvas");
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
const expressions = $tool("#graph-expression").value
|
|
||||||
.split("\n")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 4);
|
|
||||||
const range = Number($tool("#graph-range").value);
|
|
||||||
const parameter = Number($tool("#graph-parameter").value);
|
|
||||||
const width = canvas.width;
|
|
||||||
const height = canvas.height;
|
|
||||||
$tool("#graph-range-label").textContent = `−${range} 到 ${range}`;
|
|
||||||
$tool("#graph-parameter-label").textContent = `a = ${parameter}`;
|
|
||||||
$tool("#graph-error").textContent = "";
|
|
||||||
context.clearRect(0, 0, width, height);
|
|
||||||
context.fillStyle = "#fbfbf7";
|
|
||||||
context.fillRect(0, 0, width, height);
|
|
||||||
const toX = (x) => ((x + range) / (range * 2)) * width;
|
|
||||||
const toY = (y) => height / 2 - (y / range) * (height / 2);
|
|
||||||
|
|
||||||
context.strokeStyle = "#e4e5df";
|
|
||||||
context.lineWidth = 1;
|
|
||||||
for (let value = -range; value <= range; value += 1) {
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(toX(value), 0);
|
|
||||||
context.lineTo(toX(value), height);
|
|
||||||
context.moveTo(0, toY(value));
|
|
||||||
context.lineTo(width, toY(value));
|
|
||||||
context.stroke();
|
|
||||||
}
|
|
||||||
context.strokeStyle = "#718078";
|
|
||||||
context.lineWidth = 2;
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(0, height / 2);
|
|
||||||
context.lineTo(width, height / 2);
|
|
||||||
context.moveTo(width / 2, 0);
|
|
||||||
context.lineTo(width / 2, height);
|
|
||||||
context.stroke();
|
|
||||||
|
|
||||||
if (!expressions.length) {
|
|
||||||
$tool("#graph-error").textContent = "请至少输入一个函数";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const firstSamples = drawFunctionPath(
|
|
||||||
context,
|
|
||||||
expressions[0],
|
|
||||||
{ a: parameter },
|
|
||||||
range,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
GRAPH_COLORS[0],
|
|
||||||
);
|
|
||||||
expressions.slice(1).forEach((expression, index) => {
|
|
||||||
drawFunctionPath(
|
|
||||||
context,
|
|
||||||
expression,
|
|
||||||
{ a: parameter },
|
|
||||||
range,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
GRAPH_COLORS[index + 1],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($tool("#graph-integral").checked) {
|
|
||||||
context.fillStyle = "rgba(25, 101, 72, .13)";
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(0, height / 2);
|
|
||||||
firstSamples.forEach((item) => context.lineTo(toX(item.x), toY(item.y)));
|
|
||||||
context.lineTo(width, height / 2);
|
|
||||||
context.closePath();
|
|
||||||
context.fill();
|
|
||||||
}
|
|
||||||
if ($tool("#graph-derivative").checked) {
|
|
||||||
const derivative = firstSamples.slice(1, -1).map((item, index) => {
|
|
||||||
const before = firstSamples[index];
|
|
||||||
const after = firstSamples[index + 2];
|
|
||||||
return { x: item.x, y: (after.y - before.y) / (after.x - before.x) };
|
|
||||||
});
|
|
||||||
context.strokeStyle = "#111827";
|
|
||||||
context.lineWidth = 2;
|
|
||||||
context.setLineDash([9, 7]);
|
|
||||||
context.beginPath();
|
|
||||||
derivative.forEach((item, index) => {
|
|
||||||
const x = toX(item.x);
|
|
||||||
const y = toY(item.y);
|
|
||||||
if (index === 0) context.moveTo(x, y);
|
|
||||||
else context.lineTo(x, y);
|
|
||||||
});
|
|
||||||
context.stroke();
|
|
||||||
context.setLineDash([]);
|
|
||||||
}
|
|
||||||
const analysis = graphAnalysis(firstSamples);
|
|
||||||
const rootText = analysis.roots.length
|
|
||||||
? analysis.roots.map((item) => item.toFixed(2)).join("、")
|
|
||||||
: "当前范围未发现";
|
|
||||||
$tool("#graph-analysis").textContent =
|
|
||||||
`第一条曲线:近似零点 ${rootText};检测到 ${analysis.extrema} 个极值转折。`;
|
|
||||||
} catch (error) {
|
|
||||||
$tool("#graph-error").textContent = error.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function canvasPoint(canvas, event) {
|
function canvasPoint(canvas, event) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
@@ -273,6 +196,7 @@
|
|||||||
this.drawing = false;
|
this.drawing = false;
|
||||||
this.start = null;
|
this.start = null;
|
||||||
this.preview = null;
|
this.preview = null;
|
||||||
|
this.onChange = null;
|
||||||
this.reset();
|
this.reset();
|
||||||
canvas.addEventListener("pointerdown", (event) => this.startDrawing(event));
|
canvas.addEventListener("pointerdown", (event) => this.startDrawing(event));
|
||||||
canvas.addEventListener("pointermove", (event) => this.move(event));
|
canvas.addEventListener("pointermove", (event) => this.move(event));
|
||||||
@@ -290,6 +214,23 @@
|
|||||||
if (this.history.length > 20) this.history.shift();
|
if (this.history.length > 20) this.history.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitSnapshot() {
|
||||||
|
if (!this.onChange) return;
|
||||||
|
const data = this.canvas.toDataURL("image/png");
|
||||||
|
if (data.length <= 700000) this.onChange(data);
|
||||||
|
else showToast("当前画布内容较大,已保留本地编辑但暂停联机同步");
|
||||||
|
}
|
||||||
|
|
||||||
|
applySnapshot(data) {
|
||||||
|
if (typeof data !== "string" || !data.startsWith("data:image/")) return;
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.context.drawImage(image, 0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
};
|
||||||
|
image.src = data;
|
||||||
|
}
|
||||||
|
|
||||||
startDrawing(event) {
|
startDrawing(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.snapshot();
|
this.snapshot();
|
||||||
@@ -305,6 +246,7 @@
|
|||||||
this.context.fillStyle = $tool("#whiteboard-color").value;
|
this.context.fillStyle = $tool("#whiteboard-color").value;
|
||||||
this.context.font = `${size}px "SFMono-Regular", "PingFang SC", sans-serif`;
|
this.context.font = `${size}px "SFMono-Regular", "PingFang SC", sans-serif`;
|
||||||
this.context.fillText(text, this.start.x, this.start.y);
|
this.context.fillText(text, this.start.x, this.start.y);
|
||||||
|
this.emitSnapshot();
|
||||||
}
|
}
|
||||||
this.drawing = false;
|
this.drawing = false;
|
||||||
return;
|
return;
|
||||||
@@ -352,6 +294,7 @@
|
|||||||
this.move(event);
|
this.move(event);
|
||||||
this.drawing = false;
|
this.drawing = false;
|
||||||
this.preview = null;
|
this.preview = null;
|
||||||
|
this.emitSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
@@ -366,6 +309,7 @@
|
|||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
this.context.drawImage(image, 0, 0);
|
this.context.drawImage(image, 0, 0);
|
||||||
|
this.emitSnapshot();
|
||||||
};
|
};
|
||||||
image.src = source;
|
image.src = source;
|
||||||
}
|
}
|
||||||
@@ -373,6 +317,7 @@
|
|||||||
clear() {
|
clear() {
|
||||||
this.snapshot();
|
this.snapshot();
|
||||||
this.reset();
|
this.reset();
|
||||||
|
this.emitSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
addGrid() {
|
addGrid() {
|
||||||
@@ -400,6 +345,7 @@
|
|||||||
context.moveTo(0, this.canvas.height / 2);
|
context.moveTo(0, this.canvas.height / 2);
|
||||||
context.lineTo(this.canvas.width, this.canvas.height / 2);
|
context.lineTo(this.canvas.width, this.canvas.height / 2);
|
||||||
context.stroke();
|
context.stroke();
|
||||||
|
this.emitSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
addImage(file) {
|
addImage(file) {
|
||||||
@@ -421,6 +367,7 @@
|
|||||||
image.width * scale,
|
image.width * scale,
|
||||||
image.height * scale,
|
image.height * scale,
|
||||||
);
|
);
|
||||||
|
this.emitSnapshot();
|
||||||
};
|
};
|
||||||
image.src = reader.result;
|
image.src = reader.result;
|
||||||
};
|
};
|
||||||
@@ -439,6 +386,7 @@
|
|||||||
this.history = [];
|
this.history = [];
|
||||||
this.pending = [];
|
this.pending = [];
|
||||||
this.dragging = null;
|
this.dragging = null;
|
||||||
|
this.onChange = null;
|
||||||
canvas.addEventListener("pointerdown", (event) => this.pointerDown(event));
|
canvas.addEventListener("pointerdown", (event) => this.pointerDown(event));
|
||||||
canvas.addEventListener("pointermove", (event) => this.pointerMove(event));
|
canvas.addEventListener("pointermove", (event) => this.pointerMove(event));
|
||||||
canvas.addEventListener("pointerup", (event) => this.pointerUp(event));
|
canvas.addEventListener("pointerup", (event) => this.pointerUp(event));
|
||||||
@@ -448,6 +396,34 @@
|
|||||||
this.draw();
|
this.draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serialize() {
|
||||||
|
return {
|
||||||
|
points: this.points,
|
||||||
|
segments: this.segments,
|
||||||
|
circles: this.circles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
emitState() {
|
||||||
|
if (this.onChange) this.onChange(this.serialize());
|
||||||
|
}
|
||||||
|
|
||||||
|
applyState(payload) {
|
||||||
|
if (
|
||||||
|
!payload ||
|
||||||
|
!Array.isArray(payload.points) ||
|
||||||
|
!Array.isArray(payload.segments) ||
|
||||||
|
!Array.isArray(payload.circles)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.points = payload.points.slice(0, 300);
|
||||||
|
this.segments = payload.segments.slice(0, 500);
|
||||||
|
this.circles = payload.circles.slice(0, 300);
|
||||||
|
this.pending = [];
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
this.history.push(
|
this.history.push(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -501,6 +477,7 @@
|
|||||||
if (this.tool === "move") {
|
if (this.tool === "move") {
|
||||||
this.pointerMove(event);
|
this.pointerMove(event);
|
||||||
this.dragging = null;
|
this.dragging = null;
|
||||||
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.handlePoint(event);
|
this.handlePoint(event);
|
||||||
@@ -535,6 +512,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.draw();
|
this.draw();
|
||||||
|
this.emitState();
|
||||||
$tool("#geometry-hint").textContent = this.pending.length
|
$tool("#geometry-hint").textContent = this.pending.length
|
||||||
? "再选择一个点完成构造。"
|
? "再选择一个点完成构造。"
|
||||||
: "可继续创建或切换构造工具。";
|
: "可继续创建或切换构造工具。";
|
||||||
@@ -624,6 +602,7 @@
|
|||||||
this.circles = parsed.circles;
|
this.circles = parsed.circles;
|
||||||
this.pending = [];
|
this.pending = [];
|
||||||
this.draw();
|
this.draw();
|
||||||
|
this.emitState();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
@@ -633,15 +612,196 @@
|
|||||||
this.circles = [];
|
this.circles = [];
|
||||||
this.pending = [];
|
this.pending = [];
|
||||||
this.draw();
|
this.draw();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BoardRealtime {
|
||||||
|
constructor() {
|
||||||
|
this.session = null;
|
||||||
|
this.socket = null;
|
||||||
|
this.pollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
websocketUrl(path) {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${protocol}//${window.location.host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.pollTimer) window.clearInterval(this.pollTimer);
|
||||||
|
this.pollTimer = null;
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.onclose = null;
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
if (!this.session?.session_id) return;
|
||||||
|
try {
|
||||||
|
this.session = await api(`toolbox/boards/${this.session.session_id}/`);
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 404) this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.close();
|
||||||
|
if (!this.session?.websocket_path) return;
|
||||||
|
const socket = new WebSocket(this.websocketUrl(this.session.websocket_path));
|
||||||
|
this.socket = socket;
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
this.render("实时连接已建立");
|
||||||
|
socket.send(JSON.stringify({ type: "ping" }));
|
||||||
|
});
|
||||||
|
socket.addEventListener("message", async (event) => {
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
message.type === "canvas" &&
|
||||||
|
message.user_id !== String(state.user?.id)
|
||||||
|
) {
|
||||||
|
whiteboard.applySnapshot(message.payload);
|
||||||
|
} else if (
|
||||||
|
message.type === "geometry" &&
|
||||||
|
message.user_id !== String(state.user?.id)
|
||||||
|
) {
|
||||||
|
geometry.applyState(message.payload);
|
||||||
|
} else if (message.type === "state") {
|
||||||
|
await this.refresh();
|
||||||
|
if (message.reason === "joined" && this.session?.role === "host") {
|
||||||
|
this.send("canvas", whiteboard.canvas.toDataURL("image/png"));
|
||||||
|
this.send("geometry", geometry.serialize());
|
||||||
|
}
|
||||||
|
} else if (message.type === "error") {
|
||||||
|
showToast(message.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
this.render("实时连接已断开,正在轮询房间状态");
|
||||||
|
});
|
||||||
|
this.pollTimer = window.setInterval(() => this.refresh(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(type, payload) {
|
||||||
|
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
||||||
|
this.socket.send(JSON.stringify({ type, payload }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create() {
|
||||||
|
if (!requireAuth()) return;
|
||||||
|
const mode = $tool("#board-session-mode").value;
|
||||||
|
try {
|
||||||
|
this.session = await api("toolbox/boards/", {
|
||||||
|
method: "POST",
|
||||||
|
body: { mode },
|
||||||
|
});
|
||||||
|
this.render();
|
||||||
|
this.connect();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async join(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!requireAuth()) return;
|
||||||
|
const input = $tool("#board-code-input");
|
||||||
|
try {
|
||||||
|
this.session = await api("toolbox/boards/join/", {
|
||||||
|
method: "POST",
|
||||||
|
body: { code: input.value },
|
||||||
|
});
|
||||||
|
input.value = "";
|
||||||
|
this.render();
|
||||||
|
this.connect();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async guess(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!this.session) return;
|
||||||
|
const input = $tool("#board-guess-input");
|
||||||
|
try {
|
||||||
|
const result = await api(
|
||||||
|
`toolbox/boards/${this.session.session_id}/guess/`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: { guess: input.value },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.session = result;
|
||||||
|
showToast(result.message);
|
||||||
|
if (result.correct) input.value = "";
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(connectionMessage = "") {
|
||||||
|
const status = $tool("#board-session-status");
|
||||||
|
const target = $tool("#board-target");
|
||||||
|
const guessForm = $tool("#board-guess-form");
|
||||||
|
if (!this.session) {
|
||||||
|
status.textContent = "当前为本地画板,创建或加入后开始实时同步。";
|
||||||
|
target.hidden = true;
|
||||||
|
guessForm.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const participants = this.session.guest
|
||||||
|
? `${this.session.host} 与 ${this.session.guest}`
|
||||||
|
: `${this.session.host} 正在等待另一位用户`;
|
||||||
|
status.textContent =
|
||||||
|
`${this.session.mode_label} · 联机码 ${this.session.code} · ${participants}` +
|
||||||
|
(connectionMessage ? ` · ${connectionMessage}` : "");
|
||||||
|
target.hidden = !this.session.target;
|
||||||
|
target.textContent = this.session.target
|
||||||
|
? `本轮数学对象:${this.session.target}`
|
||||||
|
: "";
|
||||||
|
guessForm.hidden = !(
|
||||||
|
this.session.mode === "draw_guess" &&
|
||||||
|
this.session.role === "guest" &&
|
||||||
|
this.session.status === "active"
|
||||||
|
);
|
||||||
|
if (this.session.status === "completed") {
|
||||||
|
status.textContent +=
|
||||||
|
` · 本轮结束,比分 ${this.session.host_score}:${this.session.guest_score}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let whiteboard;
|
let whiteboard;
|
||||||
let geometry;
|
let geometry;
|
||||||
|
let boardRealtime;
|
||||||
|
|
||||||
function initDrawingTools() {
|
function initDrawingTools() {
|
||||||
whiteboard = new Whiteboard($tool("#whiteboard-canvas"));
|
whiteboard = new Whiteboard($tool("#whiteboard-canvas"));
|
||||||
geometry = new GeometryBoard($tool("#geometry-canvas"));
|
geometry = new GeometryBoard($tool("#geometry-canvas"));
|
||||||
|
boardRealtime = new BoardRealtime();
|
||||||
|
whiteboard.onChange = (payload) => boardRealtime.send("canvas", payload);
|
||||||
|
geometry.onChange = (payload) => boardRealtime.send("geometry", payload);
|
||||||
|
$$tool("[data-board-pane-toggle]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const pane = button.dataset.boardPaneToggle;
|
||||||
|
$$tool("[data-board-pane-toggle]").forEach((item) => {
|
||||||
|
item.classList.toggle("active", item === button);
|
||||||
|
});
|
||||||
|
$$tool("[data-board-pane]").forEach((item) => {
|
||||||
|
item.classList.toggle("active", item.dataset.boardPane === pane);
|
||||||
|
});
|
||||||
|
if (pane === "geometry") window.setTimeout(() => geometry.draw(), 30);
|
||||||
|
});
|
||||||
|
});
|
||||||
$$tool("[data-whiteboard-tool]").forEach((button) => {
|
$$tool("[data-whiteboard-tool]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
whiteboard.tool = button.dataset.whiteboardTool;
|
whiteboard.tool = button.dataset.whiteboardTool;
|
||||||
@@ -675,10 +835,35 @@
|
|||||||
$tool("#geometry-export").addEventListener("click", () =>
|
$tool("#geometry-export").addEventListener("click", () =>
|
||||||
downloadCanvas(geometry.canvas, "hulumath-geometry")
|
downloadCanvas(geometry.canvas, "hulumath-geometry")
|
||||||
);
|
);
|
||||||
|
$tool("#board-create").addEventListener("click", () => boardRealtime.create());
|
||||||
|
$tool("#board-join-form").addEventListener(
|
||||||
|
"submit",
|
||||||
|
(event) => boardRealtime.join(event),
|
||||||
|
);
|
||||||
|
$tool("#board-guess-form").addEventListener(
|
||||||
|
"submit",
|
||||||
|
(event) => boardRealtime.guess(event),
|
||||||
|
);
|
||||||
|
$tool("#board-code-input").addEventListener("input", (event) => {
|
||||||
|
event.target.value = event.target.value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, "")
|
||||||
|
.slice(0, 6);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
$tool("#calc-operation").addEventListener("change", updateCalculatorFields);
|
$tool("#calc-operation").addEventListener("change", () => {
|
||||||
|
setCalculatorCategory(
|
||||||
|
calculatorCategoryFor($tool("#calc-operation").value),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
$$tool("[data-calc-category]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
setCalculatorCategory(button.dataset.calcCategory);
|
||||||
|
});
|
||||||
|
});
|
||||||
$tool("#calc-run").addEventListener("click", runCalculator);
|
$tool("#calc-run").addEventListener("click", runCalculator);
|
||||||
$tool("#calc-input").addEventListener("keydown", (event) => {
|
$tool("#calc-input").addEventListener("keydown", (event) => {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") runCalculator();
|
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") runCalculator();
|
||||||
@@ -687,24 +872,22 @@
|
|||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
$tool("#calc-operation").value = button.dataset.calcOperation;
|
$tool("#calc-operation").value = button.dataset.calcOperation;
|
||||||
$tool("#calc-input").value = button.dataset.calcExample;
|
$tool("#calc-input").value = button.dataset.calcExample;
|
||||||
updateCalculatorFields();
|
setCalculatorCategory(
|
||||||
|
calculatorCategoryFor(button.dataset.calcOperation),
|
||||||
|
false,
|
||||||
|
);
|
||||||
runCalculator();
|
runCalculator();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
["#graph-run", "#graph-range", "#graph-parameter", "#graph-derivative", "#graph-integral"]
|
|
||||||
.forEach((selector) => $tool(selector).addEventListener("input", drawGraph));
|
|
||||||
$tool("#graph-expression").addEventListener("keydown", (event) => {
|
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") drawGraph();
|
|
||||||
});
|
|
||||||
initDrawingTools();
|
initDrawingTools();
|
||||||
updateCalculatorFields();
|
setCalculatorCategory("algebra", false);
|
||||||
drawGraph();
|
window.HuluGraph?.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
function activate(tool) {
|
function activate(tool) {
|
||||||
if (tool === "graph") window.setTimeout(drawGraph, 30);
|
if (tool === "graph") window.setTimeout(() => window.HuluGraph?.resize(), 30);
|
||||||
if (tool === "geometry") window.setTimeout(() => geometry.draw(), 30);
|
if (tool === "whiteboard") window.setTimeout(() => geometry.draw(), 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.HuluToolbox = { init, activate, runCalculator, drawGraph };
|
window.HuluToolbox = { init, activate, runCalculator };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+21
@@ -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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user