35 Commits
Author SHA1 Message Date
Jacky 8ac22a4c52 Merge pull request 'feat: upgrade toolbox for v1.2.1' (#38) from feat/v1.2.1-toolbox into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/38
2026-08-10 02:06:40 +08:00
Jacky 8e94f053b1 feat: upgrade toolbox for v1.2.1
CI / test (pull_request) Canceled after 52s
PR合并自动部署 / release-check (pull_request) Successful in 13s
PR合并自动部署 / deploy (pull_request) Successful in 13s
2026-08-10 02:05:21 +08:00
Jacky 30f335c83b Merge pull request 'Release/v1.2.0' (#37) from release/v1.2.0 into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/37
2026-08-10 01:29:22 +08:00
Jacky 6c9f475ba5 release: prepare Hulumath v1.2.0
CI / test (pull_request) Successful in 3m58s
PR合并自动部署 / release-check (pull_request) Successful in 13s
PR合并自动部署 / deploy (pull_request) Successful in 16s
2026-08-10 01:23:30 +08:00
Jacky aafaf1b77a feat: rebuild math life around marks and directed endings 2026-08-10 01:06:06 +08:00
Jacky b8916180a9 feat: add collaborative math board and draw guess mode 2026-08-10 00:47:03 +08:00
Jacky 40e9462842 feat: add unified contest pool and realtime game modes 2026-08-10 00:31:21 +08:00
Jacky 97ca20413e fix: complete v1.2 account and profile P0 fixes 2026-08-10 00:12:53 +08:00
Jacky 587962f9c9 Merge pull request 'new_bug_contest' (#23) from bug3.0 into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/23
2026-08-09 16:58:35 +08:00
huluxia 94188b5f0a new_bug_contest
CI / test (pull_request) Failing after 2m6s
PR合并自动部署 / release-check (pull_request) Successful in 13s
PR合并自动部署 / deploy (pull_request) Successful in 13s
2026-08-09 16:45:22 +08:00
Jacky 46815c28f1 Merge pull request 'Bug2.0' (#20) from bug2.0 into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/20
2026-08-09 16:22:21 +08:00
huluxia 589adfa5e6 contest
CI / test (pull_request) Successful in 4m2s
PR合并自动部署 / release-check (pull_request) Successful in 12s
PR合并自动部署 / deploy (pull_request) Successful in 14s
2026-08-09 16:15:41 +08:00
huluxia 9f9aa2825a update select video
CI / test (pull_request) Failing after 2m20s
2026-08-09 15:59:09 +08:00
huluxia ee006fba18 test contest 2026-08-09 15:33:07 +08:00
Jacky ebd6070aae Merge pull request 'ci: remove duplicate release dependency installs' (#19) from ci/avoid-repeat-dependency-downloads into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/19
2026-08-09 04:00:56 +08:00
Jacky 52816ce442 ci: remove duplicate release dependency installs
CI / test (pull_request) Successful in 3m24s
PR合并自动部署 / release-check (pull_request) Successful in 12s
PR合并自动部署 / deploy (pull_request) Successful in 12s
2026-08-09 03:58:04 +08:00
Jacky 903ead60c9 Merge pull request 'fix: restore KaTeX assets and deployment checks' (#16) from fix/latex-assets-and-deploy into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/16
2026-08-09 03:48:12 +08:00
Jacky 3e47ba787d fix: restore KaTeX assets and deployment checks
CI / test (pull_request) Canceled after 7s
PR合并自动部署 / release-check (pull_request) Successful in 1m40s
PR合并自动部署 / deploy (pull_request) Successful in 14s
2026-08-09 03:47:17 +08:00
Jacky feb8845ec7 Merge pull request 'Fix/api error details' (#14) from fix/api-error-details into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/14
2026-08-09 03:37:16 +08:00
Jacky f177739824 merge: sync latest main
CI / test (pull_request) Successful in 3m3s
PR合并自动部署 / release-check (pull_request) Successful in 1m32s
PR合并自动部署 / deploy (pull_request) Failing after 10s
2026-08-09 03:36:14 +08:00
Jacky aa3246a1cf merge: integrate bug1.0 updates 2026-08-09 03:34:36 +08:00
Jacky 2b87b32f5b Merge pull request 'update bug1.0' (#13) from bug1.0 into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/13
2026-08-09 03:34:04 +08:00
Jacky ce7bed5f19 fix: harden twenty four game submissions 2026-08-09 03:30:31 +08:00
huluxia 9f1872c558 update bug1.0
CI / test (pull_request) Successful in 3m8s
PR合并自动部署 / release-check (pull_request) Successful in 1m31s
PR合并自动部署 / deploy (pull_request) Failing after 10s
2026-08-09 03:29:50 +08:00
Jacky b1ac6cef86 fix: expose actionable API error details 2026-08-09 03:26:45 +08:00
Jacky 6dc9052219 Merge pull request 'Feat/realtime challenge match' (#12) from feat/realtime-challenge-match into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/12
2026-08-09 03:12:43 +08:00
Jacky 1dd828609e feat: add realtime challenge client and local smoke test
CI / test (pull_request) Successful in 2m53s
PR合并自动部署 / release-check (pull_request) Successful in 1m34s
PR合并自动部署 / deploy (pull_request) Successful in 12s
2026-08-09 03:08:01 +08:00
Jacky 821311f4ad feat: complete realtime challenge matchmaking 2026-08-09 03:07:38 +08:00
Jacky b6fc2431ca 更新 CONTRIBUTING.md 2026-08-09 02:24:14 +08:00
Jacky 59f6131ac6 Merge pull request 'Feat/toolbox and math games' (#7) from feat/toolbox-and-math-games into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/7
2026-08-09 02:13:07 +08:00
Jacky 2582bdfe3a Merge branch 'main' into feat/toolbox-and-math-games
CI / test (pull_request) Canceled after 51s
PR合并自动部署 / release-check (pull_request) Successful in 1m36s
PR合并自动部署 / deploy (pull_request) Successful in 27s
2026-08-09 02:12:18 +08:00
Jacky 2e96f9bb52 Merge pull request 'combined update : docs/toolbox' (#6) from docs/contribution-guide into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/6
2026-08-09 02:08:32 +08:00
Jacky 6cd155ef05 feat: expand toolbox and add math games
CI / test (pull_request) Canceled after 13s
2026-08-09 01:52:35 +08:00
Jacky b99a22fc06 feat: add math engine and puzzle services 2026-08-09 01:52:20 +08:00
Jacky 30d4bb15dd docs: add code contribution guide
CI / test (pull_request) Canceled after 13s
PR合并自动部署 / release-check (pull_request) Successful in 1m35s
PR合并自动部署 / deploy (pull_request) Successful in 10s
2026-08-09 01:12:25 +08:00
161 changed files with 9583 additions and 363 deletions
+1
View File
@@ -5,3 +5,4 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DATABASE_URL=mysql://hulumath:hulumath@127.0.0.1:3307/hulumath
REDIS_URL=redis://localhost:6379/0
CORS_ALLOWED_ORIGINS=http://localhost:3000
CALCULATOR_RATE=30/minute
+2
View File
@@ -2,6 +2,7 @@
# 包含 #、空格等特殊字符的值请用单引号包裹。
DJANGO_SETTINGS_MODULE=config.settings
APP_VERSION=1.2.1
DJANGO_SECRET_KEY='replace-with-at-least-50-random-characters'
DJANGO_DEBUG=false
DJANGO_USE_HTTPS=true
@@ -14,6 +15,7 @@ CORS_ALLOWED_ORIGINS=https://math.example.com
CSRF_TRUSTED_ORIGINS=https://math.example.com
API_ANON_RATE=120/minute
API_USER_RATE=600/minute
CALCULATOR_RATE=30/minute
SECURE_HSTS_SECONDS=31536000
# 仅供部署脚本直接访问 127.0.0.1:8000 时设置 Host 头。
+10
View File
@@ -60,6 +60,16 @@ jobs:
- name: ASGI import check
working-directory: backend
run: ../.venv-ci/bin/python -c "from config.asgi import application; print(type(application).__name__)"
- name: Production static assets check
working-directory: backend
env:
DJANGO_DEBUG: "false"
DJANGO_USE_HTTPS: "false"
DJANGO_SECRET_KEY: ci-static-assets-check
DJANGO_ALLOWED_HOSTS: localhost
DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
REDIS_URL: redis://127.0.0.1:6379/0
run: ../.venv-ci/bin/python manage.py collectstatic --noinput --clear
- name: SQLite tests and coverage
run: |
.venv-ci/bin/python -m pytest -q \
+11 -51
View File
@@ -15,64 +15,24 @@ jobs:
release-check:
if: ${{ github.event.pull_request.merged == true }}
runs-on: ubuntu-latest
timeout-minutes: 15
services:
mysql:
image: mysql:8.0.35
env:
MYSQL_ROOT_PASSWORD: ci-root-password
MYSQL_DATABASE: hulumath
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -pci-root-password --silent"
--health-interval 5s
--health-timeout 5s
--health-retries 20
timeout-minutes: 5
steps:
- name: 检出 main
env:
REPOSITORY_URL: http://117.72.28.96:8765/Jacky/Hulumath-Web.git
run: git clone --branch main --single-branch "$REPOSITORY_URL" .
- name: 显示 Python 版本
run: python3 --version
- name: 安装 MySQL 编译依赖
- name: 检查发布文件和脚本语法
run: |
sudo apt-get update
sudo apt-get install -y \
default-libmysqlclient-dev \
pkg-config \
python3-dev \
python3-venv
- name: 安装发布检查依赖
run: |
python3 -m venv .venv-release
.venv-release/bin/python -m pip install \
--index-url https://mirrors.aliyun.com/pypi/simple \
--timeout 120 \
--retries 5 \
-r requirements.txt
- name: 检查迁移文件
working-directory: backend
run: ../.venv-release/bin/python manage.py makemigrations --check --dry-run
- name: Django 系统检查
working-directory: backend
run: ../.venv-release/bin/python manage.py check
- name: ASGI 启动导入检查
working-directory: backend
run: ../.venv-release/bin/python -c "from config.asgi import application; print(type(application).__name__)"
- name: MySQL 发布迁移检查
env:
DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
run: |
.venv-release/bin/python backend/manage.py check --database default
.venv-release/bin/python backend/manage.py check_mysql
.venv-release/bin/python backend/manage.py migrate --noinput
set -eu
test -f requirements.txt
test -f backend/manage.py
test -f deploy/hulumath-web.service
test -f scripts/deploy_production.sh
test -f scripts/smoke_production.py
bash -n scripts/deploy_production.sh
python3 -m compileall -q backend scripts
git diff-tree --check -m -r HEAD
deploy:
if: ${{ github.event.pull_request.merged == true }}
+503
View File
@@ -0,0 +1,503 @@
新功能前要用分支发PR!禁止直接向main提交。
# Hulumath-Web 代码贡献指南
本指南面向人类开发者和 AI 编程助手。开始修改前,请完整阅读本文。
葫芦数学不是通用内容站,而是围绕 MathBTI、数学人生、比赛、工具箱、视频探索和长期成长构建的“数学人生宇宙”。本仓库已经进入可部署的 Django 生产形态,贡献时应优先保持业务一致性、数据安全和可回滚性,不要把它当作一次性 Demo。
## 1. 必须遵守的协作规则
以下规则没有例外:
1. **禁止直接向 `main` 推送。**
2. **所有改动必须通过 Pull RequestPR)提交。**
3. **所有 PR 必须由仓库负责人 Jacky 审查并明确批准后才能合并。**
4. CI 未通过时不得合并,不得通过删除测试、降低规则或跳过检查来“修复” CI。
5. 不得提交密码、Token、SSH 私钥、生产 `.env.production`、真实用户数据或数据库备份。
6. 不得直接在生产服务器上改源码后绕过 Git。紧急修复也应补回 PR。
7. 不得回退、覆盖或整理与当前任务无关的他人改动。
如果你没有仓库写权限,请从 fork 创建分支并向本仓库提交 PR。
## 2. AI 协作者五分钟快速入口
AI 在采取任何修改动作前,至少完成以下步骤:
1. 阅读本文件。
2. 阅读任务直接涉及的代码、测试和迁移。
3. 执行 `git status --short --branch`,确认工作区是否已有他人改动。
4.`rg` 搜索现有实现、调用方和测试,不凭文件名猜测行为。
5. 对照下方“文档优先级”和“不可破坏的系统约束”。
6. 明确最小修改范围,避免顺手重构。
7. 修改后运行与风险相匹配的检查。
8. 总结行为变化、测试结果、迁移影响和未验证风险。
AI 不应:
- 在没有阅读上下文时批量重写模块。
- 因测试失败而删除断言、吞掉异常或放宽安全校验。
- 自行修改产品规则、奖励数值、剧情事实或历史人物设定。
- 使用字符串替换模拟结构化数据迁移。
- 为了“代码更现代”而更换框架、数据库或部署方式。
- 自动合并 PR。最终审查与合并权属于 Jacky。
## 3. 文档与事实来源优先级
遇到冲突时按以下顺序判断:
1. **当前任务中仓库负责人的明确要求。**
2. **本文件中的贡献与工程规则。**
3. **数据库迁移、当前代码、测试和 API 契约所表达的真实行为。**
4. **`Bible.md` 中的产品定位、核心循环和架构决策。**
5. **`docs/DEPLOYMENT.md``docs/BAOTA_UBUNTU_FROM_ZERO.md` 中的生产约束。**
6. **`README.md` 中的使用入口。**
7. **`docs/TUTORIAL.md` 及旧脚本只作为历史和内容迁移参考。**
旧 Flask、旧 SQLite 和旧前端文档不能覆盖当前 Django/MySQL 生产设计。
`docs/` 中可能包含 Word 导出、UTF-16 或历史格式文件。编辑器显示乱码时,不要直接重写或删除原文件;优先使用同名 Markdown 版本,必要时先确认编码和来源。
## 4. 当前技术栈
- Django 4.2
- Django REST Framework
- Django Channels
- MySQL 8.0.35
- Redis 7.x Channel Layer
- Gunicorn + UvicornWorker
- Nginx
- WhiteNoise
- 原生 HTML、CSS、JavaScript Web 客户端
- pytest、pytest-django、pytest-cov
- Ruff
- Gitea Actions
本地最低兼容 Python 版本为 3.9,建议使用 Python 3.11 或 3.12。生产环境使用 Python 3.12。
当前 Web 客户端没有 Node 构建步骤。不要仅为一个小功能引入 Node、前端框架或新的打包链。
## 5. 仓库结构与模块所有权
```text
backend/
├── accounts/ 用户、邀请码、会话、游客迁移、审计
├── math_life/ MathBTI、数学人格、剧情版本、存档、人物 Skill
├── contest/ 题库、比赛、实时匹配、判分、Rating、反作弊
├── progression/ 五维能力、数学精灵、卡牌、奖励流水
├── content/ 视频、知识卡片、人物内容、收藏、观看进度
├── latex_lab/ 公式文档、课程、练习与 LaTeX 判定
├── engagement/ 签到、通知等回访能力
├── common/ 通用 API、日志、健康检查、管理后台
├── config/ Django 设置、URL、ASGI/WSGI
├── templates/ 网站与 Django Admin 模板
└── static/ CSS、JavaScript 等静态资源源文件
scripts/ 部署、生产冒烟检查等运维脚本
docs/ 部署、迁移、产品和内容文档
.gitea/workflows/ PR CI 与合并后自动部署
```
### 静态资源特别说明
- `backend/static/` 是源文件,应在这里修改。
- `backend/staticfiles/``collectstatic` 产物,不应手工编辑。
- 普通功能 PR 不应提交 `backend/staticfiles/` 的意外变化。
- 生产部署会自动执行 `collectstatic` 并生成带指纹资源。
## 6. 核心请求路径
典型请求路径如下:
```text
浏览器
→ Nginx
→ Gunicorn/Uvicorn ASGI
→ Django URL / Channels 路由
→ View / Consumer
→ Service
→ Model / MySQL / Redis
```
职责建议:
- View/Consumer:鉴权、解析请求、返回响应。
- Service:事务、业务规则、判分、奖励、状态迁移。
- Model:数据结构、约束、索引和轻量领域属性。
- Serializer:输入校验与 API 表达。
- Template/JS:交互与展示,不承担正式判分和奖励真相。
复杂业务不要全部写进 View,也不要把正式规则只放在浏览器。
## 7. 不可破坏的系统约束
### 7.1 服务端是正式数据唯一事实来源
以下结果必须由服务端决定并持久化:
- MathBTI 正式结果
- 剧情存档、选择、关系和结局
- Contest 计时、答案、分数、Rating 和反作弊标记
- 视频完成状态
- 五维能力、精灵经验、卡牌和奖励
浏览器状态只能用于临时 UI,不得代替正式数据库记录。
### 7.2 幂等性
剧情选择、比赛提交、奖励发放、游客数据迁移等可重试写操作必须幂等。
- 优先使用数据库唯一约束和事务保证幂等。
- 客户端正式提交应提供 `Idempotency-Key`
- 不要只依赖“按钮禁用”防止重复请求。
- HTTP 测试环境不是安全上下文,前端不能假设 `crypto.randomUUID()` 一定存在。
### 7.3 数据库
- 生产数据库必须为 MySQL 8.0.35 或更高兼容版本。
- 禁止把生产改回 SQLite、MySQL 5.7 或直接暴露数据库公网端口。
- 字符集必须支持 `utf8mb4`
- 表引擎使用 InnoDB。
- 事务隔离级别为 `READ COMMITTED`
- 实时匹配依赖 `SELECT ... FOR UPDATE SKIP LOCKED` 和匹配索引。
SQLite 只用于快速本地测试。涉及锁、排序规则、事务或 MySQL 特性的改动必须在 MySQL 上验证。
### 7.4 Redis 与 WebSocket
- Redis 是 Channels 的消息层,不是可随意移除的缓存依赖。
- ASGI 初始化顺序经过特殊处理,模型相关 Consumer 必须在 Django App Registry 初始化后导入。
- 修改 `config/asgi.py`、Consumer 或路由时,必须验证 ASGI 导入和 WebSocket。
### 7.5 内容版本
- 已发布题目和剧情内容使用版本模型,避免直接覆盖历史运行所依赖的内容。
- `StoryRun` 应指向具体 `StoryVersion`
- 比赛题目应指向具体 `QuestionVersion`
- 修改种子内容时保持命令可重复执行。
- 种子命令不能加入每次生产部署,以免覆盖运营修改。
### 7.6 用户和权限
- 使用项目自定义 UUID 用户模型,不要绕过 `AUTH_USER_MODEL`
- 普通用户不能看到或访问运营后台。
- `is_staff``is_superuser` 等权限字段不得通过普通用户 API 写入。
- 任何后台入口可见性都不能替代服务端权限校验。
### 7.7 生产部署
- 生产进程由 systemd 管理,不使用宝塔 Python 项目管理器。
- 应用监听 `127.0.0.1:8000`,由 Nginx 对外代理。
- 当前测试生产入口使用 `4321`,不要开放应用内部 `8000`
- 部署前自动备份 MySQL,执行迁移和静态资源收集。
- 部署后验证 HTTP、数据库、Redis、WebSocket、页面和视频目录。
- 不要在自动回退中执行破坏性数据库反向迁移。
## 8. API 与前端约定
### API
- HTTP API 使用 `/api/v1/` 前缀。
- WebSocket 使用 `/ws/`,业务 WebSocket 通常使用 `/ws/v1/`
- API 异常使用统一结构:
```json
{
"error": {
"code": "request_error",
"message": "请求未能完成",
"details": {},
"request_id": "..."
}
}
```
- 新接口应遵循现有鉴权、CSRF、限流和 Request ID 约定。
- 不要在响应中泄露内部异常、密钥或敏感用户字段。
### 前端
- 复用 `backend/static/js/app.js` 中的 `api()`、状态和渲染模式。
- 使用 DOM API 和 `textContent` 表达不可信内容,避免直接拼接 HTML。
- 修改用户可见流程时同时检查桌面端和移动端。
- 当前生产可能运行在纯 HTTP IP 环境,不要无条件依赖安全上下文 API。
- 后台主题源文件为 `backend/static/admin/css/hulumath_admin.css`
- CSS 修改要检查颜色对比度、禁用态、长内容滚动和系统深色偏好。
## 9. 本地开发
### 初始化
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
make migrate
make seed
make run
```
本地地址:
```text
网站:http://127.0.0.1:8000/
后台:http://127.0.0.1:8000/admin/
邀请码:HULU2026
```
创建本地管理员:
```bash
cd backend
../.venv/bin/python manage.py createsuperuser
```
### 环境变量
`.env.example``.env.production.example` 复制本地文件,不要修改并提交真实值。
```bash
cp .env.example .env.local
set -a
source .env.local
set +a
```
生产要求 `DJANGO_DEBUG=false`、MySQL `DATABASE_URL`、Redis `REDIS_URL`、强随机 `DJANGO_SECRET_KEY` 和正确的 Host/CORS/CSRF 来源。
## 10. 推荐修改流程
### 10.1 创建分支
先同步 `main`,再创建语义清晰的分支:
```bash
git switch main
git pull --ff-only origin main
git switch -c feat/short-description
```
常用前缀:
- `feat/`:用户可见功能
- `fix/`:缺陷修复
- `ci/`CI/CD
- `docs/`:文档
- `refactor/`:无行为变化的重构
- `test/`:测试改进
### 10.2 先读后改
查找实现和调用方:
```bash
rg "目标类名|函数名|API 路径" backend
rg --files backend/<app>
```
至少阅读:
- 目标模块的 model/service/view/serializer
- 对应 URL 或 routing
- 现有测试
- 相关迁移
- 调用该行为的前端代码
### 10.3 小步提交
- 一个 PR 解决一个明确问题。
- 优先提交可运行的垂直切片。
- 不夹带格式化整个仓库、目录重命名或无关依赖升级。
- 提交信息使用祈使式或清楚的类型前缀,例如:
```text
feat: add story resume endpoint
fix: make contest submission idempotent
ci: add production smoke check
docs: document content authoring flow
```
## 11. 数据模型与迁移
修改 Django Model 时:
```bash
cd backend
../.venv/bin/python manage.py makemigrations
../.venv/bin/python manage.py makemigrations --check --dry-run
../.venv/bin/python manage.py migrate
```
要求:
- 迁移文件必须随 Model 变更提交。
- 为唯一性、幂等性和高频查询使用数据库约束或索引。
- 高风险迁移采用“先扩展、后切换、再清理”。
- 不在同一发布中删除旧字段并立即依赖不可回退的新结构。
- 数据迁移必须可审查、可重复或明确记录一次性边界。
- 禁止直接复制 SQLite 文件、MySQL 数据目录或跨数据库 dump 作为迁移方案。
PR 描述中必须说明:
- 是否新增迁移
- 是否锁表或扫描大表
- 是否需要数据回填
- 应用代码如何兼容发布前后的 schema
- 回滚时数据库如何处理
## 12. 测试与质量门禁
### 最小本地检查
文档以外的代码改动至少运行:
```bash
.venv/bin/ruff check backend scripts
make check
make test
```
### 与 PR CI 对齐
```bash
.venv/bin/ruff check backend scripts
cd backend
../.venv/bin/python manage.py makemigrations --check --dry-run
../.venv/bin/python manage.py check
../.venv/bin/python -c \
"from config.asgi import application; print(type(application).__name__)"
cd ..
.venv/bin/python -m pytest -q \
--cov=backend \
--cov-config=.coveragerc \
--cov-report=term \
--cov-fail-under=75
```
Gitea CI 还会在隔离的 MySQL 8.0.35 容器中执行:
```bash
python backend/manage.py check --database default
python backend/manage.py check_mysql
python backend/manage.py migrate --noinput
pytest -q
```
### 测试原则
- Bug 修复必须先理解复现条件,并增加能防止回归的测试。
- Service 层规则优先写单元测试。
- API 权限和响应写请求测试。
- MySQL 锁、并发和事务行为不能只用 SQLite 测试。
- 前端缺陷至少增加静态资产断言;关键交互应进行浏览器验证。
- 用户流程、后台样式和响应式布局应附截图或录屏。
- 覆盖率是下限,不是目标;不要为了数字测试无意义代码。
## 13. PR 要求
PR 标题应描述结果,而不是过程:
```text
fix: prevent duplicate contest settlement
feat: add actuarial story import
```
PR 描述至少包含:
```markdown
## 背景
为什么需要修改。
## 变更
具体改变了什么行为和模块。
## 验证
执行了哪些测试,结果是什么。
## 数据与部署
是否有迁移、种子、环境变量、静态资源或回滚影响。
## 截图
涉及 UI 时提供修改前后截图。
```
提交 PR 后:
1. 等待 `CI / test` 全部通过。
2. 处理审查意见,不要无解释地关闭讨论。
3. 请求 Jacky 审查。
4. 只有 Jacky 明确批准后才可合并。
5. 合并后观察 `PR合并自动部署``release-check` 做无依赖轻量检查,`deploy` 执行生产发布与冒烟。
6. 部署失败时保留日志,先判断是代码、迁移、网络还是冒烟检查问题。
## 14. 安全与隐私
- 不记录真实密码、Cookie、Session、Token 或私钥。
- 日志中使用 Request ID,避免打印完整请求体和敏感字段。
- 最小化收集未成年人信息,不要求真实学校、姓名或精确年龄。
- 新的用户输入必须校验长度、类型和权限。
- 文件上传、富文本、外部 URL 和管理员批量操作需要单独安全评审。
- 不要通过前端隐藏代替后端权限控制。
- 不要关闭 CSRF、CORS、密码校验或生产安全检查来解决局部问题。
## 15. 内容贡献规范
剧情、数学人物和题目既是内容,也是生产数据。
### 剧情
- 使用版本化 Story 内容。
- 节点 ID 稳定且唯一。
- 每个 choice 的 `next` 必须存在。
- 结局节点不再提供 choice。
- 真实人物内容应列出事实来源和虚构边界。
- 不擅自改写已发布存档所依赖的版本。
### 题目
- 正确答案和解释属于服务端版本。
- 不把正式答案提前发送给未提交的客户端。
- 题号必须为正整数且不重复。
- 题目更新创建新版本,不覆盖历史正式尝试。
- 注意 Unicode 数学符号与普通 ASCII 输入的归一化边界。
### 视频与成长
- 五维能力固定为:眼光、人文、侦探、建模、联结。
- 视频完成奖励必须幂等。
- 内容筛选字段和能力映射保持后台、API、前端一致。
## 16. 不应出现在普通 PR 中的改动
除非任务明确要求,否则不要:
- 替换 Django、MySQL、Redis、Channels 或部署方案。
- 将模块化单体拆成微服务。
- 引入 Kubernetes。
- 新建长期并行的第二套前端或后端。
- 批量重写全部剧情和种子数据。
- 修改生产服务器路径、端口、systemd 服务名或 Gitea Secrets。
- 提交 `.env.production`、备份、数据库文件和运行日志。
- 手改 `backend/staticfiles/`
- 重新运行生产种子命令覆盖运营数据。
- 降低覆盖率门槛、删除 Ruff 规则或跳过 MySQL CI。
需要做上述变更时,先提交设计说明并获得 Jacky 明确批准。
## 17. 完成定义
一个贡献只有同时满足以下条件才算完成:
- 需求行为已实现,且没有明显超出范围。
- 代码遵循现有模块边界。
- 数据约束、事务和幂等性得到处理。
- 测试覆盖新增行为和关键失败路径。
- Ruff、Django check、迁移检查和 pytest 通过。
- 涉及 MySQL、Redis、WebSocket 或部署时完成对应验证。
- 涉及 UI 时检查桌面端、移动端和颜色对比度。
- 文档、环境变量示例和迁移说明已同步。
- 没有提交秘密或生成垃圾。
- PR 已由 Jacky 审查并明确批准。
不确定时,不要猜测产品规则。把问题、已知事实、可选方案和影响写进 PR,请仓库负责人决策。
+8 -1
View File
@@ -1,4 +1,4 @@
.PHONY: install migrate seed run test check
.PHONY: install migrate seed local-admin run run-asgi test check
install:
python3 -m venv .venv
@@ -11,12 +11,19 @@ seed:
cd backend && ../.venv/bin/python manage.py seed_initial_content
cd backend && ../.venv/bin/python manage.py seed_contests
local-admin:
cd backend && ../.venv/bin/python manage.py init_local_admin
run:
cd backend && ../.venv/bin/python manage.py runserver
run-asgi:
cd backend && ../.venv/bin/uvicorn config.asgi:application --host 127.0.0.1 --port 8000 --reload
test:
.venv/bin/python -m pytest -q
check:
.venv/bin/ruff check backend scripts
cd backend && ../.venv/bin/python manage.py check
cd backend && ../.venv/bin/python manage.py makemigrations --check --dry-run
+25 -3
View File
@@ -2,19 +2,29 @@
面向全年龄数学兴趣用户的“数学人生宇宙”。当前仓库包含可运行的 Django 模块化单体、响应式 Web 客户端、运营后台、内容种子、实时比赛基础设施和生产部署配置。
当前发布版本:**v1.2.1**。变更与迁移说明见
[v1.2.1 Release Notes](docs/RELEASE_NOTES_V1.2.1.md)。
## 参与贡献
提交代码、内容或文档前,请先完整阅读 [代码贡献指南](CONTRIBUTING.md)。
所有改动必须通过 PR 提交,并由仓库负责人 Jacky 审查批准后才能合并。贡献指南同时包含供 AI 编程助手快速建立上下文的架构、约束、测试和部署说明。
## 已实现
- 邀请码注册、登录、个人资料、会话记录和管理员审计模型
- 12 题 MathBTI、16 种数学人格、人物卡与数学精灵初始化
- 统一版本化剧情引擎、85 节点信仰者主线、2 个人物 Skill 样板
- 剧情服务端存档、嵌套资源效果、结局与幂等选择
- 入门、标准、进阶三赛道的实时 1v1今日挑战和单人闯关
- 统一玩家池的口算、数独 Timerun、24 点实时 1v1,以及随机今日挑战和单人闯关
- 题目版本、服务端计时判分、Elo Rating、排行榜和基础反作弊
- Channels WebSocket 比赛进度通道,Redis Channel Layer
- LaTeX 文档与版本、六级零基础课程、练习判定
- 54 条旧版志愿者视频、五维能力地图、专业筛选与融合视频流
- 视频观看进度、幂等奖励、收藏、五维能力、数学精灵和人物卡册
- 多工具工具箱:口算入口、科学计算器、符号查询、函数绘图和 LaTeX Lab
- 多工具工具箱:强计算器、增强函数绘图、统一联机数学画板、符号查询和 LaTeX Lab
- 信仰者人生 20 印记、4 个组合彩蛋、4 个直博方向与独立全屏交互
- Django Admin、健康检查、请求 ID、限流与统一 API 错误结构
- MySQL 8.0/Redis Docker Compose、Gitea CI 和自动化测试
@@ -33,9 +43,19 @@ make run
管理后台位于 `http://127.0.0.1:8000/admin/`。创建管理员:
```bash
make local-admin
```
默认本地测试账号为 `local_admin` / `LocalAdmin2026!`。该命令仅允许在
`DJANGO_DEBUG=true` 时运行,不会接触或修改生产管理员。需要自定义时:
```bash
cd backend
../.venv/bin/python manage.py createsuperuser
../.venv/bin/python manage.py init_local_admin \
--username jacky_local \
--password '仅用于本机的测试密码' \
--reset-password
```
## 测试
@@ -70,6 +90,7 @@ Gitea 会在 PR 合并到 `main` 后自动测试和部署:
- [Ubuntu + 宝塔面板从零部署](docs/BAOTA_UBUNTU_FROM_ZERO.md)
- [自动发布机制与运维说明](docs/DEPLOYMENT.md)
- [MySQL 8 数据迁移说明](docs/MYSQL8_MIGRATION.md)
- [本地实时 1v1 与联机码约战测试](docs/LOCAL_REALTIME_TEST.md)
## 目录
@@ -82,6 +103,7 @@ backend/
├── progression/ # 五维能力、精灵、卡牌、奖励
├── content/ # 视频、知识卡片、人物内容
├── engagement/ # 签到与通知
├── toolbox/ # 受限数学计算内核与工具 API
├── common/ # 健康检查、错误、日志
└── config/ # Django/ASGI 配置
```
+1
View File
@@ -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()
+52 -4
View File
@@ -1,17 +1,65 @@
import logging
from rest_framework.views import exception_handler as drf_exception_handler
logger = logging.getLogger(__name__)
def _error_messages(details):
messages = []
def collect(value):
if isinstance(value, dict):
for item in value.values():
collect(item)
elif isinstance(value, (list, tuple)):
for item in value:
collect(item)
elif value is not None:
message = str(value).strip()
if message and message not in messages:
messages.append(message)
collect(details)
return messages
def _error_message(details):
messages = _error_messages(details)
if not messages:
return "请求未能完成"
return "".join(messages)[:300]
def exception_handler(exc, context):
response = drf_exception_handler(exc, context)
request = context.get("request")
if response is None:
logger.exception(
"api_unhandled_error method=%s path=%s",
getattr(request, "method", "-"),
getattr(request, "path", "-"),
)
return response
request = context.get("request")
details = response.data
code = getattr(exc, "default_code", "request_error")
message = _error_message(details)
fields = ",".join(details.keys()) if isinstance(details, dict) else "-"
logger.info(
"api_request_error method=%s path=%s status=%s code=%s fields=%s message=%s",
getattr(request, "method", "-"),
getattr(request, "path", "-"),
response.status_code,
code,
fields,
message,
)
response.data = {
"error": {
"code": getattr(exc, "default_code", "request_error"),
"message": "请求未能完成",
"details": response.data,
"code": code,
"message": message,
"details": details,
"request_id": getattr(request, "request_id", None),
}
}
+201
View File
@@ -1,6 +1,9 @@
import re
from pathlib import Path
from django.conf import settings
from django.core.management import call_command
from django.test import override_settings
STATIC_ROOT = Path(settings.BASE_DIR) / "static"
@@ -13,6 +16,18 @@ def test_app_js_幂等键兼容非安全上下文():
assert source.count('"Idempotency-Key": createIdempotencyKey()') == 2
def test_app_js_api_错误优先显示详情并输出安全诊断日志():
source = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
assert "function collectApiErrorMessages(" in source
assert "const baseMessage = detailMessages.length" in source
assert 'console.error("[Hulumath API]", {' in source
assert "requestId: error.requestId" in source
assert "error.details = details || null" in source
assert "payload.error?.message || payload.detail ||" in source
assert "payload.error?.message || payload.detail ||\n (details ?" not in source
def test_app_css_答题提交按钮可见且长弹窗可滚动():
source = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
@@ -34,3 +49,189 @@ def test_admin_css_深色系统下仍保持完整浅色主题():
assert "background-color: #ffffff !important" in source
assert ".theme-toggle" in source
assert "display: none" in source
def test_toolbox_and_games_资源入口与移动端触控样式存在():
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
toolbox = (STATIC_ROOT / "js" / "toolbox.js").read_text(encoding="utf-8")
games = (STATIC_ROOT / "js" / "games.js").read_text(encoding="utf-8")
styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
assert "js/toolbox.js" in template
assert "js/games.js" in template
assert 'id="whiteboard-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 "window.HuluToolbox" 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 "sudoku-board" in games
assert 'errorMessage.className = "form-error game-form-error"' in games
assert "errorMessage.textContent = error.message" in games
assert "touch-action: none" in styles
assert ".calculator-controls [hidden]" in styles
assert ".sudoku-board" in styles
assert "@media (max-width: 700px)" in styles
def test_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_前端资源存在():
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8")
styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
assert 'id="challenge-create"' in template
assert 'id="challenge-join-form"' in template
assert template.count("js/realtime.js") == 1
assert "new WebSocket" in realtime
assert "setInterval(refreshMatch, 2000)" in realtime
assert "Idempotency-Key" in realtime
assert ".challenge-panel" in styles
assert ".realtime-progress-panel" in styles
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
+25
View File
@@ -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
+9 -2
View File
@@ -1,14 +1,21 @@
from django.conf import settings
from django.db import connection
from django.http import JsonResponse
from django.shortcuts import render
def home(request):
return render(request, "index.html")
return render(request, "index.html", {"app_version": settings.APP_VERSION})
def health(request):
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
return JsonResponse({"status": "ok", "database": "ok"})
return JsonResponse(
{
"status": "ok",
"database": "ok",
"version": settings.APP_VERSION,
}
)
+2
View File
@@ -11,6 +11,7 @@ django_asgi_application = get_asgi_application()
from common.consumers import HealthConsumer
from contest.routing import websocket_urlpatterns
from toolbox.routing import websocket_urlpatterns as toolbox_websocket_urlpatterns
application = ProtocolTypeRouter(
{
@@ -20,6 +21,7 @@ application = ProtocolTypeRouter(
[
path("ws/health/", HealthConsumer.as_asgi()),
*websocket_urlpatterns,
*toolbox_websocket_urlpatterns,
]
)
),
+3
View File
@@ -6,6 +6,7 @@ from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.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")
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
@@ -38,6 +39,7 @@ INSTALLED_APPS = [
"latex_lab",
"content",
"engagement",
"toolbox",
"common",
]
@@ -142,6 +144,7 @@ REST_FRAMEWORK = {
"DEFAULT_THROTTLE_RATES": {
"anon": os.getenv("API_ANON_RATE", "120/minute"),
"user": os.getenv("API_USER_RATE", "600/minute"),
"calculator": os.getenv("CALCULATOR_RATE", "30/minute"),
},
}
+1
View File
@@ -17,4 +17,5 @@ urlpatterns = [
path("api/v1/latex/", include("latex_lab.urls")),
path("api/v1/content/", include("content.urls")),
path("api/v1/progression/", include("progression.urls")),
path("api/v1/toolbox/", include("toolbox.urls")),
]
+44 -2
View File
@@ -7,6 +7,7 @@ from .models import (
ContestAttempt,
ContestQuestion,
LeaderboardSnapshot,
MathGameAttempt,
Question,
QuestionVersion,
RatingHistory,
@@ -33,7 +34,7 @@ class ContestAdmin(admin.ModelAdmin):
class ContestAttemptAdmin(admin.ModelAdmin):
list_display = ("id", "user", "contest", "status", "score", "duration_ms", "started_at")
list_filter = ("status", "contest__kind", "contest__track")
readonly_fields = ("started_at", "submitted_at")
readonly_fields = ("question_order", "started_at", "submitted_at")
@admin.register(CheatFlag)
@@ -61,6 +62,47 @@ class QuestionVersionAdmin(admin.ModelAdmin):
admin.site.register(ContestAnswer)
admin.site.register(RealtimeMatch)
admin.site.register(RatingHistory)
admin.site.register(LeaderboardSnapshot)
@admin.register(RealtimeMatch)
class RealtimeMatchAdmin(admin.ModelAdmin):
list_display = (
"id",
"contest",
"match_type",
"game_kind",
"challenge_code",
"player_one",
"player_two",
"status",
"created_at",
)
list_filter = ("match_type", "game_kind", "status", "contest__track")
search_fields = (
"challenge_code",
"player_one__username",
"player_two__username",
)
readonly_fields = ("created_at", "started_at", "completed_at")
ordering = ("-created_at",)
@admin.register(MathGameAttempt)
class MathGameAttemptAdmin(admin.ModelAdmin):
list_display = (
"id",
"user",
"kind",
"match",
"difficulty",
"status",
"score",
"hints_used",
"started_at",
)
list_filter = ("kind", "difficulty", "status")
search_fields = ("user__username", "user__nickname")
readonly_fields = ("puzzle", "solution", "submission", "started_at", "submitted_at")
ordering = ("-started_at",)
+13
View File
@@ -26,7 +26,11 @@ class MatchConsumer(AsyncJsonWebsocketConsumer):
await self.send_json({"type": "pong"})
return
if event_type == "progress":
try:
answered_count = max(0, min(int(content.get("answered_count", 0)), 100))
except (TypeError, ValueError):
await self.send_json({"type": "error", "message": "答题进度无效"})
return
await self.channel_layer.group_send(
self.group_name,
{
@@ -45,6 +49,15 @@ class MatchConsumer(AsyncJsonWebsocketConsumer):
}
)
async def match_state(self, event):
await self.send_json(
{
"type": "state",
"reason": event["reason"],
"match_id": str(self.match_id),
}
)
@database_sync_to_async
def _is_participant(self, user_id):
return RealtimeMatch.objects.filter(id=self.match_id).filter(
+250
View File
@@ -0,0 +1,250 @@
import ast
import secrets
import unicodedata
from collections import Counter
from fractions import Fraction
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from .models import MathGameAttempt
SUDOKU_PUZZLES = {
MathGameAttempt.Difficulty.EASY: [
(
"530070000600195000098000060800060003400803001700020006060000280000419005000080079",
"534678912672195348198342567859761423426853791713924856961537284287419635345286179",
),
],
MathGameAttempt.Difficulty.STANDARD: [
(
"000260701680070090190004500820100040004602900050003028009300074040050036703018000",
"435269781682571493197834562826195347374682915951743628519326874248957136763418259",
),
],
MathGameAttempt.Difficulty.HARD: [
(
"000000010400000000020000000000050407008000300001090000300400200050100000000806000",
"693784512487512936125963874932651487568247391741398625319475268856129743274836159",
),
],
}
TWENTY_FOUR_PUZZLES = {
MathGameAttempt.Difficulty.EASY: [(3, 3, 8, 8), (1, 3, 4, 6), (4, 4, 10, 10)],
MathGameAttempt.Difficulty.STANDARD: [(2, 3, 4, 9), (3, 5, 7, 13), (4, 7, 8, 8)],
MathGameAttempt.Difficulty.HARD: [(1, 5, 5, 5), (3, 3, 7, 7), (5, 5, 7, 11)],
}
TWENTY_FOUR_SYMBOLS = str.maketrans(
{
"×": "*",
"·": "*",
"": "*",
"÷": "/",
"": "-",
"": "-",
"": "-",
}
)
def _grid_from_text(value):
return [[int(value[row * 9 + column]) for column in range(9)] for row in range(9)]
def game_payload(attempt):
return {
"attempt_id": attempt.id,
"match_id": attempt.match_id,
"kind": attempt.kind,
"difficulty": attempt.difficulty,
"status": attempt.status,
"puzzle": attempt.puzzle,
"score": attempt.score,
"duration_ms": attempt.duration_ms,
"hints_used": attempt.hints_used,
"started_at": attempt.started_at,
}
def build_game(kind, difficulty=MathGameAttempt.Difficulty.STANDARD):
if kind not in MathGameAttempt.Kind.values:
raise ValidationError({"kind": "不支持的数学玩法"})
if difficulty not in MathGameAttempt.Difficulty.values:
raise ValidationError({"difficulty": "不支持的难度"})
if kind == MathGameAttempt.Kind.SUDOKU:
puzzle_text, solution_text = secrets.choice(SUDOKU_PUZZLES[difficulty])
puzzle = {"grid": _grid_from_text(puzzle_text), "hints": []}
solution = {"grid": _grid_from_text(solution_text)}
else:
numbers = list(secrets.choice(TWENTY_FOUR_PUZZLES[difficulty]))
secrets.SystemRandom().shuffle(numbers)
puzzle = {"numbers": numbers}
solution = {"target": 24}
return puzzle, solution
def start_game(user, kind, difficulty=MathGameAttempt.Difficulty.STANDARD):
puzzle, solution = build_game(kind, difficulty)
attempt = MathGameAttempt.objects.create(
user=user,
kind=kind,
difficulty=difficulty,
puzzle=puzzle,
solution=solution,
)
return game_payload(attempt)
def _validate_twenty_four_expression(source, numbers):
source = unicodedata.normalize("NFKC", str(source or "")).translate(
TWENTY_FOUR_SYMBOLS
)
source = source.strip()
if not source or len(source) > 120:
raise ValidationError({"expression": "请输入不超过 120 个字符的表达式"})
try:
tree = ast.parse(source, mode="eval")
except SyntaxError as exc:
raise ValidationError({"expression": "表达式语法无效"}) from exc
used = []
def evaluate(node):
if (
isinstance(node, ast.Constant)
and isinstance(node.value, int)
and not isinstance(node.value, bool)
):
used.append(node.value)
return Fraction(node.value)
if isinstance(node, ast.BinOp) and isinstance(
node.op,
(ast.Add, ast.Sub, ast.Mult, ast.Div),
):
left = evaluate(node.left)
right = evaluate(node.right)
if isinstance(node.op, ast.Add):
return left + right
if isinstance(node.op, ast.Sub):
return left - right
if isinstance(node.op, ast.Mult):
return left * right
if right == 0:
raise ValidationError({"expression": "不能除以零"})
return left / right
raise ValidationError({"expression": "只允许题目数字、括号和 + - * /"})
result = evaluate(tree.body)
if Counter(used) != Counter(numbers):
raise ValidationError({"expression": "必须且只能使用题目给出的四个数字各一次"})
if result != 24:
raise ValidationError({"expression": f"当前结果是 {result},还没有得到 24"})
return source
def _validate_sudoku_grid(raw_grid, puzzle, solution):
if (
not isinstance(raw_grid, list)
or len(raw_grid) != 9
or any(not isinstance(row, list) or len(row) != 9 for row in raw_grid)
):
raise ValidationError({"grid": "数独答案必须是 9×9 网格"})
try:
grid = [[int(value) for value in row] for row in raw_grid]
except (TypeError, ValueError) as exc:
raise ValidationError({"grid": "每个格子必须填写 1 到 9"}) from exc
if any(value < 1 or value > 9 for row in grid for value in row):
raise ValidationError({"grid": "每个格子必须填写 1 到 9"})
givens = puzzle["grid"]
for row in range(9):
for column in range(9):
if givens[row][column] and grid[row][column] != givens[row][column]:
raise ValidationError({"grid": "不能修改题目给出的数字"})
if grid != solution["grid"]:
raise ValidationError({"grid": "答案尚未满足全部行、列和九宫格"})
return grid
@transaction.atomic
def submit_game(user, attempt_id, submission, submission_key):
attempt = MathGameAttempt.objects.select_for_update().get(id=attempt_id, user=user)
if attempt.status != MathGameAttempt.Status.ACTIVE:
if (
attempt.status == MathGameAttempt.Status.COMPLETED
and submission_key
and attempt.submission_key == submission_key
):
return game_payload(attempt)
raise ValidationError("这局游戏已经结束")
if not submission_key:
raise ValidationError({"Idempotency-Key": "提交必须提供幂等键"})
if attempt.kind == MathGameAttempt.Kind.SUDOKU:
normalized = {
"grid": _validate_sudoku_grid(
submission.get("grid"),
attempt.puzzle,
attempt.solution,
)
}
else:
normalized = {
"expression": _validate_twenty_four_expression(
submission.get("expression"),
attempt.puzzle["numbers"],
)
}
now = timezone.now()
duration_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000))
base_score = 1800 if attempt.kind == MathGameAttempt.Kind.SUDOKU else 1000
time_penalty = duration_ms // (2000 if attempt.kind == MathGameAttempt.Kind.SUDOKU else 1000)
attempt.submission = normalized
attempt.status = MathGameAttempt.Status.COMPLETED
attempt.duration_ms = duration_ms
attempt.score = max(100, base_score - time_penalty - attempt.hints_used * 150)
attempt.submission_key = submission_key
attempt.submitted_at = now
attempt.save(
update_fields=[
"submission",
"status",
"duration_ms",
"score",
"submission_key",
"submitted_at",
]
)
return game_payload(attempt)
@transaction.atomic
def request_sudoku_hint(user, attempt_id):
attempt = MathGameAttempt.objects.select_for_update().get(id=attempt_id, user=user)
if attempt.kind != MathGameAttempt.Kind.SUDOKU:
raise ValidationError("只有数独支持提示")
if attempt.status != MathGameAttempt.Status.ACTIVE:
raise ValidationError("这局游戏已经结束")
hints = list(attempt.puzzle.get("hints", []))
if len(hints) >= 3:
raise ValidationError("每局最多使用 3 次提示")
candidates = [
(row, column)
for row in range(9)
for column in range(9)
if attempt.puzzle["grid"][row][column] == 0
and not any(item["row"] == row and item["column"] == column for item in hints)
]
row, column = secrets.choice(candidates)
hint = {
"row": row,
"column": column,
"value": attempt.solution["grid"][row][column],
}
hints.append(hint)
attempt.puzzle = {**attempt.puzzle, "hints": hints}
attempt.hints_used = len(hints)
attempt.save(update_fields=["puzzle", "hints_used"])
return {**hint, "hints_used": attempt.hints_used}
@@ -1,34 +1,149 @@
import random
from django.core.management.base import BaseCommand
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 = {
Question.Track.BEGINNER: [
("b-12-plus-19", "12 + 19", "31"),
("b-8-times-7", "8 × 7", "56"),
("b-90-minus-37", "90 - 37", "53"),
("b-144-div-12", "144 ÷ 12", "12"),
("b-25-times-4", "25 × 4", "100"),
],
Question.Track.STANDARD: [
("s-17-times-23", "17 × 23", "391"),
("s-625-div-25", "625 ÷ 25", "25"),
("s-48-times-15", "48 × 15", "720"),
("s-1000-minus-387", "1000 - 387", "613"),
("s-35-squared", "35²", "1225"),
],
Question.Track.ADVANCED: [
("a-mod-2pow10", "2¹⁰ 除以 7 的余数", "2"),
("a-sum-1-50", "1 到 50 的整数和", "1275"),
("a-15-choose-2", "C(15,2)", "105"),
("a-sqrt-2025", "√2025", "45"),
("a-3pow6", "3⁶", "729"),
],
Question.Track.BEGINNER: _generate_beginner(),
Question.Track.STANDARD: _generate_standard(),
Question.Track.ADVANCED: _generate_advanced(),
}
class Command(BaseCommand):
help = "创建首批分层题目、实时 1v1、每日赛单人练习"
help = "创建分层题库(入门/标准/进阶)和实时 1v1、每日赛单人练习"
def handle(self, *args, **options):
versions = {}
@@ -93,6 +208,8 @@ class Command(BaseCommand):
},
)
ContestQuestion.objects.filter(contest=contest).delete()
pool = list(versions[track])
random.shuffle(pool)
ContestQuestion.objects.bulk_create(
[
ContestQuestion(
@@ -101,11 +218,14 @@ class Command(BaseCommand):
order=index,
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.style.SUCCESS(
f"已创建 {sum(map(len, QUESTIONS.values()))} 道题{len(contest_specs)} 场比赛"
f"已创建 {total} 道题(入门{len(QUESTIONS[Question.Track.BEGINNER])}"
f"标准{len(QUESTIONS[Question.Track.STANDARD])}"
f"进阶{len(QUESTIONS[Question.Track.ADVANCED])})和 {len(contest_specs)} 场比赛"
)
)
@@ -0,0 +1,44 @@
# Generated by Django 4.2.23 on 2026-08-08 17:26
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contest', '0002_realtimematch_matchmaking_lookup_idx'),
]
operations = [
migrations.CreateModel(
name='MathGameAttempt',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('kind', models.CharField(choices=[('sudoku', '数独'), ('twenty_four', '24 点')], max_length=20)),
('difficulty', models.CharField(choices=[('easy', '入门'), ('standard', '标准'), ('hard', '进阶')], default='standard', max_length=16)),
('puzzle', models.JSONField(default=dict)),
('solution', models.JSONField(default=dict)),
('submission', models.JSONField(blank=True, default=dict)),
('status', models.CharField(choices=[('active', '进行中'), ('completed', '已完成'), ('failed', '未通过')], default='active', max_length=16)),
('score', models.PositiveIntegerField(default=0)),
('duration_ms', models.PositiveIntegerField(default=0)),
('hints_used', models.PositiveSmallIntegerField(default=0)),
('submission_key', models.CharField(blank=True, max_length=80, null=True)),
('started_at', models.DateTimeField(auto_now_add=True)),
('submitted_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='math_game_attempts', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-started_at'],
'indexes': [models.Index(fields=['kind', 'status', '-score', 'duration_ms'], name='math_game_ranking_idx')],
},
),
migrations.AddConstraint(
model_name='mathgameattempt',
constraint=models.UniqueConstraint(fields=('user', 'submission_key'), name='unique_user_math_game_submission'),
),
]
@@ -0,0 +1,36 @@
# Generated by Django 4.2.23 on 2026-08-08 18:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contest', '0003_mathgameattempt_and_more'),
]
operations = [
migrations.RemoveIndex(
model_name='realtimematch',
name='matchmaking_lookup_idx',
),
migrations.AddField(
model_name='realtimematch',
name='challenge_code',
field=models.CharField(blank=True, max_length=8, null=True, unique=True),
),
migrations.AddField(
model_name='realtimematch',
name='expires_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='realtimematch',
name='match_type',
field=models.CharField(choices=[('random', '随机匹配'), ('challenge', '联机码约战')], default='random', max_length=16),
),
migrations.AddIndex(
model_name='realtimematch',
index=models.Index(fields=['contest', 'match_type', 'status', 'player_one_rating', 'created_at'], name='matchmaking_lookup_idx'),
),
]
@@ -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,
),
]
@@ -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,
),
]
+96 -2
View File
@@ -77,14 +77,34 @@ class ContestQuestion(models.Model):
class RealtimeMatch(models.Model):
class MatchType(models.TextChoices):
RANDOM = "random", "随机匹配"
CHALLENGE = "challenge", "联机码约战"
class Status(models.TextChoices):
WAITING = "waiting", "等待对手"
ACTIVE = "active", "进行中"
COMPLETED = "completed", "已完成"
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)
contest = models.ForeignKey(Contest, on_delete=models.PROTECT)
match_type = models.CharField(
max_length=16,
choices=MatchType.choices,
default=MatchType.RANDOM,
)
challenge_code = models.CharField(max_length=8, unique=True, null=True, blank=True)
game_kind = models.CharField(
max_length=20,
choices=GameKind.choices,
default=GameKind.QUIZ,
)
player_one = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="matches_as_player_one"
)
@@ -106,14 +126,22 @@ class RealtimeMatch(models.Model):
related_name="won_matches",
)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
models.Index(
fields=("contest", "status", "player_one_rating", "created_at"),
name="matchmaking_lookup_idx",
fields=(
"contest",
"match_type",
"game_kind",
"status",
"player_one_rating",
"created_at",
),
name="matchmaking_mode_idx",
)
]
@@ -135,6 +163,7 @@ class ContestAttempt(models.Model):
correct_count = models.PositiveIntegerField(default=0)
answer_count = 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)
started_at = models.DateTimeField(auto_now_add=True)
submitted_at = models.DateTimeField(null=True, blank=True)
@@ -192,3 +221,68 @@ class CheatFlag(models.Model):
evidence = models.JSONField(default=dict)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN)
created_at = models.DateTimeField(auto_now_add=True)
class MathGameAttempt(models.Model):
class Kind(models.TextChoices):
SUDOKU = "sudoku", "数独"
TWENTY_FOUR = "twenty_four", "24 点"
class Difficulty(models.TextChoices):
EASY = "easy", "入门"
STANDARD = "standard", "标准"
HARD = "hard", "进阶"
class Status(models.TextChoices):
ACTIVE = "active", "进行中"
COMPLETED = "completed", "已完成"
FAILED = "failed", "未通过"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
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)
difficulty = models.CharField(
max_length=16,
choices=Difficulty.choices,
default=Difficulty.STANDARD,
)
puzzle = models.JSONField(default=dict)
solution = models.JSONField(default=dict)
submission = models.JSONField(default=dict, blank=True)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE)
score = models.PositiveIntegerField(default=0)
duration_ms = models.PositiveIntegerField(default=0)
hints_used = models.PositiveSmallIntegerField(default=0)
submission_key = models.CharField(max_length=80, null=True, blank=True)
started_at = models.DateTimeField(auto_now_add=True)
submitted_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=("user", "submission_key"),
name="unique_user_math_game_submission",
),
models.UniqueConstraint(
fields=("match", "user"),
name="unique_user_realtime_math_game",
),
]
indexes = [
models.Index(
fields=("kind", "status", "-score", "duration_ms"),
name="math_game_ranking_idx",
)
]
ordering = ["-started_at"]
+510 -55
View File
@@ -1,5 +1,9 @@
import secrets
from datetime import timedelta
from decimal import Decimal
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
@@ -12,10 +16,164 @@ from .models import (
Contest,
ContestAnswer,
ContestAttempt,
MathGameAttempt,
RatingHistory,
RealtimeMatch,
)
CHALLENGE_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
WAITING_MATCH_TTL = timedelta(minutes=10)
ATTEMPT_QUESTION_COUNT = 8
def _broadcast_match(match_id, reason):
channel_layer = get_channel_layer()
if channel_layer is None:
return
async_to_sync(channel_layer.group_send)(
f"match_{match_id}",
{
"type": "match.state",
"reason": reason,
},
)
def notify_match_on_commit(match_id, reason):
transaction.on_commit(lambda: _broadcast_match(match_id, reason))
def _new_challenge_code():
for _ in range(20):
code = "".join(secrets.choice(CHALLENGE_CODE_ALPHABET) for _ in range(6))
if not RealtimeMatch.objects.filter(challenge_code=code).exists():
return code
raise ValidationError("暂时无法生成联机码,请稍后重试")
def _validate_realtime_contest(contest):
if contest.kind != Contest.Kind.REALTIME or contest.status != Contest.Status.PUBLISHED:
raise ValidationError("实时比赛不可用")
def _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():
RealtimeMatch.objects.filter(status=RealtimeMatch.Status.WAITING).filter(
Q(expires_at__isnull=True) | Q(expires_at__lte=timezone.now())
).update(status=RealtimeMatch.Status.CANCELLED)
def _active_match_for(user):
return (
RealtimeMatch.objects.filter(
Q(player_one=user) | Q(player_two=user),
status=RealtimeMatch.Status.ACTIVE,
)
.select_related("contest", "player_one", "player_two")
.first()
)
def _cancel_other_waiting_matches(user, match_type, game_kind):
matches = list(
RealtimeMatch.objects.filter(
player_one=user,
status=RealtimeMatch.Status.WAITING,
)
.exclude(match_type=match_type, game_kind=game_kind)
.values_list("id", flat=True)
)
if matches:
RealtimeMatch.objects.filter(id__in=matches).update(
status=RealtimeMatch.Status.CANCELLED
)
for match_id in matches:
notify_match_on_commit(match_id, "cancelled")
def _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):
from .game_services import build_game
now = timezone.now()
match.player_two = user
match.player_two_rating = user.rating
match.status = RealtimeMatch.Status.ACTIVE
match.started_at = now
match.save(
update_fields=["player_two", "player_two_rating", "status", "started_at"]
)
if match.game_kind == RealtimeMatch.GameKind.QUIZ:
question_order = _select_question_order(match.contest)
ContestAttempt.objects.bulk_create(
[
ContestAttempt(
contest=match.contest,
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")
return match
def normalize_answer(value):
text = str(value).strip().lower().replace(" ", "")
@@ -28,19 +186,19 @@ def normalize_answer(value):
def attempt_payload(attempt, include_results=False):
questions = []
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 = {
"order": item.order,
"order": display_order,
"prompt": item.question_version.prompt,
"metadata": item.question_version.metadata,
"points": item.points,
}
if include_results and item.id in answers:
answer = answers[item.id]
if include_results:
answer = answers.get(item.id)
question.update(
{
"submitted_answer": answer.submitted_answer,
"is_correct": answer.is_correct,
"submitted_answer": answer.submitted_answer if answer else "",
"is_correct": answer.is_correct if answer else False,
"correct_answer": item.question_version.answer,
"explanation": item.question_version.explanation,
}
@@ -48,6 +206,7 @@ def attempt_payload(attempt, include_results=False):
questions.append(question)
return {
"attempt_id": attempt.id,
"match_id": attempt.match_id,
"contest": attempt.contest.title,
"kind": attempt.contest.kind,
"status": attempt.status,
@@ -77,7 +236,11 @@ def start_attempt(user, contest):
existing,
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)
@@ -90,7 +253,10 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key):
)
if attempt.status != ContestAttempt.Status.ACTIVE:
if submission_key and attempt.submission_key == submission_key:
return attempt_payload(attempt, include_results=True)
include_results = not attempt.match_id or (
attempt.match.status == RealtimeMatch.Status.COMPLETED
)
return attempt_payload(attempt, include_results=include_results)
raise ValidationError("该答题记录已经结算")
if not submission_key:
raise ValidationError({"Idempotency-Key": "正式提交必须提供幂等键"})
@@ -98,9 +264,7 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key):
now = timezone.now()
duration_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000))
limit_ms = attempt.contest.duration_seconds * 1000
items = list(
attempt.contest.contest_questions.select_related("question_version").all()
)
items = _attempt_questions(attempt)
if not isinstance(raw_answers, list):
raise ValidationError({"answers": "答案必须是数组"})
by_order = {}
@@ -114,8 +278,8 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key):
raise ValidationError({"answers": "答案题号无效或重复"}) from exc
score = 0
correct_count = 0
for contest_question in items:
submitted = str(by_order.get(contest_question.order, ""))[:200]
for display_order, contest_question in enumerate(items, start=1):
submitted = str(by_order.get(display_order, ""))[:200]
correct = normalize_answer(submitted) == normalize_answer(
contest_question.question_version.answer
)
@@ -153,20 +317,40 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key):
},
)
if attempt.match_id:
finalize_match(attempt.match_id)
match = finalize_match(attempt.match_id)
if match.status != RealtimeMatch.Status.COMPLETED:
notify_match_on_commit(attempt.match_id, "submitted")
attempt.match.refresh_from_db()
return attempt_payload(
attempt,
include_results=attempt.match.status == RealtimeMatch.Status.COMPLETED,
)
return attempt_payload(attempt, include_results=True)
@transaction.atomic
def find_match(user, contest):
if contest.kind != Contest.Kind.REALTIME or contest.status != Contest.Status.PUBLISHED:
raise ValidationError("实时比赛不可用")
def find_match(user, contest, game_kind=RealtimeMatch.GameKind.QUIZ):
_validate_realtime_contest(contest)
game_kind = _validate_game_kind(game_kind)
_cancel_expired_waiting_matches()
active = _active_match_for(user)
if active:
if active.contest_id == contest.id and active.game_kind == game_kind:
return active
raise ValidationError("你已有一场进行中的实时比赛")
_cancel_other_waiting_matches(
user,
RealtimeMatch.MatchType.RANDOM,
game_kind,
)
existing = (
RealtimeMatch.objects.filter(
Q(player_one=user) | Q(player_two=user),
contest=contest,
match_type=RealtimeMatch.MatchType.RANDOM,
game_kind=game_kind,
status__in=[RealtimeMatch.Status.WAITING, RealtimeMatch.Status.ACTIVE],
)
).filter(Q(status=RealtimeMatch.Status.ACTIVE) | Q(expires_at__gt=timezone.now()))
.order_by("-created_at")
.first()
)
@@ -177,7 +361,10 @@ def find_match(user, contest):
RealtimeMatch.objects.select_for_update(skip_locked=True)
.filter(
contest=contest,
match_type=RealtimeMatch.MatchType.RANDOM,
game_kind=game_kind,
status=RealtimeMatch.Status.WAITING,
expires_at__gt=timezone.now(),
player_one_rating__gte=max(0, user.rating - 300),
player_one_rating__lte=user.rating + 300,
)
@@ -188,69 +375,258 @@ def find_match(user, contest):
if waiting is None:
return RealtimeMatch.objects.create(
contest=contest,
match_type=RealtimeMatch.MatchType.RANDOM,
game_kind=game_kind,
player_one=user,
player_one_rating=user.rating,
expires_at=timezone.now() + WAITING_MATCH_TTL,
)
waiting.player_two = user
waiting.player_two_rating = user.rating
waiting.status = RealtimeMatch.Status.ACTIVE
waiting.started_at = timezone.now()
waiting.save(
update_fields=["player_two", "player_two_rating", "status", "started_at"]
return _activate_match(waiting, user)
@transaction.atomic
def create_challenge(user, contest, game_kind=RealtimeMatch.GameKind.QUIZ):
_validate_realtime_contest(contest)
game_kind = _validate_game_kind(game_kind)
_cancel_expired_waiting_matches()
active = _active_match_for(user)
if active:
raise ValidationError("你已有一场进行中的实时比赛")
_cancel_other_waiting_matches(
user,
RealtimeMatch.MatchType.CHALLENGE,
game_kind,
)
ContestAttempt.objects.bulk_create(
[
ContestAttempt(contest=contest, user=waiting.player_one, match=waiting),
ContestAttempt(contest=contest, user=user, match=waiting),
]
existing = (
RealtimeMatch.objects.filter(
player_one=user,
contest=contest,
match_type=RealtimeMatch.MatchType.CHALLENGE,
game_kind=game_kind,
status=RealtimeMatch.Status.WAITING,
expires_at__gt=timezone.now(),
)
return waiting
.order_by("-created_at")
.first()
)
if existing:
return existing
return RealtimeMatch.objects.create(
contest=contest,
match_type=RealtimeMatch.MatchType.CHALLENGE,
game_kind=game_kind,
challenge_code=_new_challenge_code(),
player_one=user,
player_one_rating=user.rating,
expires_at=timezone.now() + WAITING_MATCH_TTL,
)
@transaction.atomic
def join_challenge(user, challenge_code):
_cancel_expired_waiting_matches()
code = str(challenge_code or "").strip().upper()
if len(code) != 6 or any(character not in CHALLENGE_CODE_ALPHABET for character in code):
raise ValidationError({"challenge_code": "联机码应为 6 位大写字母或数字"})
try:
match = (
RealtimeMatch.objects.select_for_update()
.select_related("contest", "player_one")
.get(
challenge_code=code,
match_type=RealtimeMatch.MatchType.CHALLENGE,
)
)
except RealtimeMatch.DoesNotExist as exc:
raise ValidationError({"challenge_code": "联机码不存在"}) from exc
if match.player_one_id == user.id:
raise ValidationError({"challenge_code": "不能加入自己创建的约战"})
if match.status != RealtimeMatch.Status.WAITING or (
match.expires_at and match.expires_at <= timezone.now()
):
raise ValidationError({"challenge_code": "联机码已失效或已被使用"})
active = _active_match_for(user)
if active and active.id != match.id:
raise ValidationError("你已有一场进行中的实时比赛")
own_waiting_ids = list(
RealtimeMatch.objects.filter(
player_one=user,
status=RealtimeMatch.Status.WAITING,
)
.exclude(id=match.id)
.values_list("id", flat=True)
)
if own_waiting_ids:
RealtimeMatch.objects.filter(id__in=own_waiting_ids).update(
status=RealtimeMatch.Status.CANCELLED
)
for match_id in own_waiting_ids:
notify_match_on_commit(match_id, "cancelled")
return _activate_match(match, user)
@transaction.atomic
def cancel_waiting_match(user, match_id):
match = RealtimeMatch.objects.select_for_update().get(id=match_id)
if match.player_one_id != user.id:
raise ValidationError("只有创建者可以取消等待")
if match.status != RealtimeMatch.Status.WAITING:
raise ValidationError("只能取消等待中的比赛")
match.status = RealtimeMatch.Status.CANCELLED
match.save(update_fields=["status"])
notify_match_on_commit(match.id, "cancelled")
return match
def match_payload(match, user):
attempt = match.attempts.filter(user=user).first()
from .game_services import game_payload
reveal_results = match.status == RealtimeMatch.Status.COMPLETED
if match.game_kind == RealtimeMatch.GameKind.QUIZ:
attempts = {
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)
opponent = match.player_two if match.player_one_id == user.id else match.player_one
opponent_attempt = attempts.get(opponent.id) if opponent else None
rating_change = (
match.rating_changes.filter(user=user).values("delta", "rating_after").first()
if reveal_results
else None
)
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 {
"match_id": match.id,
"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,
"challenge_code": (
match.challenge_code
if match.match_type == RealtimeMatch.MatchType.CHALLENGE
and match.status == RealtimeMatch.Status.WAITING
else None
),
"status": match.status,
"contest": (
match.contest.title
if match.game_kind == RealtimeMatch.GameKind.QUIZ
else match.get_game_kind_display()
),
"duration_seconds": match.contest.duration_seconds,
"expires_at": match.expires_at,
"started_at": match.started_at,
"opponent": (
{"nickname": opponent.nickname, "rating": opponent.rating}
{
"nickname": opponent.nickname,
"rating": opponent.rating,
"status": opponent_attempt.status if opponent_attempt else None,
"score": opponent_attempt.score if reveal_results and opponent_attempt else None,
"correct_count": (
opponent_attempt.correct_count
if reveal_results
and opponent_attempt
and match.game_kind == RealtimeMatch.GameKind.QUIZ
else None
),
"duration_ms": (
opponent_attempt.duration_ms
if reveal_results and opponent_attempt
else None
),
}
if opponent
else None
),
"attempt": attempt_payload(attempt) if attempt else None,
"attempt": attempt_data,
"result": (
{
"winner": (
"draw"
if match.winner_id is None
else "self"
if match.winner_id == user.id
else "opponent"
),
"rating_delta": rating_change["delta"] if rating_change else 0,
"rating_after": rating_change["rating_after"] if rating_change else user.rating,
}
if reveal_results
else None
),
"websocket_path": f"/ws/v1/contest/matches/{match.id}/",
}
@transaction.atomic
def refresh_match_state(match_id):
match = (
RealtimeMatch.objects.select_for_update()
.select_related("contest", "player_one", "player_two")
.get(id=match_id)
)
if (
match.status == RealtimeMatch.Status.WAITING
and match.expires_at
and match.expires_at <= timezone.now()
):
match.status = RealtimeMatch.Status.CANCELLED
match.save(update_fields=["status"])
notify_match_on_commit(match.id, "expired")
elif match.status == RealtimeMatch.Status.ACTIVE and match.started_at:
deadline = match.started_at + timedelta(seconds=match.contest.duration_seconds)
if timezone.now() >= deadline:
now = timezone.now()
if match.game_kind == RealtimeMatch.GameKind.QUIZ:
for attempt in match.attempts.filter(
status=ContestAttempt.Status.ACTIVE
):
elapsed_ms = max(
0,
int((now - attempt.started_at).total_seconds() * 1000),
)
attempt.status = ContestAttempt.Status.EXPIRED
attempt.duration_ms = elapsed_ms
attempt.submitted_at = now
attempt.save(
update_fields=["status", "duration_ms", "submitted_at"]
)
match = finalize_match(match.id)
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
def _elo_delta(rating, opponent_rating, score, k=32):
expected = 1 / (1 + 10 ** ((opponent_rating - rating) / 400))
return round(k * (score - expected))
@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
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
def _settle_match(match, first_result, second_result, winner_id):
users = {
user.id: user
for user in User.objects.select_for_update().filter(
@@ -274,7 +650,86 @@ def finalize_match(match_id):
rating_after=user.rating,
delta=delta,
)
match.winner_id = winner_id
match.status = RealtimeMatch.Status.COMPLETED
match.completed_at = timezone.now()
match.save(update_fields=["winner", "status", "completed_at"])
notify_match_on_commit(match.id, "completed")
return match
@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,
)
+87
View File
@@ -0,0 +1,87 @@
import pytest
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.urls import path
from accounts.models import User
from contest.consumers import MatchConsumer
from contest.models import Contest, Question, RealtimeMatch
@pytest.mark.django_db(transaction=True)
def test_match_consumer_双方连接并同步答题进度():
first = User.objects.create_user(
username="socket_player_one",
password="StrongPass_2026",
nickname="WS 玩家一",
)
second = User.objects.create_user(
username="socket_player_two",
password="StrongPass_2026",
nickname="WS 玩家二",
)
outsider = User.objects.create_user(
username="socket_outsider",
password="StrongPass_2026",
nickname="WS 局外人",
)
contest = Contest.objects.create(
slug="socket-contest",
title="WebSocket 联机赛",
kind=Contest.Kind.REALTIME,
track=Question.Track.STANDARD,
status=Contest.Status.PUBLISHED,
)
match = RealtimeMatch.objects.create(
contest=contest,
player_one=first,
player_two=second,
player_one_rating=first.rating,
player_two_rating=second.rating,
status=RealtimeMatch.Status.ACTIVE,
)
application = URLRouter(
[
path(
"ws/test/<uuid:match_id>/",
MatchConsumer.as_asgi(),
)
]
)
async def scenario():
outsider_socket = WebsocketCommunicator(application, f"/ws/test/{match.id}/")
outsider_socket.scope["user"] = outsider
outsider_connected, close_code = await outsider_socket.connect()
assert not outsider_connected
assert close_code == 4403
first_socket = WebsocketCommunicator(application, f"/ws/test/{match.id}/")
second_socket = WebsocketCommunicator(application, f"/ws/test/{match.id}/")
first_socket.scope["user"] = first
second_socket.scope["user"] = second
first_connected, _ = await first_socket.connect()
second_connected, _ = await second_socket.connect()
assert first_connected and second_connected
assert (await first_socket.receive_json_from())["type"] == "connected"
assert (await second_socket.receive_json_from())["type"] == "connected"
await first_socket.send_json_to({"type": "progress", "answered_count": 3})
first_progress = await first_socket.receive_json_from()
second_progress = await second_socket.receive_json_from()
assert first_progress["answered_count"] == 3
assert second_progress["answered_count"] == 3
assert second_progress["user_id"] == str(first.id)
await get_channel_layer().group_send(
f"match_{match.id}",
{"type": "match.state", "reason": "completed"},
)
assert (await first_socket.receive_json_from())["reason"] == "completed"
assert (await second_socket.receive_json_from())["reason"] == "completed"
await first_socket.disconnect()
await second_socket.disconnect()
async_to_sync(scenario)()
+178
View File
@@ -0,0 +1,178 @@
import pytest
from rest_framework.exceptions import ValidationError
from accounts.models import User
from contest.game_services import (
SUDOKU_PUZZLES,
request_sudoku_hint,
start_game,
submit_game,
)
from contest.models import MathGameAttempt
@pytest.fixture
def game_user(db):
return User.objects.create_user(
username="game_user",
password="StrongPass_2026",
nickname="游戏玩家",
)
@pytest.mark.django_db
def test_twenty_four_服务端校验数字使用与幂等提交(game_user):
attempt = MathGameAttempt.objects.create(
user=game_user,
kind=MathGameAttempt.Kind.TWENTY_FOUR,
puzzle={"numbers": [1, 3, 4, 6]},
solution={"target": 24},
)
result = submit_game(
game_user,
attempt.id,
{"expression": "6 / (1 - 3 / 4)"},
"game-submit-1",
)
replay = submit_game(
game_user,
attempt.id,
{"expression": "1 + 3 + 4 + 6"},
"game-submit-1",
)
assert result["status"] == MathGameAttempt.Status.COMPLETED
assert result["score"] >= 100
assert replay["score"] == result["score"]
@pytest.mark.django_db
def test_twenty_four_api_兼容常见数学符号并完成计分(client, game_user):
attempt = MathGameAttempt.objects.create(
user=game_user,
kind=MathGameAttempt.Kind.TWENTY_FOUR,
puzzle={"numbers": [1, 3, 4, 6]},
solution={"target": 24},
)
client.force_login(game_user)
response = client.post(
f"/api/v1/contests/games/attempts/{attempt.id}/submit/",
{"expression": "6÷(1−3÷4)"},
content_type="application/json",
HTTP_IDEMPOTENCY_KEY="unicode-game-submit",
)
assert response.status_code == 200
assert response.json()["status"] == MathGameAttempt.Status.COMPLETED
assert response.json()["score"] >= 100
attempt.refresh_from_db()
assert attempt.submission == {"expression": "6/(1-3/4)"}
@pytest.mark.django_db
def test_twenty_four_api_答案错误时返回具体原因和请求编号(client, game_user):
attempt = MathGameAttempt.objects.create(
user=game_user,
kind=MathGameAttempt.Kind.TWENTY_FOUR,
puzzle={"numbers": [1, 3, 4, 6]},
solution={"target": 24},
)
client.force_login(game_user)
response = client.post(
f"/api/v1/contests/games/attempts/{attempt.id}/submit/",
{"expression": "1 + 3 + 4 + 6"},
content_type="application/json",
HTTP_IDEMPOTENCY_KEY="incorrect-game-submit",
HTTP_X_REQUEST_ID="twenty-four-invalid-test",
)
assert response.status_code == 400
assert response.json()["error"]["message"] == "当前结果是 14,还没有得到 24"
assert response.json()["error"]["request_id"] == "twenty-four-invalid-test"
attempt.refresh_from_db()
assert attempt.status == MathGameAttempt.Status.ACTIVE
@pytest.mark.django_db
def test_twenty_four_拒绝额外数字和非四则表达式(game_user):
attempt = MathGameAttempt.objects.create(
user=game_user,
kind=MathGameAttempt.Kind.TWENTY_FOUR,
puzzle={"numbers": [1, 3, 4, 6]},
solution={"target": 24},
)
with pytest.raises(ValidationError, match="四个数字"):
submit_game(
game_user,
attempt.id,
{"expression": "6 / (1 - 3 / 4) + 24 - 24"},
"invalid-extra-number",
)
with pytest.raises(ValidationError, match="只允许"):
submit_game(
game_user,
attempt.id,
{"expression": "pow(2, 3) * 3"},
"invalid-function",
)
with pytest.raises(ValidationError, match="只允许"):
submit_game(
game_user,
attempt.id,
{"expression": "6 / (True - 3 / 4)"},
"invalid-boolean",
)
@pytest.mark.django_db
def test_sudoku_提示受限并由服务端校验完整答案(game_user):
payload = start_game(
game_user,
MathGameAttempt.Kind.SUDOKU,
MathGameAttempt.Difficulty.EASY,
)
attempt = MathGameAttempt.objects.get(id=payload["attempt_id"])
hint = request_sudoku_hint(game_user, attempt.id)
solution_text = SUDOKU_PUZZLES[MathGameAttempt.Difficulty.EASY][0][1]
solution = [
[int(solution_text[row * 9 + column]) for column in range(9)]
for row in range(9)
]
result = submit_game(
game_user,
attempt.id,
{"grid": solution},
"sudoku-submit-1",
)
assert hint["value"] == solution[hint["row"]][hint["column"]]
assert result["status"] == MathGameAttempt.Status.COMPLETED
assert result["hints_used"] == 1
@pytest.mark.django_db
def test_math_game_api_目录公开但开局需要登录(client, game_user):
catalog = client.get("/api/v1/contests/games/")
anonymous_start = client.post(
"/api/v1/contests/games/twenty_four/start/",
{"difficulty": "easy"},
content_type="application/json",
)
client.force_login(game_user)
authenticated_start = client.post(
"/api/v1/contests/games/twenty_four/start/",
{"difficulty": "easy"},
content_type="application/json",
)
assert catalog.status_code == 200
assert {item["kind"] for item in catalog.json()} == {"sudoku", "twenty_four"}
assert anonymous_start.status_code in {401, 403}
assert authenticated_start.status_code == 201
assert authenticated_start.json()["difficulty"] == "standard"
assert "solution" not in authenticated_start.json()
+256
View File
@@ -0,0 +1,256 @@
from datetime import timedelta
import pytest
from django.test import Client
from django.utils import timezone
from accounts.models import User
from contest.models import (
Contest,
ContestQuestion,
Question,
QuestionVersion,
RealtimeMatch,
)
@pytest.fixture
def realtime_api_setup(db):
question = Question.objects.create(
slug="realtime-api-question",
track=Question.Track.STANDARD,
)
version = QuestionVersion.objects.create(
question=question,
version=1,
prompt="18 + 24",
answer="42",
explanation="18 + 24 = 42",
)
contest = Contest.objects.create(
slug="realtime-api",
title="API 联机赛",
kind=Contest.Kind.REALTIME,
track=Question.Track.STANDARD,
status=Contest.Status.PUBLISHED,
duration_seconds=60,
)
ContestQuestion.objects.create(
contest=contest,
question_version=version,
order=1,
points=100,
)
first = User.objects.create_user(
username="api_player_one",
password="StrongPass_2026",
nickname="API 玩家一",
)
second = User.objects.create_user(
username="api_player_two",
password="StrongPass_2026",
nickname="API 玩家二",
)
first_client = Client()
second_client = Client()
first_client.force_login(first)
second_client.force_login(second)
return contest, first_client, second_client
@pytest.mark.django_db
def test_challenge_api_创建加入状态提交形成完整闭环(realtime_api_setup):
contest, first_client, second_client = realtime_api_setup
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
content_type="application/json",
)
code = created.json()["challenge_code"]
joined = second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": code.lower()},
content_type="application/json",
)
match_id = joined.json()["match_id"]
first_state = first_client.get(f"/api/v1/contests/matches/{match_id}/")
assert created.status_code == 201
assert joined.status_code == 200
assert joined.json()["status"] == "active"
assert first_state.json()["opponent"]["nickname"] == "API 玩家二"
first_attempt = first_state.json()["attempt"]["attempt_id"]
second_attempt = joined.json()["attempt"]["attempt_id"]
first_submit = first_client.post(
f"/api/v1/contests/attempts/{first_attempt}/submit/",
{"answers": [{"order": 1, "answer": "42"}]},
content_type="application/json",
HTTP_IDEMPOTENCY_KEY="api-submit-one",
)
second_submit = second_client.post(
f"/api/v1/contests/attempts/{second_attempt}/submit/",
{"answers": [{"order": 1, "answer": "0"}]},
content_type="application/json",
HTTP_IDEMPOTENCY_KEY="api-submit-two",
)
assert first_submit.json()["status"] == "active"
assert "correct_answer" not in first_submit.json()["attempt"]["questions"][0]
assert second_submit.json()["status"] == "completed"
assert second_submit.json()["result"]["winner"] == "opponent"
final_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final_state["result"]["winner"] == "self"
assert final_state["attempt"]["questions"][0]["correct_answer"] == "42"
@pytest.mark.django_db
def test_challenge_api_无效联机码返回具体原因_request_id_和诊断日志(
realtime_api_setup,
caplog,
):
_, _, second_client = realtime_api_setup
caplog.set_level("INFO", logger="common.api")
response = second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": "ABC234"},
content_type="application/json",
HTTP_X_REQUEST_ID="challenge-invalid-test",
)
assert response.status_code == 400
assert response["X-Request-ID"] == "challenge-invalid-test"
assert response.json()["error"] == {
"code": "invalid",
"message": "联机码不存在",
"details": {"challenge_code": "联机码不存在"},
"request_id": "challenge-invalid-test",
}
assert "api_request_error method=POST" in caplog.text
assert "path=/api/v1/contests/challenges/join/" in caplog.text
assert "fields=challenge_code message=联机码不存在" in caplog.text
@pytest.mark.django_db
def test_challenge_api_创建者不能加入自己的联机码(realtime_api_setup):
contest, first_client, _ = realtime_api_setup
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
content_type="application/json",
)
response = first_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": created.json()["challenge_code"]},
content_type="application/json",
)
assert response.status_code == 400
assert response.json()["error"]["message"] == "不能加入自己创建的约战"
@pytest.mark.django_db
def test_challenge_api_过期联机码返回具体原因(realtime_api_setup):
contest, first_client, second_client = realtime_api_setup
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
content_type="application/json",
)
RealtimeMatch.objects.filter(id=created.json()["match_id"]).update(
expires_at=timezone.now() - timedelta(seconds=1)
)
response = second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": created.json()["challenge_code"]},
content_type="application/json",
)
assert response.status_code == 400
assert response.json()["error"]["message"] == "联机码已失效或已被使用"
@pytest.mark.django_db
def test_challenge_api_已使用联机码返回具体原因(realtime_api_setup):
contest, first_client, second_client = realtime_api_setup
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
content_type="application/json",
)
second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": created.json()["challenge_code"]},
content_type="application/json",
)
third = User.objects.create_user(
username="api_player_three",
password="StrongPass_2026",
nickname="API 玩家三",
)
third_client = Client()
third_client.force_login(third)
response = third_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": created.json()["challenge_code"]},
content_type="application/json",
)
assert response.status_code == 400
assert response.json()["error"]["message"] == "联机码已失效或已被使用"
@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"}
+8
View File
@@ -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
+278
View File
@@ -5,19 +5,27 @@ from django.utils import timezone
from rest_framework.exceptions import ValidationError
from accounts.models import User
from contest.game_services import submit_game
from contest.models import (
Contest,
ContestAttempt,
ContestQuestion,
MathGameAttempt,
Question,
QuestionVersion,
RatingHistory,
RealtimeMatch,
)
from contest.services import (
cancel_waiting_match,
create_challenge,
finalize_game_match,
finalize_match,
find_match,
join_challenge,
match_payload,
normalize_answer,
refresh_match_state,
start_attempt,
submit_attempt,
)
@@ -62,6 +70,16 @@ def daily_contest(db):
return contest
@pytest.fixture
def realtime_contest(daily_contest):
daily_contest.kind = Contest.Kind.REALTIME
daily_contest.slug = "realtime-with-question"
daily_contest.title = "联机测试赛"
daily_contest.duration_seconds = 60
daily_contest.save(update_fields=["kind", "slug", "title", "duration_seconds"])
return daily_contest
@pytest.mark.parametrize(
"raw, expected",
[
@@ -175,6 +193,21 @@ def test_find_match_同分玩家配对并由_finalize_match_唯一结算_rating(
track=Question.Track.STANDARD,
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)
active = find_match(second, contest)
@@ -201,3 +234,248 @@ def test_find_match_同分玩家配对并由_finalize_match_唯一结算_rating(
assert first.rating == 1016
assert second.rating == 984
assert RatingHistory.objects.filter(match=active).count() == 2
@pytest.mark.django_db
def test_challenge_code_创建者和加入者通过联机码进入同一场(realtime_contest):
first = User.objects.create_user(
username="challenge_owner",
password="StrongPass_2026",
nickname="房主",
)
second = User.objects.create_user(
username="challenge_guest",
password="StrongPass_2026",
nickname="访客",
)
waiting = create_challenge(first, realtime_contest)
active = join_challenge(second, waiting.challenge_code.lower())
owner_payload = match_payload(active, first)
guest_payload = match_payload(active, second)
assert len(waiting.challenge_code) == 6
assert active.id == waiting.id
assert active.match_type == RealtimeMatch.MatchType.CHALLENGE
assert active.status == RealtimeMatch.Status.ACTIVE
assert active.attempts.count() == 2
assert owner_payload["opponent"]["nickname"] == "访客"
assert guest_payload["opponent"]["nickname"] == "房主"
assert owner_payload["attempt"]["questions"] == guest_payload["attempt"]["questions"]
@pytest.mark.django_db
def test_random_match_不会加入联机码约战(realtime_contest):
owner = User.objects.create_user(
username="private_owner",
password="StrongPass_2026",
nickname="约战房主",
)
random_player = User.objects.create_user(
username="random_player",
password="StrongPass_2026",
nickname="随机玩家",
)
challenge = create_challenge(owner, realtime_contest)
random_match = find_match(random_player, realtime_contest)
assert challenge.status == RealtimeMatch.Status.WAITING
assert random_match.id != challenge.id
assert random_match.match_type == RealtimeMatch.MatchType.RANDOM
@pytest.mark.django_db
def test_realtime_submit_双方结束前不泄露答案且结束后结算(realtime_contest):
first = User.objects.create_user(
username="fair_player_one",
password="StrongPass_2026",
nickname="公平玩家一",
)
second = User.objects.create_user(
username="fair_player_two",
password="StrongPass_2026",
nickname="公平玩家二",
)
match = join_challenge(
second,
create_challenge(first, realtime_contest).challenge_code,
)
first_attempt = match.attempts.get(user=first)
second_attempt = match.attempts.get(user=second)
first_result = submit_attempt(
first,
first_attempt.id,
[{"order": 1, "answer": "42"}],
"fair-submit-one",
)
active_payload = match_payload(match, first)
assert "correct_answer" not in first_result["questions"][0]
assert active_payload["status"] == RealtimeMatch.Status.ACTIVE
assert active_payload["attempt"]["status"] == ContestAttempt.Status.SUBMITTED
submit_attempt(
second,
second_attempt.id,
[{"order": 1, "answer": "0"}],
"fair-submit-two",
)
match.refresh_from_db()
completed_payload = match_payload(match, first)
assert match.status == RealtimeMatch.Status.COMPLETED
assert completed_payload["result"]["winner"] == "self"
assert completed_payload["attempt"]["questions"][0]["correct_answer"] == "42"
assert completed_payload["opponent"]["score"] == 0
@pytest.mark.django_db
def test_realtime_timeout_未提交玩家自动过期并完成比赛(realtime_contest):
first = User.objects.create_user(
username="timeout_one",
password="StrongPass_2026",
nickname="超时玩家一",
)
second = User.objects.create_user(
username="timeout_two",
password="StrongPass_2026",
nickname="超时玩家二",
)
match = join_challenge(
second,
create_challenge(first, realtime_contest).challenge_code,
)
RealtimeMatch.objects.filter(id=match.id).update(
started_at=timezone.now() - timedelta(seconds=61)
)
refreshed = refresh_match_state(match.id)
assert refreshed.status == RealtimeMatch.Status.COMPLETED
assert not refreshed.attempts.filter(status=ContestAttempt.Status.ACTIVE).exists()
@pytest.mark.django_db
def test_challenge_owner_可取消等待中的联机码(realtime_contest):
owner = User.objects.create_user(
username="cancel_owner",
password="StrongPass_2026",
nickname="取消房主",
)
waiting = create_challenge(owner, realtime_contest)
cancelled = cancel_waiting_match(owner, waiting.id)
assert cancelled.status == RealtimeMatch.Status.CANCELLED
with pytest.raises(ValidationError, match="失效"):
join_challenge(
User.objects.create_user(
username="late_guest",
password="StrongPass_2026",
nickname="迟到访客",
),
waiting.challenge_code,
)
@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
+317
View File
@@ -0,0 +1,317 @@
"""端到端模拟两人实时竞赛,覆盖同分不同时长的平局判定场景。"""
from datetime import timedelta
import pytest
from django.test import Client
from django.utils import timezone
from accounts.models import User
from contest.models import (
Contest,
ContestQuestion,
Question,
QuestionVersion,
RealtimeMatch,
)
def _setup_realtime_contest(db, slug="tiebreak-contest", duration=60):
question = Question.objects.create(
slug=f"{slug}-q",
track=Question.Track.STANDARD,
)
version = QuestionVersion.objects.create(
question=question,
version=1,
prompt="18 + 24",
answer="42",
explanation="18 + 24 = 42",
)
contest = Contest.objects.create(
slug=slug,
title="同分决胜测试赛",
kind=Contest.Kind.REALTIME,
track=Question.Track.STANDARD,
status=Contest.Status.PUBLISHED,
duration_seconds=duration,
)
ContestQuestion.objects.create(
contest=contest,
question_version=version,
order=1,
points=100,
)
return contest
def _create_user(username, nickname):
return User.objects.create_user(
username=username,
password="StrongPass_2026",
nickname=nickname,
)
def _client(user):
c = Client()
c.force_login(user)
return c
def _create_and_join(first_client, second_client, contest):
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
content_type="application/json",
)
assert created.status_code == 201
code = created.json()["challenge_code"]
joined = second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": code},
content_type="application/json",
)
assert joined.status_code == 200
assert joined.json()["status"] == "active"
match_id = joined.json()["match_id"]
first_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json()
second_state = second_client.get(f"/api/v1/contests/matches/{match_id}/").json()
return match_id, first_state, second_state
def _set_start(match, user, seconds_ago):
"""统一设置 match 和 attempt 的 started_at。"""
t = timezone.now() - timedelta(seconds=seconds_ago)
match.started_at = t
match.save(update_fields=["started_at"])
match.attempts.filter(user=user).update(started_at=t)
def _submit(client, attempt_id, answer, key):
return client.post(
f"/api/v1/contests/attempts/{attempt_id}/submit/",
{"answers": [{"order": 1, "answer": answer}]},
content_type="application/json",
HTTP_IDEMPOTENCY_KEY=key,
)
@pytest.mark.django_db
def test_同分但一方更快则快者胜(db):
"""两人答对同一题,但提交时间不同,用时短者获胜。"""
contest = _setup_realtime_contest(db, "tiebreak-fast")
first = _create_user("tie_p1", "快方")
second = _create_user("tie_p2", "慢方")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
r1 = _submit(fc, fa, "42", "tie-fast-one")
assert r1.status_code == 200
_set_start(match, second, 15)
r2 = _submit(sc, sa, "42", "tie-fast-two")
assert r2.status_code == 200
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 100
assert final["opponent"]["score"] == 100
assert final["attempt"]["duration_ms"] < final["opponent"]["duration_ms"]
@pytest.mark.django_db
def test_同分且同时长则平局(db):
"""两人答对同一题且用时完全相同,判平局。"""
contest = _setup_realtime_contest(db, "tiebreak-draw")
first = _create_user("draw_p1", "")
second = _create_user("draw_p2", "")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
_submit(fc, fa, "42", "draw-one")
_set_start(match, second, 5)
_submit(sc, sa, "42", "draw-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "draw"
@pytest.mark.django_db
def test_一方答对一方答错则答对者胜(db):
"""一人答对一人答错,答对者胜(不受时间影响)。"""
contest = _setup_realtime_contest(db, "tiebreak-correct")
first = _create_user("corr_p1", "答对方")
second = _create_user("corr_p2", "答错方")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 3)
_submit(fc, fa, "42", "corr-one")
_set_start(match, second, 10)
_submit(sc, sa, "0", "corr-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 100
assert final["opponent"]["score"] == 0
@pytest.mark.django_db
def test_双方都答错则快者胜(db):
"""两人都答错,分数相同(0 分),比较用时,快者胜。"""
contest = _setup_realtime_contest(db, "tiebreak-both-wrong")
first = _create_user("wrong_p1", "快错")
second = _create_user("wrong_p2", "慢错")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
_submit(fc, fa, "0", "wrong-one")
_set_start(match, second, 12)
_submit(sc, sa, "0", "wrong-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 0
assert final["opponent"]["score"] == 0
@pytest.mark.django_db
def test_超时提交导致分数为零_同分时比较用时(db):
"""一方超时提交(分数为 0),另一方正常提交也得 0 分,同分快者胜。"""
contest = _setup_realtime_contest(db, "tiebreak-timeout", duration=10)
first = _create_user("to_p1", "超时方")
second = _create_user("to_p2", "正常零分方")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 20)
r1 = _submit(fc, fa, "42", "to-one")
assert r1.status_code == 200
assert r1.json()["attempt"]["status"] == "expired"
_set_start(match, second, 3)
_submit(sc, sa, "0", "to-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["attempt"]["score"] == 0
assert final["opponent"]["score"] == 0
assert final["result"]["winner"] == "opponent"
@pytest.mark.django_db
def test_随机匹配同分快者胜(db):
"""通过随机匹配(非联机码)也能正确触发同分快者胜。"""
contest = _setup_realtime_contest(db, "tiebreak-random")
first = _create_user("rnd_p1", "随机快")
second = _create_user("rnd_p2", "随机慢")
fc, sc = _client(first), _client(second)
r1 = fc.post(
f"/api/v1/contests/{contest.slug}/matchmaking/",
{},
content_type="application/json",
)
r2 = sc.post(
f"/api/v1/contests/{contest.slug}/matchmaking/",
{},
content_type="application/json",
)
assert r1.json()["status"] == "active" or r2.json()["status"] == "active"
match_id = r1.json().get("match_id") or r2.json()["match_id"]
fs = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
ss = sc.get(f"/api/v1/contests/matches/{match_id}/").json()
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 4)
_submit(fc, fa, "42", "rnd-one")
_set_start(match, second, 8)
_submit(sc, sa, "42", "rnd-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 100
assert final["opponent"]["score"] == 100
@pytest.mark.django_db
def test_rating_变化在同分快者胜时正确(db):
"""同分快者胜时,Elo rating 按正常胜负变化(不平局)。"""
contest = _setup_realtime_contest(db, "tiebreak-rating")
first = _create_user("rate_p1", "Rating快")
second = _create_user("rate_p2", "Rating慢")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
_submit(fc, fa, "42", "rate-one")
_set_start(match, second, 12)
_submit(sc, sa, "42", "rate-two")
first.refresh_from_db()
second.refresh_from_db()
assert first.rating > 1000
assert second.rating < 1000
@pytest.mark.django_db
def test_双方都超时则由_refresh_自动结算判平(db):
"""双方都超时(分数都为 0),refresh_match_state 自动结算,duration 统一为时限,判平。"""
contest = _setup_realtime_contest(db, "tiebreak-both-timeout", duration=10)
first = _create_user("both_to_p1", "快超时")
second = _create_user("both_to_p2", "慢超时")
fc, sc = _client(first), _client(second)
match_id, _, _ = _create_and_join(fc, sc, contest)
# 两个人都不提交,直接让 match 超时
match = RealtimeMatch.objects.get(id=match_id)
match.started_at = timezone.now() - timedelta(seconds=15)
match.save(update_fields=["started_at"])
match.attempts.update(started_at=match.started_at)
from contest.services import refresh_match_state
refresh_match_state(match.id)
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["attempt"]["score"] == 0
assert final["opponent"]["score"] == 0
# 双方都超时,duration_ms 相同(都约 15000ms),判平
assert final["result"]["winner"] == "draw"
+29 -1
View File
@@ -3,17 +3,45 @@ from django.urls import path
from .views import (
AttemptStartView,
AttemptSubmitView,
ChallengeCreateView,
ChallengeJoinView,
ContestListView,
LeaderboardView,
MatchCancelView,
MatchmakingView,
MatchStateView,
MathGameCatalogView,
MathGameHistoryView,
MathGameStartView,
MathGameSubmitView,
SudokuHintView,
)
urlpatterns = [
path("", ContestListView.as_view(), name="contest-list"),
path("challenges/join/", ChallengeJoinView.as_view(), name="challenge-join"),
path("matches/<uuid:match_id>/", MatchStateView.as_view(), name="match-state"),
path("matches/<uuid:match_id>/cancel/", MatchCancelView.as_view(), name="match-cancel"),
path("<slug:slug>/start/", AttemptStartView.as_view(), name="attempt-start"),
path("<slug:slug>/matchmaking/", MatchmakingView.as_view(), name="matchmaking"),
path(
"<slug:slug>/challenges/",
ChallengeCreateView.as_view(),
name="challenge-create",
),
path("<slug:slug>/leaderboard/", LeaderboardView.as_view(), name="leaderboard"),
path("attempts/<uuid:attempt_id>/submit/", AttemptSubmitView.as_view(), name="attempt-submit"),
path("matches/<uuid:match_id>/", MatchStateView.as_view(), name="match-state"),
path("games/", MathGameCatalogView.as_view(), name="math-game-catalog"),
path("games/history/", MathGameHistoryView.as_view(), name="math-game-history"),
path("games/<str:kind>/start/", MathGameStartView.as_view(), name="math-game-start"),
path(
"games/attempts/<uuid:attempt_id>/submit/",
MathGameSubmitView.as_view(),
name="math-game-submit",
),
path(
"games/attempts/<uuid:attempt_id>/hint/",
SudokuHintView.as_view(),
name="sudoku-hint",
),
]
+110 -5
View File
@@ -3,10 +3,17 @@ from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Contest, ContestAttempt, RealtimeMatch
from .game_services import game_payload, request_sudoku_hint, start_game, submit_game
from .models import Contest, ContestAttempt, MathGameAttempt, RealtimeMatch
from .services import (
cancel_waiting_match,
create_challenge,
finalize_game_match,
find_match,
join_challenge,
match_payload,
notify_match_on_commit,
refresh_match_state,
start_attempt,
submit_attempt,
)
@@ -43,31 +50,63 @@ class AttemptStartView(APIView):
class AttemptSubmitView(APIView):
def post(self, request, attempt_id):
get_object_or_404(ContestAttempt, id=attempt_id, user=request.user)
attempt = get_object_or_404(ContestAttempt, id=attempt_id, user=request.user)
payload = submit_attempt(
user=request.user,
attempt_id=attempt_id,
raw_answers=request.data.get("answers", []),
submission_key=request.headers.get("Idempotency-Key"),
)
if attempt.match_id:
match = refresh_match_state(attempt.match_id)
return Response(match_payload(match, request.user))
return Response(payload)
class MatchmakingView(APIView):
def post(self, request, 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)
class MatchStateView(APIView):
def get(self, request, match_id):
match = get_object_or_404(
existing = get_object_or_404(
RealtimeMatch.objects.select_related("player_one", "player_two"),
id=match_id,
)
if request.user.id not in (match.player_one_id, match.player_two_id):
if request.user.id not in (existing.player_one_id, existing.player_two_id):
return Response(status=status.HTTP_403_FORBIDDEN)
match = refresh_match_state(match_id)
return Response(match_payload(match, request.user))
class ChallengeCreateView(APIView):
def post(self, request, slug):
contest = get_object_or_404(Contest, slug=slug)
match = create_challenge(
request.user,
contest,
request.data.get("game_kind", RealtimeMatch.GameKind.QUIZ),
)
return Response(match_payload(match, request.user), status=status.HTTP_201_CREATED)
class ChallengeJoinView(APIView):
def post(self, request):
match = join_challenge(request.user, request.data.get("challenge_code"))
return Response(match_payload(match, request.user))
class MatchCancelView(APIView):
def post(self, request, match_id):
get_object_or_404(RealtimeMatch, id=match_id)
match = cancel_waiting_match(request.user, match_id)
return Response(match_payload(match, request.user))
@@ -98,3 +137,69 @@ class LeaderboardView(APIView):
for index, attempt in enumerate(attempts, start=1)
]
)
class MathGameCatalogView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request):
return Response(
[
{
"kind": MathGameAttempt.Kind.TWENTY_FOUR,
"title": "24 点",
"summary": "四个数字各用一次,只用四则运算得到 24。",
"ability": "connection",
"estimated_minutes": 3,
},
{
"kind": MathGameAttempt.Kind.SUDOKU,
"title": "数独",
"summary": "在行、列和九宫格约束中完成 9×9 数字推理。",
"ability": "detection",
"estimated_minutes": 8,
},
]
)
class MathGameStartView(APIView):
def post(self, request, kind):
payload = start_game(
request.user,
kind,
MathGameAttempt.Difficulty.STANDARD,
)
return Response(payload, status=status.HTTP_201_CREATED)
class MathGameSubmitView(APIView):
def post(self, request, attempt_id):
attempt = get_object_or_404(
MathGameAttempt,
id=attempt_id,
user=request.user,
)
payload = submit_game(
request.user,
attempt_id,
request.data,
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)
class SudokuHintView(APIView):
def post(self, request, attempt_id):
return Response(request_sudoku_hint(request.user, attempt_id))
class MathGameHistoryView(APIView):
def get(self, request):
attempts = MathGameAttempt.objects.filter(user=request.user)[:20]
return Response([game_payload(attempt) for attempt in attempts])
+2
View File
@@ -9,6 +9,7 @@ from .models import (
SkillPackage,
Story,
StoryChoice,
StoryMark,
StoryRun,
StoryVersion,
UserRelationship,
@@ -52,5 +53,6 @@ admin.site.register(MathBTIAssessment)
admin.site.register(MathBTIResult)
admin.site.register(Character)
admin.site.register(StoryChoice)
admin.site.register(StoryMark)
admin.site.register(UserRelationship)
admin.site.register(SkillPackage)
@@ -16,6 +16,7 @@ from math_life.models import (
StoryVersion,
)
from math_life.services import validate_story_content
from math_life.story_v12 import V12_IDENTITY_SCORES, build_v12_story
DISCIPLINE_ICONS = {
"人工智能": "🤖",
@@ -134,7 +135,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
docs = Path(settings.PROJECT_ROOT) / "docs"
mathbti_path = docs / "old_scripts" / "seed_mathbti.json"
story_path = docs / "数学少年线_story.json"
story_path = docs / "信仰者线_story.json"
videos_path = docs / "old_scripts" / "seed_videos.json"
if not mathbti_path.exists() or not story_path.exists() or not videos_path.exists():
raise CommandError("缺少 docs 中的初始内容文件")
@@ -157,17 +158,20 @@ class Command(BaseCommand):
"clan": result["clan_name"],
"mathematician": result["mathematician"],
"description": result["description"],
"initial_abilities": result.get("stats5", {}),
"initial_abilities": V12_IDENTITY_SCORES.get(
code,
result.get("stats5", {}),
),
"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)
if errors:
raise CommandError("; ".join(errors))
flagship, _ = Story.objects.update_or_create(
slug="believer-math-teen",
slug="believer",
defaults={
"title": story_document["title"],
"summary": story_document.get("description", ""),
@@ -176,9 +180,12 @@ class Command(BaseCommand):
"is_visible": True,
},
)
StoryVersion.objects.filter(story=flagship).exclude(version=2).update(
is_published=False
)
StoryVersion.objects.update_or_create(
story=flagship,
version=1,
version=2,
defaults={
"content": story_document,
"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,
),
]
+36
View File
@@ -127,6 +127,42 @@ class StoryChoice(models.Model):
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):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
character = models.ForeignKey(Character, on_delete=models.CASCADE)
+112 -11
View File
@@ -4,7 +4,7 @@ from django.db import transaction
from django.utils import timezone
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):
@@ -40,6 +40,11 @@ def validate_story_content(content):
errors.append("start_node 不存在")
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():
if not isinstance(node.get("choices", []), list):
errors.append(f"{node_id}.choices 必须是数组")
@@ -52,6 +57,16 @@ def validate_story_content(content):
errors.append(f"{node_id} 引用了不存在的节点 {target}")
else:
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:
reachable = set()
@@ -66,6 +81,9 @@ def validate_story_content(content):
for choice in nodes[node_id].get("choices", [])
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)
if unreachable:
errors.append(f"存在不可达节点: {', '.join(unreachable[:10])}")
@@ -77,6 +95,9 @@ def _merge_effects(state, effects):
for key, value in effects.items():
if isinstance(value, dict):
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)):
result[key] = result.get(key, 0) + value
else:
@@ -84,12 +105,67 @@ def _merge_effects(state, effects):
return result
def _node_payload(run):
node = run.story_version.content["nodes"][run.current_node]
choices = [
{"index": index, "text": choice.get("text", "")}
for index, choice in enumerate(node.get("choices", []))
def _mark_lookup(content):
return {
item["code"]: item
for item in content.get("mark_definitions", [])
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 {
"run_id": run.id,
"story": run.story_version.story.title,
@@ -97,6 +173,10 @@ def _node_payload(run):
"status": run.status,
"current_node": run.current_node,
"state": run.state,
"chapter": chapter,
"mark_definitions": content.get("mark_definitions", []),
"domain_labels": content.get("domain_labels", {}),
"collection": collection,
"node": {
"scene": node.get("scene", ""),
"character": node.get("character", ""),
@@ -125,7 +205,11 @@ def start_story(user, story):
user=user,
story_version=version,
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)
@@ -151,8 +235,9 @@ def make_choice(*, run_id, user, choice_index, idempotency_key=None):
raise ValidationError("该人生已经结束")
node_id = run.current_node
node = run.story_version.content["nodes"][node_id]
choices = node.get("choices", [])
content = run.story_version.content
node = content["nodes"][node_id]
choices = _available_choices(run, node)
try:
normalized_index = int(choice_index)
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,
)
run.state = _merge_effects(run.state, effects)
run.current_node = choice["next"]
next_node = run.story_version.content["nodes"][run.current_node]
if content.get("system_version") == "marks-v1":
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"):
run.status = StoryRun.Status.COMPLETED
run.ending_code = run.current_node
+261
View File
@@ -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
+123 -1
View File
@@ -1,14 +1,28 @@
import pytest
from django.conf import settings
from rest_framework.exceptions import ValidationError
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 (
get_run_payload,
make_choice,
score_mathbti,
start_story,
validate_story_content,
)
from math_life.story_v12 import (
EASTER_EGGS,
MARK_DEFINITIONS,
V12_IDENTITY_SCORES,
build_v12_story,
)
@pytest.fixture
@@ -142,3 +156,111 @@ def test_make_choice_负数索引必须拒绝且存档不变(story_setup):
run = StoryRun.objects.get(id=started["run_id"])
assert run.current_node == "start"
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"]
+59 -1
View File
@@ -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"]
+74
View File
@@ -1,6 +1,9 @@
from django.db.models import Prefetch, Q
from rest_framework.response import Response
from rest_framework.views import APIView
from contest.models import RatingHistory, RealtimeMatch
from .models import UserAbility, UserPet
@@ -21,6 +24,54 @@ class ProgressionProfileView(APIView):
"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(
{
"pet": {
@@ -40,5 +91,28 @@ class ProgressionProfileView(APIView):
}
for item in request.user.cards.select_related("card")
],
"recent_games": [
{
"kind": attempt.kind,
"label": attempt.get_kind_display(),
"difficulty": attempt.get_difficulty_display(),
"status": attempt.status,
"score": attempt.score,
"duration_ms": attempt.duration_ms,
"started_at": attempt.started_at,
}
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")
],
}
)
+192 -15
View File
@@ -50,6 +50,7 @@ button { color: inherit; }
.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 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 {
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;
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; }
.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; }
@@ -132,23 +134,69 @@ button { color: inherit; }
.track-switch { display: flex; gap: 5px; margin-bottom: 22px; }
.track-switch button { border: 1px solid var(--line); padding: 9px 18px; border-radius: 99px; background: transparent; cursor: pointer; }
.track-switch button.active { background: var(--ink); color: white; }
.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; }
.realtime-status-line { display: flex; justify-content: space-between; gap: 15px; margin: 10px 0 18px; color: var(--muted); font-size: 11px; }.realtime-status-line strong { color: var(--green); }
.realtime-waiting { display: grid; justify-items: center; gap: 16px; padding: 35px 20px; border-radius: 18px; background: #eef1eb; text-align: center; }.realtime-waiting p { max-width: 480px; margin: 0; color: var(--muted); line-height: 1.7; }.realtime-pulse { width: 18px; height: 18px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 0 rgba(25,101,72,.35); animation: realtime-pulse 1.5s infinite; }.challenge-code { border: 1px dashed var(--green); border-radius: 14px; padding: 14px 24px; background: white; color: var(--green); font: 700 31px/1 "SFMono-Regular", Consolas, monospace; letter-spacing: .22em; cursor: pointer; }
@keyframes realtime-pulse { 70% { box-shadow: 0 0 0 15px rgba(25,101,72,0); } 100% { box-shadow: 0 0 0 0 rgba(25,101,72,0); } }
.realtime-progress-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }.realtime-progress-panel > div { padding: 13px 15px; border-radius: 12px; background: #eef1eb; }.realtime-progress-panel span, .realtime-progress-panel b { display: block; }.realtime-progress-panel span { color: var(--muted); font-size: 10px; }.realtime-progress-panel b { margin-top: 5px; color: var(--green); }
.realtime-answer-form input { margin-top: 7px; width: 100%; padding: 12px; border: 1px solid #d6d8d1; border-radius: 9px; }.realtime-submitted { margin-top: 20px; padding: 30px; border-radius: 16px; background: var(--ink); color: white; text-align: center; }.realtime-submitted strong { color: var(--lime); font: 27px Georgia, serif; }.realtime-submitted p { margin-bottom: 0; color: #b9c2bc; }
.realtime-result { margin: 20px 0; padding: 28px; border-radius: 17px; background: var(--ink); color: white; text-align: center; }.realtime-result > strong { color: var(--lime); font: 38px Georgia, serif; }.realtime-result p { color: #c5cec8; }.realtime-result > b { display: inline-block; padding: 6px 10px; border-radius: 99px; background: rgba(204,232,91,.13); color: var(--lime); }.result-opponent > strong { color: #ef947c; }
.realtime-time-line { margin: 10px 0 6px; font-size: 13px; color: #9ba89d; }
.realtime-tiebreak { display: block; margin-top: 6px; font-size: 11px; color: var(--lime); font-weight: 700; }
.realtime-review { display: grid; gap: 9px; }.realtime-review article { padding: 15px; border: 1px solid var(--line); border-left: 4px solid var(--green); border-radius: 11px; background: white; }.realtime-review article.incorrect { border-left-color: #c05245; }.realtime-review p { margin: 7px 0; color: #3e4942; }.realtime-review small { color: var(--muted); }
.math-games-section { margin-top: 60px; }
.game-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.game-card { min-height: 285px; padding: 26px; border: 1px solid var(--line); border-radius: 20px; background: var(--panel); display: flex; flex-direction: column; overflow: hidden; position: relative; }
.game-card::after { content: ""; position: absolute; width: 140px; height: 140px; right: -65px; top: -65px; border-radius: 50%; background: rgba(204,232,91,.18); }
.game-sudoku::after { background: rgba(93,115,232,.13); }
.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-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, .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; }
.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:nth-child(3n) { border-right: 2px solid var(--ink); }.sudoku-board input:nth-child(9n) { border-right: 0; }.sudoku-board input:nth-child(n+19):nth-child(-n+27), .sudoku-board input:nth-child(n+46):nth-child(-n+54) { border-bottom: 2px solid var(--ink); }
.sudoku-board input:focus { outline-color: var(--green); }.sudoku-board input.given { background: #ecece5; color: var(--ink); font-weight: 800; }.sudoku-board input.hint { background: #fff2ca; color: #9a5b22; }
.sudoku-actions { display: flex; justify-content: flex-end; gap: 10px; }.game-result-panel { margin-top: 30px; padding: 30px; border-radius: 18px; background: var(--ink); color: white; text-align: center; }.game-result-panel strong { color: var(--lime); font: 48px Georgia, serif; }.game-result-panel p { color: #b9c2bc; }
.editor-shell { display: grid; grid-template-columns: 1fr 1fr; overflow: hidden; min-height: 430px; }
.editor-pane, .preview-pane { padding: 28px; }
.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 textarea { width: 100%; height: 330px; margin-top: 22px; resize: none; border: 0; outline: 0; background: transparent; color: #dcefa1; font: 16px/1.8 "SFMono-Regular", Consolas, monospace; }
.preview-pane { display: flex; flex-direction: column; }
#latex-preview { margin: auto; max-width: 100%; overflow-wrap: anywhere; font: 28px/1.6 Georgia, serif; }
.preview-pane button { align-self: flex-end; }
.preview-pane { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; }
#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; }
#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; }
.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 b, .metric span { display: block; }.metric b { font: 28px Georgia, serif; }.metric span { margin-top: 5px; color: var(--muted); font-size: 11px; }
.tool-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin-bottom: 22px; }
.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-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 {
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;
}
.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.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; }
@@ -159,19 +207,55 @@ button { color: inherit; }
.tool-workspace.active { display: block; animation: rise .3s ease; }
.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; }
.calculator-shell { max-width: 800px; 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 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); }
.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-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-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); }
.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%); }
.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 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-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-canvas { width: 100%; height: auto; border: 1px solid var(--line); border-radius: 17px; background: #fbfbf7; }
.graph-shell { display: grid; grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); gap: 18px; align-items: start; }
.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; }
.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-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; }
.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; }
.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; }
.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; }
@@ -185,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 b { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 50%; background: #778990; color: white; font: 22px Georgia, serif; }.pet-node span { position: absolute; bottom: 8px; font-size: 10px; color: var(--muted); }
.map-hint { margin: 0; text-align: center; color: #a0a49e; font-size: 11px; }
.video-browser { margin-top: 28px; }.filter-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 11px; }
.filter-chip { border: 1px solid var(--line); border-radius: 99px; padding: 9px 14px; background: rgba(255,255,252,.7); color: var(--muted); cursor: pointer; }
.filter-chip.active { background: var(--ink); border-color: var(--ink); color: white; }
.discipline-filters .filter-chip { border-radius: 9px; }
.video-browser { margin-top: 28px; }.filter-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 11px; }
.filter-dropdown { position: relative; }
.filter-dropdown-toggle { display: flex; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 12px; padding: 10px 16px; background: rgba(255,255,252,.82); color: var(--ink); cursor: pointer; font-size: 14px; min-width: 140px; justify-content: space-between; }
.filter-dropdown-toggle b { font-size: 10px; color: var(--muted); transition: transform .2s; }
.filter-dropdown-toggle[aria-expanded="true"] b { transform: rotate(180deg); }
.filter-dropdown-panel { position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; min-width: 200px; max-height: 320px; overflow-y: auto; border: 1px solid var(--line); border-radius: 14px; background: rgba(255,255,252,.96); backdrop-filter: blur(16px); box-shadow: 0 12px 40px rgba(38,48,40,.12); padding: 6px; display: flex; flex-direction: column; gap: 2px; }
.filter-dropdown-panel.hidden { display: none; }
.filter-option { border: 0; border-radius: 10px; padding: 10px 14px; background: transparent; color: var(--ink); cursor: pointer; text-align: left; font-size: 13px; transition: background .15s; }
.filter-option:hover { background: #eef1eb; }
.filter-option.active { background: var(--ink); color: white; }
.video-section-heading { margin: 35px 0 16px; display: flex; justify-content: space-between; align-items: end; }.video-section-heading h2 { margin: 8px 0 0; font: 30px Georgia, serif; }.video-section-heading > span { color: var(--muted); font-size: 12px; }
.video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }.video-grid.loading { display: block; color: var(--muted); padding: 35px 0; }
.video-card { border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,252,.82); overflow: hidden; cursor: pointer; transition: .2s ease; }
@@ -210,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; }
.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; }
.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 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); }
@@ -225,16 +325,93 @@ 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; }
.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; }
.tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 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) {
.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; }
.brand, .sidebar-foot { display: none; }.nav { margin: 0; grid-template-columns: repeat(6, 1fr); gap: 2px; }.nav-item { padding: 11px 2px; font-size: 0; }.nav-item span { display: block; margin: auto; }
.main { padding: 0 18px 90px; }.topbar { height: 66px; }.topbar p { max-width: 55%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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 { 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; }
.tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 20px; }
.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; }
.challenge-panel { grid-template-columns: 1fr; padding: 20px; }.challenge-actions form { grid-template-columns: 1fr; }.challenge-actions .dark-button { width: 100%; }.challenge-code { width: 100%; padding: 14px 10px; font-size: 27px; }.realtime-status-line { display: grid; }.realtime-progress-panel { grid-template-columns: 1fr 1fr; }
.map-stage { height: 480px; transform: scale(.92); }.ability-node { width: 108px; height: 94px; }.node-vision { left: calc(50% - 54px); }.node-humanities { left: 0; top: 100px; }.node-connection { right: 0; top: 100px; }.node-detection { left: 2%; bottom: 30px; }.node-modeling { right: 2%; bottom: 30px; }.pet-node { top: 190px; }
.map-lines { display: none; }.ability-legend { margin-top: 14px; justify-content: start; }.video-cover { height: 150px; }
}
.life-hub, .life-skills { animation: rise .35s ease; }
.back-button { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 10px; padding: 8px 16px; background: transparent; color: var(--muted); cursor: pointer; margin-bottom: 18px; font-size: 13px; transition: .2s ease; }
.back-button:hover { color: var(--ink); border-color: var(--ink); }
.life-map { margin-top: 28px; }
.life-map-canvas { position: relative; max-width: 1200px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; padding: 30px 0; }
.life-map-lines { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; pointer-events: none; }
.route-card {
position: relative; width: 100%; padding: 28px 26px 24px; border-radius: 22px; z-index: 2;
border: 1px solid rgba(23,33,27,.1); background: rgba(255,255,252,.82); backdrop-filter: blur(14px);
box-shadow: 0 24px 70px rgba(38,48,40,.06); cursor: pointer; transition: .25s ease; display: flex; flex-direction: column; min-height: 300px;
}
.route-card:hover { transform: translateY(-6px); box-shadow: 0 28px 80px rgba(38,48,40,.1); }
.route-card::after { content: ""; position: absolute; width: 80px; height: 80px; border-radius: 50%; right: -28px; top: -28px; opacity: .5; }
.route-believer::after { background: var(--lime); }
.route-spreader::after { background: var(--blue); }
.route-applier::after { background: var(--orange); }
.route-seer::after { background: #bb8ac4; }
.route-index { font: 700 13px Georgia, serif; color: #b0b6ae; letter-spacing: .08em; }
.route-spirit { margin-top: 14px; } .route-spirit b { display: block; font: 25px Georgia, serif; } .route-spirit small { font-size: 9px; color: var(--muted); letter-spacing: .22em; }
.route-conflict { margin: 16px 0 18px; color: #3e4942; font-size: 14px; line-height: 1.7; }
.route-mathematicians { display: flex; gap: 10px; margin-bottom: 18px; }
.route-mathematician { display: flex; flex-direction: column; align-items: center; gap: 5px; flex: 1; }
.route-mathematician img { width: 52px; height: 52px; border-radius: 50%; object-fit: cover; border: 2px solid rgba(23,33,27,.08); }
.route-mathematician b { font-size: 10px; text-align: center; }
.route-mathematician small { font-size: 8px; color: var(--muted); text-align: center; line-height: 1.3; }
.route-meta { margin-top: auto; margin-bottom: 16px; }
.route-status { font-size: 10px; color: var(--green); font-weight: 700; letter-spacing: .06em; }
.route-enter { border: 0; border-bottom: 2px solid currentColor; padding: 4px 0; background: transparent; cursor: pointer; font-size: 14px; font-weight: 650; text-align: left; }
.route-believer .route-enter { color: var(--green); } .route-spreader .route-enter { color: var(--blue); }
.route-applier .route-enter { color: var(--orange); } .route-seer .route-enter { color: #9b6bb0; }
.alumni-entry { margin-top: 50px; border-radius: 22px; background: var(--ink); color: white; padding: 40px 46px; overflow: hidden; position: relative; }
.alumni-entry::after { content: ""; position: absolute; width: 280px; height: 280px; right: -80px; bottom: -120px; border: 45px solid rgba(204,232,91,.08); border-radius: 50%; }
.alumni-entry-content { display: flex; align-items: center; justify-content: space-between; gap: 30px; position: relative; z-index: 1; }
.alumni-entry .kicker { color: var(--lime); }
.alumni-entry h2 { margin: 12px 0 10px; font: 30px Georgia, serif; }
.alumni-entry p { color: #aeb9b1; font-size: 14px; line-height: 1.7; max-width: 480px; }
.alumni-entry-action { display: flex; flex-direction: column; align-items: flex-end; gap: 14px; }
.alumni-entry-action span { font-size: 11px; color: #aeb9b1; }
.alumni-entry .primary-button { background: var(--lime); color: var(--ink); }
.skill-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.skill-grid.loading { display: block; color: var(--muted); padding: 40px 0; }
.skill-card { border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,252,.82); overflow: hidden; transition: .2s ease; }
.skill-card:hover { transform: translateY(-4px); box-shadow: 0 18px 38px rgba(38,48,40,.1); }
.skill-header { height: 90px; background: var(--ink); display: flex; align-items: flex-end; padding: 18px; }
.skill-meta { color: var(--lime); font-size: 10px; font-weight: 700; letter-spacing: .16em; }
.skill-body { padding: 22px; display: flex; flex-direction: column; min-height: 180px; }
.skill-body h3 { margin: 0 0 10px; font: 21px/1.3 Georgia, serif; }
.skill-body p { color: var(--muted); font-size: 13px; line-height: 1.7; flex: 1; }
.skill-body footer { display: flex; align-items: center; justify-content: space-between; margin-top: 18px; }
.skill-body footer span { color: var(--muted); font-size: 12px; }
.skill-body footer button { border: 0; border-bottom: 2px solid var(--green); background: transparent; color: var(--green); padding: 4px 0; cursor: pointer; font-weight: 650; }
.skill-body footer button:disabled { cursor: not-allowed; opacity: .5; }
@media (max-width: 1050px) {
.life-map-canvas { grid-template-columns: 1fr; }
.life-map-lines { display: none; }
.alumni-entry-content { flex-direction: column; align-items: flex-start; }
.alumni-entry-action { align-items: flex-start; }
.skill-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 700px) {
.skill-grid { grid-template-columns: 1fr; }
.alumni-entry { padding: 28px 24px; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+486 -144
View File
@@ -1,8 +1,35 @@
const ROUTE_MATHEMATICIANS = {
believer: [
{ name: "高斯", portrait: "/static/img/mathematicians/1000.png", identity: "定理工匠" },
{ name: "祖冲之", portrait: "/static/img/mathematicians/1010.png", identity: "穷竭守尺人" },
{ name: "欧拉", portrait: "/static/img/mathematicians/1001.png", identity: "公式狂僧" },
{ name: "诺特", portrait: "/static/img/mathematicians/1011.png", identity: "抽象代数宗师" },
],
spreader: [
{ name: "斐波那契", portrait: "/static/img/mathematicians/1100.png", identity: "商路算郎" },
{ name: "华罗庚", portrait: "/static/img/mathematicians/1110.png", identity: "双法布道者" },
{ name: "伽罗瓦", portrait: "/static/img/mathematicians/1101.png", identity: "决斗狂热者" },
{ name: "Lovelace", portrait: "/static/img/mathematicians/1111.png", identity: "织机先知" },
],
applier: [
{ name: "秦九韶", portrait: "/static/img/mathematicians/0100.png", identity: "大衍谋士" },
{ name: "牛顿", portrait: "/static/img/mathematicians/0110.png", identity: "宇宙立法者" },
{ name: "图灵", portrait: "/static/img/mathematicians/0111.png", identity: "密码破译使" },
{ name: "冯·诺依曼", portrait: "/static/img/mathematicians/0101.png", identity: "博弈游侠" },
],
seer: [
{ name: "希帕提娅", portrait: "/static/img/mathematicians/0000.png", identity: "几何殉道者" },
{ name: "赵爽", portrait: "/static/img/mathematicians/0010.png", identity: "弦图先觉" },
{ name: "庞加莱", portrait: "/static/img/mathematicians/0001.png", identity: "拓扑游方" },
{ name: "约翰逊", portrait: "/static/img/mathematicians/0011.png", identity: "人脑计算机" },
],
};
const state = {
user: null,
stories: [],
contests: [],
track: "standard",
matchMode: "quiz",
videoCatalog: null,
videos: [],
videoAbility: "",
@@ -43,22 +70,72 @@ function createIdempotencyKey() {
].join("-");
}
function collectApiErrorMessages(value, messages = []) {
if (Array.isArray(value)) {
value.forEach((item) => collectApiErrorMessages(item, messages));
} else if (value && typeof value === "object") {
Object.values(value).forEach((item) => collectApiErrorMessages(item, messages));
} else if (value !== undefined && value !== null) {
const message = String(value).trim();
if (message && !messages.includes(message)) messages.push(message);
}
return messages;
}
function logApiError(error) {
if (error.status === 401 || error.status === 403) return;
console.error("[Hulumath API]", {
method: error.method,
path: error.path,
status: error.status,
code: error.code,
requestId: error.requestId,
details: error.details,
});
}
async function api(path, options = {}) {
const method = (options.method || "GET").toUpperCase();
const requestPath = `/api/v1/${path}`;
const headers = { Accept: "application/json", ...(options.headers || {}) };
if (options.body && typeof options.body !== "string") {
headers["Content-Type"] = "application/json";
options.body = JSON.stringify(options.body);
}
if (!["GET", "HEAD"].includes(options.method || "GET")) headers["X-CSRFToken"] = csrfToken();
const response = await fetch(`/api/v1/${path}`, { credentials: "same-origin", ...options, headers });
if (!["GET", "HEAD"].includes(method)) headers["X-CSRFToken"] = csrfToken();
let response;
try {
response = await fetch(requestPath, { credentials: "same-origin", ...options, headers });
} catch (cause) {
const error = new Error("网络连接失败,请检查连接后重试");
error.status = null;
error.code = "network_error";
error.requestId = null;
error.details = null;
error.path = requestPath;
error.method = method;
error.cause = cause;
logApiError(error);
throw error;
}
if (response.status === 204) return null;
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const details = payload.error?.details;
const message = payload.error?.message || payload.detail ||
(details ? Object.values(details).flat().join(" ") : "请求失败");
const detailMessages = collectApiErrorMessages(details);
const requestId = payload.error?.request_id || response.headers.get("X-Request-ID");
const baseMessage = detailMessages.length
? detailMessages.join("")
: payload.error?.message || payload.detail || `请求失败(HTTP ${response.status}`;
const message = requestId ? `${baseMessage}(请求编号:${requestId}` : baseMessage;
const error = new Error(message);
error.status = response.status;
error.code = payload.error?.code || "request_error";
error.requestId = requestId;
error.details = details || null;
error.path = requestPath;
error.method = method;
logApiError(error);
throw error;
}
return payload;
@@ -71,6 +148,16 @@ function showToast(message) {
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) {
$$(".nav-item").forEach((button) => button.classList.toggle("active", button.dataset.view === view));
$$(".view").forEach((section) => section.classList.toggle("active", section.id === `view-${view}`));
@@ -101,6 +188,7 @@ function updateUserUI() {
chip.append(avatar, name);
$("#auth-button").textContent = state.user ? "退出登录" : "登录 / 注册";
$("#admin-entry").hidden = !state.user?.is_staff;
if (!state.user) $("#home-match-history").hidden = true;
}
async function loadUser() {
@@ -111,6 +199,7 @@ async function loadUser() {
state.user = null;
}
updateUserUI();
await loadHomeMatchHistory();
}
function card({ meta, title, body, foot, action, onClick, disabled = false }) {
@@ -135,42 +224,164 @@ function card({ meta, title, body, foot, action, onClick, disabled = false }) {
return article;
}
function renderRouteMathematicians() {
Object.entries(ROUTE_MATHEMATICIANS).forEach(([clan, mathematicians]) => {
const root = $(`[data-mathematicians="${clan}"]`);
if (!root) return;
root.replaceChildren(...mathematicians.map((person) => {
const item = document.createElement("div");
item.className = "route-mathematician";
const img = document.createElement("img");
img.src = person.portrait;
img.alt = person.name;
img.loading = "lazy";
const name = document.createElement("b");
name.textContent = person.name;
const identity = document.createElement("small");
identity.textContent = person.identity;
item.append(img, name, identity);
return item;
}));
});
}
async function loadStories() {
const root = $("#story-list");
try {
state.stories = await api("math-life/stories/");
root.classList.remove("loading");
root.replaceChildren(...state.stories.map((story) => card({
meta: story.kind === "flagship" ? "旗舰人生" : story.kind === "skill" ? "人物 Skill" : "特别篇",
title: story.title,
body: story.summary || "在不完整信息中判断机会、关系与代价。",
foot: `${story.estimated_minutes} 分钟`,
action: story.available ? "开始人生 →" : "查看预告",
disabled: !story.available,
onClick: () => beginStory(story.slug),
})));
renderRouteBadges();
renderSkillList();
} catch (error) {
root.textContent = error.message;
showToast(error.message);
}
}
function renderRouteBadges() {
const flagship = state.stories.find((story) => story.kind === "flagship" && story.available);
const skillCount = state.stories.filter((story) => story.kind === "skill").length;
const alumniCount = $("#alumni-count");
alumniCount.textContent = skillCount > 0 ? `当前 ${skillCount} 个校友人生可体验` : "即将上线校友访谈";
if (flagship) {
const route = flagship.slug.includes("believer") ? "believer" : "believer";
const card = $(`.route-card[data-route="${route}"]`);
if (card) {
card.querySelector(".route-status").textContent = `可体验 · 约 ${flagship.estimated_minutes} 分钟`;
}
}
}
function renderSkillList() {
const root = $("#skill-list");
const skills = state.stories.filter((story) => story.kind === "skill");
if (skills.length === 0) {
root.classList.remove("loading");
root.textContent = "校友访谈即将上线,敬请期待。";
return;
}
root.classList.remove("loading");
root.replaceChildren(...skills.map((story) => {
const article = document.createElement("article");
article.className = "skill-card";
const header = document.createElement("div");
header.className = "skill-header";
const meta = document.createElement("span");
meta.className = "skill-meta";
meta.textContent = story.kind === "skill" ? "校友访谈" : "特别篇";
header.append(meta);
const body = document.createElement("div");
body.className = "skill-body";
const title = document.createElement("h3");
title.textContent = story.title;
const summary = document.createElement("p");
summary.textContent = story.summary || "在不完整信息中判断机会、关系与代价。";
const footer = document.createElement("footer");
const duration = document.createElement("span");
duration.textContent = `${story.estimated_minutes} 分钟`;
const button = document.createElement("button");
button.textContent = story.available ? "开始人生 →" : "查看预告";
button.disabled = !story.available;
if (story.available) button.addEventListener("click", () => beginStory(story.slug));
footer.append(duration, button);
body.append(title, summary, footer);
article.append(header, body);
return article;
}));
}
function showLifeHub() {
$("#life-hub").classList.remove("hidden");
$("#life-skills").classList.add("hidden");
}
function showLifeSkills() {
$("#life-hub").classList.add("hidden");
$("#life-skills").classList.remove("hidden");
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function beginStory(slug) {
if (!requireAuth()) return;
try {
const run = await api(`math-life/stories/${slug}/start/`, { method: "POST", body: {} });
renderStoryNode(run);
$("#experience-dialog").showModal();
$("#story-experience").classList.remove("hidden");
document.body.classList.add("story-open");
window.scrollTo({ top: 0 });
} catch (error) {
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) {
const root = $("#experience-content");
const root = $("#story-experience-content");
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");
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");
title.textContent = run.node.character || "旁白";
const scene = document.createElement("p");
@@ -179,13 +390,20 @@ function renderStoryNode(run) {
const choices = document.createElement("div");
choices.className = "choice-list";
if (run.status === "completed") {
root.classList.add("completed");
const ending = document.createElement("p");
ending.textContent = "这段人生已经抵达结局,路径已写入你的数学档案。";
choices.append(ending);
ending.textContent = "统一直博的方向已经确定。这段人生、结局与全部印记已写入你的数学档案。";
const back = document.createElement("button");
back.className = "primary-button";
back.textContent = "返回数学人生大厅";
back.addEventListener("click", closeStoryExperience);
choices.append(ending, back);
} else {
root.classList.remove("completed");
run.node.choices.forEach((choice) => {
const button = document.createElement("button");
button.textContent = choice.text;
if (choice.special) button.classList.add("story-special-choice");
button.addEventListener("click", async () => {
button.disabled = true;
try {
@@ -292,12 +510,21 @@ async function loadContests() {
function renderContests() {
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.replaceChildren(...contests.map((contest) => card({
meta: contest.kind === "realtime" ? "实时竞技" : contest.kind === "daily" ? "今日挑战" : "自由练习",
title: contest.title,
body: contest.kind === "realtime" ? "Rating 匹配,同题序列,服务端计时和唯一结算。" : "完成整组题目,正式答案只在提交后显示。",
title: contest.title.replace(/^(入门|标准|进阶)/, ""),
body: contest.kind === "realtime" ? "统一玩家池,支持口算、数独与 24 点,服务端计时和 Rating 结算。" : "每次随机抽取题目,正式答案只在提交后显示。",
foot: `${contest.duration_seconds}`,
action: contest.kind === "realtime" ? "开始匹配 →" : "开始答题 →",
onClick: () => beginContest(contest),
@@ -308,12 +535,8 @@ async function beginContest(contest) {
if (!requireAuth()) return;
try {
if (contest.kind === "realtime") {
const match = await api(`contests/${contest.slug}/matchmaking/`, { method: "POST", body: {} });
if (match.status === "waiting") {
showToast("已进入匹配队列,等待同赛道对手");
await window.HuluRealtime.startRandom(contest);
return;
}
renderAttempt(match.attempt);
} else {
renderAttempt(await api(`contests/${contest.slug}/start/`, { method: "POST", body: {} }));
}
@@ -379,27 +602,46 @@ function renderAttempt(attempt) {
root.append(label, title, form);
}
function filterChip(label, value, type, active) {
function filterOption(label, value, type, active) {
const button = document.createElement("button");
button.className = `filter-chip${active ? " active" : ""}`;
button.className = `filter-option${active ? " active" : ""}`;
button.textContent = label;
button.dataset[type] = value;
button.setAttribute("role", "option");
button.addEventListener("click", () => {
if (type === "ability") state.videoAbility = value;
if (type === "discipline") state.videoDiscipline = value;
renderVideoFilters();
renderVideos();
closeFilterDropdowns();
});
return button;
}
function closeFilterDropdowns() {
$$(".filter-dropdown-panel").forEach((panel) => panel.classList.add("hidden"));
$$(".filter-dropdown-toggle").forEach((toggle) => toggle.setAttribute("aria-expanded", "false"));
}
function toggleFilterDropdown(type) {
const panel = $(`#${type}-panel`);
const toggle = $(`#${type}-toggle`);
const isOpen = !panel.classList.contains("hidden");
closeFilterDropdowns();
if (!isOpen) {
panel.classList.remove("hidden");
toggle.setAttribute("aria-expanded", "true");
}
}
function renderVideoFilters() {
if (!state.videoCatalog) return;
const abilityRoot = $("#ability-filters");
abilityRoot.replaceChildren(
filterChip(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility),
const abilityPanel = $("#ability-panel");
abilityPanel.replaceChildren(
filterOption(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility),
...state.videoCatalog.abilities.map((ability) =>
filterChip(
filterOption(
`${ability.icon} ${ability.label} ${ability.count}`,
ability.id,
"ability",
@@ -407,11 +649,14 @@ function renderVideoFilters() {
)
),
);
const disciplineRoot = $("#discipline-filters");
disciplineRoot.replaceChildren(
filterChip("全部专业", "", "discipline", !state.videoDiscipline),
const abilityLabel = state.videoCatalog.abilities.find((a) => a.id === state.videoAbility);
$("#ability-label").textContent = abilityLabel ? `${abilityLabel.icon} ${abilityLabel.label}` : "全部维度";
const disciplinePanel = $("#discipline-panel");
disciplinePanel.replaceChildren(
filterOption("全部专业", "", "discipline", !state.videoDiscipline),
...state.videoCatalog.disciplines.map((discipline) =>
filterChip(
filterOption(
`${discipline.icon || "∑"} ${discipline.name}`,
discipline.name,
"discipline",
@@ -419,11 +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(
(item) => item.id === state.videoAbility
);
$("#video-stream-title").textContent = selected ? `${selected.label}视频流` : "全部视频";
$("#video-stream-title").textContent = abilityLabel ? `${abilityLabel.label}视频流` : "全部视频";
$$(".ability-node").forEach((node) => {
node.classList.toggle("active", node.dataset.ability === state.videoAbility);
});
@@ -638,11 +882,99 @@ async function loadProfile() {
metrics.append(item);
});
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) {
const gameTitle = document.createElement("h3");
gameTitle.className = "profile-subtitle";
gameTitle.textContent = "最近数学玩法";
const gameList = document.createElement("div");
gameList.className = "profile-game-list";
profile.recent_games.forEach((game) => {
const item = document.createElement("div");
const name = document.createElement("b");
name.textContent = game.label;
const detail = document.createElement("span");
detail.textContent =
game.status === "completed"
? `${game.score} 分 · ${(game.duration_ms / 1000).toFixed(1)}`
: "进行中";
item.append(name, detail);
gameList.append(item);
});
root.append(gameTitle, gameList);
}
} catch (error) {
root.textContent = error.message;
}
}
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 = [
["∑", "求和", "一组数或表达式的总和", "\\sum"],
["∏", "连乘", "一组数或表达式的乘积", "\\prod"],
@@ -673,10 +1005,20 @@ function evaluateExpression(source, variables = {}) {
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
asin: Math.asin,
acos: Math.acos,
atan: Math.atan,
sinh: Math.sinh,
cosh: Math.cosh,
tanh: Math.tanh,
sqrt: Math.sqrt,
log: Math.log10,
ln: Math.log,
exp: Math.exp,
abs: Math.abs,
floor: Math.floor,
ceil: Math.ceil,
round: Math.round,
};
const skip = () => {
@@ -767,18 +1109,6 @@ function evaluateExpression(source, variables = {}) {
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 = "") {
const keyword = query.trim().toLowerCase();
const matches = MATH_SYMBOLS.filter((symbol) =>
@@ -810,77 +1140,51 @@ 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) {
if (tool === "mental") {
navigate("contest");
return;
}
$$(".tool-card").forEach((card) => {
card.classList.toggle("active", card.dataset.tool === tool);
});
$$(".tool-workspace").forEach((workspace) => {
workspace.classList.toggle("active", workspace.id === `tool-${tool}`);
});
if (tool === "graph") window.setTimeout(drawGraph, 30);
window.HuluToolbox?.activate(tool);
}
function normalizeLatexSource(source) {
let normalized = String(source || "").trim();
if (normalized.startsWith("$$") && normalized.endsWith("$$")) {
normalized = normalized.slice(2, -2).trim();
} else if (normalized.startsWith("\\[") && normalized.endsWith("\\]")) {
normalized = normalized.slice(2, -2).trim();
} else if (normalized.startsWith("$") && normalized.endsWith("$")) {
normalized = normalized.slice(1, -1).trim();
}
return normalized.replace(/\\\\(?=[A-Za-z,;:!])/g, "\\");
}
function renderLatexPreview(source) {
const target = $("#latex-preview");
target.replaceChildren();
target.classList.remove("latex-preview-error");
const normalized = normalizeLatexSource(source);
if (!normalized) return;
if (typeof globalThis.katex?.render !== "function") {
target.classList.add("latex-preview-error");
target.textContent = "公式渲染组件加载失败,请刷新页面后重试。";
return;
}
try {
globalThis.katex.render(normalized, target, {
displayMode: true,
throwOnError: true,
trust: false,
maxExpand: 500,
maxSize: 20,
});
} catch (error) {
target.classList.add("latex-preview-error");
target.textContent = `公式语法错误:${error.message}`;
}
}
async function saveFormula() {
@@ -888,7 +1192,10 @@ async function saveFormula() {
try {
await api("latex/documents/", {
method: "POST",
body: { title: `公式 ${new Date().toLocaleString()}`, source: $("#latex-source").value },
body: {
title: `公式 ${new Date().toLocaleString()}`,
source: normalizeLatexSource($("#latex-source").value),
},
});
showToast("公式及首个版本已保存");
} catch (error) {
@@ -901,28 +1208,41 @@ function bindUI() {
$$("[data-jump]").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.jump)));
$$("[data-open-auth]").forEach((button) => button.addEventListener("click", openAuth));
$("#start-mathbti").addEventListener("click", startMathBTI);
$("#wechat-copy").addEventListener("click", copyWechatName);
$("#story-experience-close").addEventListener("click", closeStoryExperience);
$("#save-formula").addEventListener("click", saveFormula);
$("#latex-source").addEventListener("input", (event) => { $("#latex-preview").textContent = event.target.value; });
$("#latex-source").addEventListener("input", (event) => { renderLatexPreview(event.target.value); });
$$(".tool-card").forEach((button) => {
button.addEventListener("click", () => switchTool(button.dataset.tool));
});
$("#calc-run").addEventListener("click", runCalculator);
$("#calc-input").addEventListener("keydown", (event) => {
if (event.key === "Enter") runCalculator();
});
$$("[data-calc-example]").forEach((button) => {
button.addEventListener("click", () => {
$("#calc-input").value = button.dataset.calcExample;
runCalculator();
});
});
$("#symbol-search").addEventListener("input", (event) => {
renderSymbols(event.target.value);
});
$("#graph-run").addEventListener("click", drawGraph);
$("#graph-range").addEventListener("input", drawGraph);
$("#graph-expression").addEventListener("keydown", (event) => {
if (event.key === "Enter") drawGraph();
$("#enter-alumni").addEventListener("click", showLifeSkills);
$("#back-to-hub").addEventListener("click", showLifeHub);
$("#ability-toggle").addEventListener("click", () => toggleFilterDropdown("ability"));
$("#discipline-toggle").addEventListener("click", () => toggleFilterDropdown("discipline"));
document.addEventListener("click", (event) => {
if (!event.target.closest(".filter-dropdown")) closeFilterDropdowns();
});
$$(".route-card").forEach((card) => {
card.addEventListener("click", (event) => {
if (event.target.closest(".route-enter")) return;
const route = card.dataset.route;
const flagship = state.stories.find(
(story) => story.kind === "flagship" && story.available && story.slug.includes(route),
);
if (flagship) beginStory(flagship.slug);
else showToast(`${card.querySelector(".route-spirit b").textContent}人生即将开放`);
});
card.querySelector(".route-enter").addEventListener("click", () => {
const route = card.dataset.route;
const flagship = state.stories.find(
(story) => story.kind === "flagship" && story.available && story.slug.includes(route),
);
if (flagship) beginStory(flagship.slug);
else showToast(`${card.querySelector(".route-spirit b").textContent}人生即将开放`);
});
});
$$(".ability-node").forEach((button) => {
button.addEventListener("click", () => {
@@ -936,8 +1256,10 @@ function bindUI() {
$("#auth-button").addEventListener("click", async () => {
if (!state.user) return openAuth();
await api("accounts/logout/", { method: "POST", body: {} });
window.HuluRealtime?.reset();
state.user = null;
updateUserUI();
if ($("#view-profile").classList.contains("active")) await loadProfile();
showToast("已退出登录");
});
$$("dialog .dialog-close").forEach((button) => button.addEventListener("click", () => button.closest("dialog").close()));
@@ -947,10 +1269,13 @@ function bindUI() {
$("#register-form").classList.toggle("hidden", button.dataset.authTab !== "register");
$("#auth-error").textContent = "";
}));
$$("#track-switch button").forEach((button) => button.addEventListener("click", () => {
state.track = button.dataset.track;
$$("#track-switch button").forEach((item) => item.classList.toggle("active", item === button));
renderContests();
$$("#match-mode-switch button").forEach((button) => button.addEventListener("click", () => {
state.matchMode = button.dataset.matchMode;
$$("#match-mode-switch button").forEach((item) => {
item.classList.toggle("active", item === button);
});
const label = button.querySelector("b").textContent;
$("#challenge-create").textContent = `创建${label}约战`;
}));
$("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
@@ -958,7 +1283,11 @@ function bindUI() {
state.user = await api("accounts/login/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) });
$("#auth-dialog").close();
updateUserUI();
await loadContent();
await Promise.all([
loadContent(),
loadHomeMatchHistory(),
$("#view-profile").classList.contains("active") ? loadProfile() : null,
]);
showToast("登录成功");
} catch (error) {
$("#auth-error").textContent = error.message;
@@ -970,7 +1299,11 @@ function bindUI() {
state.user = await api("accounts/register/", { method: "POST", body: Object.fromEntries(new FormData(event.target)) });
$("#auth-dialog").close();
updateUserUI();
await loadContent();
await Promise.all([
loadContent(),
loadHomeMatchHistory(),
$("#view-profile").classList.contains("active") ? loadProfile() : null,
]);
showToast("账号和数学档案已建立");
} catch (error) {
$("#auth-error").textContent = error.message;
@@ -980,9 +1313,18 @@ function bindUI() {
async function boot() {
bindUI();
renderRouteMathematicians();
window.HuluToolbox?.init();
window.HuluRealtime?.init();
renderSymbols();
runCalculator();
await Promise.all([loadUser(), loadStories(), loadContests(), loadContent()]);
await Promise.all([
loadUser(),
loadStories(),
loadContests(),
loadContent(),
window.HuluGames?.load(),
]);
renderLatexPreview($("#latex-source").value);
}
boot();
+285
View File
@@ -0,0 +1,285 @@
(function () {
const $game = (selector, root = document) => root.querySelector(selector);
const GAME_META = {
twenty_four: {
title: "24 点",
kicker: "ARITHMETIC PUZZLE",
action: "开始组式",
},
sudoku: {
title: "数独",
kicker: "LOGIC GRID",
action: "开始推理",
},
};
function gameCard(game) {
const meta = GAME_META[game.kind];
const article = document.createElement("article");
article.className = `game-card game-${game.kind}`;
const top = document.createElement("div");
top.className = "game-card-top";
const icon = document.createElement("span");
icon.textContent = game.kind === "sudoku" ? "9×9" : "24";
const time = document.createElement("small");
time.textContent = `${game.estimated_minutes} 分钟`;
top.append(icon, time);
const kicker = document.createElement("b");
kicker.textContent = meta.kicker;
const title = document.createElement("h3");
title.textContent = game.title;
const summary = document.createElement("p");
summary.textContent = game.summary;
const controls = document.createElement("div");
controls.className = "game-card-controls";
const start = document.createElement("button");
start.className = "primary-button";
start.textContent = meta.action;
start.addEventListener("click", () => startGame(game.kind));
controls.append(start);
article.append(top, kicker, title, summary, controls);
return article;
}
async function loadGames() {
const root = $game("#math-game-list");
try {
const games = await api("contests/games/");
root.replaceChildren(...games.map(gameCard));
} catch (error) {
root.textContent = error.message;
}
}
async function startGame(kind) {
if (!requireAuth()) return;
try {
const attempt = await api(`contests/games/${kind}/start/`, {
method: "POST",
body: {},
});
renderGame(attempt);
$game("#experience-dialog").showModal();
} catch (error) {
showToast(error.message);
}
}
function gameHeading(attempt) {
const fragment = document.createDocumentFragment();
const label = document.createElement("span");
label.className = "kicker";
label.textContent = GAME_META[attempt.kind].kicker;
const title = document.createElement("h2");
title.textContent = GAME_META[attempt.kind].title;
fragment.append(label, title);
return fragment;
}
function resultPanel(attempt) {
const panel = document.createElement("div");
panel.className = "game-result-panel";
const score = document.createElement("strong");
score.textContent = `${attempt.score}`;
const details = document.createElement("p");
details.textContent =
`用时 ${(attempt.duration_ms / 1000).toFixed(1)} 秒 · 提示 ${attempt.hints_used}`;
panel.append(score, details);
return panel;
}
function renderGame(attempt) {
if (attempt.kind === "sudoku") renderSudoku(attempt);
else renderTwentyFour(attempt);
}
function renderTwentyFour(attempt) {
const root = $game("#experience-content");
root.replaceChildren();
root.append(gameHeading(attempt));
if (attempt.status === "completed") {
root.append(resultPanel(attempt));
return;
}
const instruction = document.createElement("p");
instruction.className = "scene";
instruction.textContent = "四个数字必须各使用一次,只允许 +、−、×、÷ 和括号。";
const numbers = document.createElement("div");
numbers.className = "twenty-four-numbers";
attempt.puzzle.numbers.forEach((number) => {
const tile = document.createElement("button");
tile.type = "button";
tile.textContent = number;
tile.addEventListener("click", () => {
input.value += String(number);
input.focus();
});
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.inputMode = "text";
input.spellcheck = false;
const errorMessage = document.createElement("p");
errorMessage.className = "form-error game-form-error";
errorMessage.setAttribute("role", "alert");
input.addEventListener("input", () => {
errorMessage.textContent = "";
});
const keypad = document.createElement("div");
keypad.className = "game-keypad";
["+", "-", "*", "/", "(", ")"].forEach((operator) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = operator;
button.addEventListener("click", () => {
input.value += operator;
input.focus();
});
keypad.append(button);
});
const submit = document.createElement("button");
submit.className = "primary-button";
submit.type = "submit";
submit.textContent = "验证并计分";
form.append(input, keypad, errorMessage, submit);
form.addEventListener("submit", async (event) => {
event.preventDefault();
submit.disabled = true;
errorMessage.textContent = "";
try {
const result = await api(
`contests/games/attempts/${attempt.attempt_id}/submit/`,
{
method: "POST",
headers: { "Idempotency-Key": createIdempotencyKey() },
body: { expression: input.value },
},
);
renderTwentyFour(result);
showToast("得到 24,成绩已记录");
} catch (error) {
errorMessage.textContent = error.message;
showToast(error.message);
submit.disabled = false;
}
});
root.append(instruction, numbers, form);
window.setTimeout(() => input.focus(), 50);
}
function sudokuCell(given, value, row, 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 = value || "";
input.className = given ? "given" : "";
input.readOnly = given;
input.setAttribute("aria-label", `${row + 1} 行第 ${column + 1}`);
input.addEventListener("input", () => {
input.value = input.value.replace(/[^1-9]/g, "").slice(0, 1);
});
return input;
}
function renderSudoku(attempt) {
const root = $game("#experience-content");
root.replaceChildren();
root.append(gameHeading(attempt));
if (attempt.status === "completed") {
root.append(resultPanel(attempt));
return;
}
const instruction = document.createElement("p");
instruction.className = "scene";
instruction.textContent = "每一行、每一列和每个 3×3 九宫格都要包含 1 到 9。";
const board = document.createElement("div");
board.className = "sudoku-board";
const hints = new Map(
(attempt.puzzle.hints || []).map((item) => [`${item.row}-${item.column}`, item.value])
);
attempt.puzzle.grid.forEach((rowValues, row) => {
rowValues.forEach((givenValue, column) => {
const hintValue = hints.get(`${row}-${column}`) || 0;
const input = sudokuCell(Boolean(givenValue), givenValue || hintValue, row, column);
if (hintValue) {
input.readOnly = true;
input.classList.add("hint");
}
board.append(input);
});
});
const actions = document.createElement("div");
actions.className = "sudoku-actions";
const hint = document.createElement("button");
hint.type = "button";
hint.className = "ghost-button";
hint.textContent = `提示(已用 ${attempt.hints_used}/3`;
hint.disabled = attempt.hints_used >= 3;
const submit = document.createElement("button");
submit.type = "button";
submit.className = "primary-button";
submit.textContent = "检查并完成";
hint.addEventListener("click", async () => {
hint.disabled = true;
try {
const result = await api(
`contests/games/attempts/${attempt.attempt_id}/hint/`,
{ method: "POST", body: {} },
);
const input = $game(
`[data-row="${result.row}"][data-column="${result.column}"]`,
board,
);
input.value = result.value;
input.readOnly = true;
input.classList.add("hint");
attempt.hints_used = result.hints_used;
hint.textContent = `提示(已用 ${result.hints_used}/3`;
hint.disabled = result.hints_used >= 3;
} catch (error) {
showToast(error.message);
hint.disabled = false;
}
});
submit.addEventListener("click", async () => {
submit.disabled = true;
const grid = Array.from({ length: 9 }, () => Array(9).fill(0));
$gameAll("input", board).forEach((input) => {
grid[Number(input.dataset.row)][Number(input.dataset.column)] =
Number(input.value || 0);
});
try {
const result = await api(
`contests/games/attempts/${attempt.attempt_id}/submit/`,
{
method: "POST",
headers: { "Idempotency-Key": createIdempotencyKey() },
body: { grid },
},
);
renderSudoku(result);
showToast("数独完成,成绩已记录");
} catch (error) {
showToast(error.message);
submit.disabled = false;
}
});
actions.append(hint, submit);
root.append(instruction, board, actions);
}
function $gameAll(selector, root = document) {
return [...root.querySelectorAll(selector)];
}
window.HuluGames = { load: loadGames };
})();
+525
View File
@@ -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 };
})();
+617
View File
@@ -0,0 +1,617 @@
(function () {
const realtime = {
match: null,
socket: null,
pollTimer: null,
clockTimer: null,
opponentProgress: 0,
reconnectTimer: null,
};
const GAME_LABELS = {
quiz: "口算竞速",
sudoku: "数独 Timerun",
twenty_four: "24 点竞速",
};
function stopTimers() {
if (realtime.pollTimer) window.clearInterval(realtime.pollTimer);
if (realtime.clockTimer) window.clearInterval(realtime.clockTimer);
realtime.pollTimer = null;
realtime.clockTimer = null;
}
function closeSocket() {
if (realtime.reconnectTimer) window.clearTimeout(realtime.reconnectTimer);
realtime.reconnectTimer = null;
if (realtime.socket) {
realtime.socket.onclose = null;
realtime.socket.close();
}
realtime.socket = null;
}
function websocketUrl(path) {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}${path}`;
}
function connectSocket() {
closeSocket();
if (!realtime.match?.websocket_path) return;
const socket = new WebSocket(websocketUrl(realtime.match.websocket_path));
realtime.socket = socket;
socket.addEventListener("open", () => {
updateConnectionStatus("实时连接已建立");
socket.send(JSON.stringify({ type: "ping" }));
});
socket.addEventListener("message", async (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "state") {
await refreshMatch();
} else if (
message.type === "progress" &&
message.user_id !== String(state.user?.id)
) {
realtime.opponentProgress = message.answered_count;
updateProgressUI();
}
});
socket.addEventListener("close", () => {
updateConnectionStatus("实时连接中断,正在使用轮询");
if (["waiting", "active"].includes(realtime.match?.status)) {
realtime.reconnectTimer = window.setTimeout(connectSocket, 2000);
}
});
socket.addEventListener("error", () => {
updateConnectionStatus("WebSocket 不可用,轮询仍在工作");
});
}
function updateConnectionStatus(message) {
const node = document.querySelector("#realtime-connection");
if (node) node.textContent = message;
}
async function refreshMatch() {
if (!realtime.match?.match_id) return;
try {
const match = await api(`contests/matches/${realtime.match.match_id}/`);
const previous = realtime.match;
const previousStatus = previous.status;
realtime.match = match;
const shouldRender =
previous.status !== match.status ||
previous.attempt?.status !== match.attempt?.status;
if (shouldRender) renderMatch();
else {
updateClock();
if (
previous.opponent?.status !== match.opponent?.status &&
match.opponent?.status &&
match.opponent.status !== "active"
) {
realtime.opponentProgress = matchProgressTotal(match);
updateProgressUI();
updateConnectionStatus("对手已提交,完成后将立即结算");
}
}
if (previousStatus === "waiting" && match.status === "active") {
showToast(`已匹配到 ${match.opponent.nickname}`);
}
if (!["waiting", "active"].includes(match.status)) {
stopTimers();
closeSocket();
}
} catch (error) {
if (error.status === 404) {
stopTimers();
closeSocket();
}
}
}
function startPolling() {
stopTimers();
realtime.pollTimer = window.setInterval(refreshMatch, 2000);
realtime.clockTimer = window.setInterval(updateClock, 250);
}
function openMatch(match) {
realtime.match = match;
realtime.opponentProgress = 0;
renderMatch();
const dialog = document.querySelector("#experience-dialog");
if (!dialog.open) dialog.showModal();
connectSocket();
if (["waiting", "active"].includes(match.status)) startPolling();
}
function header(root, match) {
const label = document.createElement("span");
label.className = "kicker";
label.textContent =
`${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · ${GAME_LABELS[match.game_kind] || "实时竞技"}`;
const title = document.createElement("h2");
title.textContent = match.contest;
const status = document.createElement("div");
status.className = "realtime-status-line";
const connection = document.createElement("span");
connection.id = "realtime-connection";
connection.textContent = "正在建立实时连接…";
const clock = document.createElement("strong");
clock.id = "realtime-clock";
status.append(connection, clock);
root.append(label, title, status);
}
function updateClock() {
const clock = document.querySelector("#realtime-clock");
if (!clock || !realtime.match) return;
if (realtime.match.status === "waiting") {
const expiresAt = new Date(realtime.match.expires_at).getTime();
const seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
clock.textContent = `联机码 ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} 后失效`;
return;
}
if (realtime.match.status === "active") {
const startedAt = new Date(realtime.match.started_at).getTime();
const elapsed = (Date.now() - startedAt) / 1000;
const remaining = Math.max(0, Math.ceil(realtime.match.duration_seconds - elapsed));
clock.textContent = `剩余 ${remaining}`;
if (remaining === 0) refreshMatch();
return;
}
clock.textContent = "";
}
async function copyCode(code) {
try {
await navigator.clipboard.writeText(code);
showToast(`联机码 ${code} 已复制`);
} catch {
showToast(`联机码:${code}`);
}
}
function renderWaiting(root, match) {
const panel = document.createElement("div");
panel.className = "realtime-waiting";
const pulse = document.createElement("span");
pulse.className = "realtime-pulse";
const message = document.createElement("p");
message.textContent =
match.match_type === "challenge"
? "把联机码发给朋友。对方加入后会自动开始,无需再次点击。"
: `正在寻找${GAME_LABELS[match.game_kind] || "同玩法"}对手。`;
panel.append(pulse, message);
if (match.challenge_code) {
const code = document.createElement("button");
code.className = "challenge-code";
code.textContent = match.challenge_code;
code.title = "点击复制联机码";
code.addEventListener("click", () => copyCode(match.challenge_code));
panel.append(code);
}
if (match.is_owner) {
const cancel = document.createElement("button");
cancel.className = "ghost-button";
cancel.textContent = "取消等待";
cancel.addEventListener("click", async () => {
cancel.disabled = true;
try {
realtime.match = await api(`contests/matches/${match.match_id}/cancel/`, {
method: "POST",
body: {},
});
renderMatch();
stopTimers();
closeSocket();
} catch (error) {
showToast(error.message);
cancel.disabled = false;
}
});
panel.append(cancel);
}
root.append(panel);
updateClock();
}
function matchProgressTotal(match) {
return (
match.game_kind === "sudoku"
? 81
: match.game_kind === "twenty_four"
? 1
: match.attempt?.questions.length || 0
);
}
function progressPanel(root, match) {
const total = matchProgressTotal(match);
const panel = document.createElement("div");
panel.className = "realtime-progress-panel";
const self = document.createElement("div");
self.innerHTML = `<span>你</span><b id="self-progress">0 / ${total}</b>`;
const opponent = document.createElement("div");
opponent.innerHTML =
`<span>${match.opponent?.nickname || "对手"}</span>` +
`<b id="opponent-progress">${realtime.opponentProgress} / ${total}</b>`;
panel.append(self, opponent);
root.append(panel);
}
function updateProgressUI(selfCount) {
const total = matchProgressTotal(realtime.match);
if (Number.isInteger(selfCount)) {
const self = document.querySelector("#self-progress");
if (self && realtime.match?.attempt) {
self.textContent = `${selfCount} / ${total}`;
}
}
const opponent = document.querySelector("#opponent-progress");
if (opponent && realtime.match?.attempt) {
opponent.textContent = `${realtime.opponentProgress} / ${total}`;
}
}
function sendProgress(answeredCount) {
if (realtime.socket?.readyState === WebSocket.OPEN) {
realtime.socket.send(
JSON.stringify({ type: "progress", answered_count: answeredCount })
);
}
}
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) {
progressPanel(root, match);
const attempt = match.attempt;
if (attempt.status !== "active") {
const waiting = document.createElement("div");
waiting.className = "realtime-submitted";
waiting.innerHTML =
"<strong>答案已锁定</strong><p>等待对手提交。双方完成后才会公开答案和 Rating 变化。</p>";
root.append(waiting);
updateClock();
return;
}
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");
form.className = "choice-list realtime-answer-form";
attempt.questions.forEach((question) => {
const field = document.createElement("label");
field.textContent = `${question.order}. ${question.prompt}`;
const input = document.createElement("input");
input.name = String(question.order);
input.inputMode = "decimal";
input.autocomplete = "off";
input.addEventListener("input", () => {
const answered = [...form.querySelectorAll("input")].filter(
(item) => item.value.trim()
).length;
updateProgressUI(answered);
sendProgress(answered);
});
field.append(input);
form.append(field);
});
const submit = document.createElement("button");
submit.className = "primary-button";
submit.type = "submit";
submit.textContent = "提交并等待对手";
form.append(submit);
form.addEventListener("submit", async (event) => {
event.preventDefault();
submit.disabled = true;
const data = new FormData(form);
try {
realtime.match = await api(
`contests/attempts/${attempt.attempt_id}/submit/`,
{
method: "POST",
headers: { "Idempotency-Key": createIdempotencyKey() },
body: {
answers: attempt.questions.map((question) => ({
order: question.order,
answer: data.get(String(question.order)) || "",
})),
},
},
);
renderMatch();
} catch (error) {
showToast(error.message);
submit.disabled = false;
}
});
root.append(form);
updateClock();
}
function renderCompleted(root, match) {
const result = document.createElement("section");
result.className = `realtime-result result-${match.result.winner}`;
const outcome = document.createElement("strong");
outcome.textContent =
match.result.winner === "self"
? "获胜"
: match.result.winner === "opponent"
? "本局惜败"
: "平局";
const score = document.createElement("p");
score.textContent =
match.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 sign = match.result.rating_delta > 0 ? "+" : "";
rating.textContent =
`Rating ${sign}${match.result.rating_delta}${match.result.rating_after}`;
result.append(outcome, score, timeLine, rating);
root.append(result);
if (state.user) {
state.user.rating = match.result.rating_after;
updateUserUI();
}
loadUser();
if (match.game_kind === "quiz") {
const review = document.createElement("div");
review.className = "realtime-review";
match.attempt.questions.forEach((question) => {
const item = document.createElement("article");
const title = document.createElement("b");
title.textContent = `${question.order}. ${question.prompt}`;
const answer = document.createElement("p");
answer.textContent =
`你的答案:${question.submitted_answer || "未作答"} · 正确答案:${question.correct_answer}`;
const explanation = document.createElement("small");
explanation.textContent = question.explanation || "";
item.className = question.is_correct ? "correct" : "incorrect";
item.append(title, answer, explanation);
review.append(item);
});
root.append(review);
}
}
function renderMatch() {
const root = document.querySelector("#experience-content");
const match = realtime.match;
root.replaceChildren();
header(root, match);
if (match.status === "waiting") renderWaiting(root, match);
else if (match.status === "active") renderActive(root, match);
else if (match.status === "completed") renderCompleted(root, match);
else {
const message = document.createElement("p");
message.className = "scene";
message.textContent = "这场匹配已取消或联机码已过期。";
root.append(message);
}
}
function currentRealtimeContest() {
const contests = state.contests.filter((contest) => contest.kind === "realtime");
return (
contests.find((contest) => contest.track === "open") ||
contests.find((contest) => contest.track === "standard") ||
contests[0]
);
}
async function startRandom(contest) {
const match = await api(`contests/${contest.slug}/matchmaking/`, {
method: "POST",
body: { game_kind: state.matchMode },
});
openMatch(match);
}
async function createChallenge() {
if (!requireAuth()) return;
const contest = currentRealtimeContest();
if (!contest) {
showToast("当前没有可用的实时比赛");
return;
}
try {
const match = await api(`contests/${contest.slug}/challenges/`, {
method: "POST",
body: { game_kind: state.matchMode },
});
openMatch(match);
} catch (error) {
showToast(error.message);
}
}
async function joinChallenge(event) {
event.preventDefault();
if (!requireAuth()) return;
const input = document.querySelector("#challenge-code-input");
const challengeCode = input.value.trim().toUpperCase();
if (challengeCode.length !== 6) {
showToast("请输入 6 位联机码");
return;
}
try {
const match = await api("contests/challenges/join/", {
method: "POST",
body: { challenge_code: challengeCode },
});
input.value = "";
openMatch(match);
} catch (error) {
showToast(error.message);
}
}
function init() {
document
.querySelector("#challenge-create")
.addEventListener("click", createChallenge);
document
.querySelector("#challenge-join-form")
.addEventListener("submit", joinChallenge);
document
.querySelector("#challenge-code-input")
.addEventListener("input", (event) => {
event.target.value = event.target.value
.toUpperCase()
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, "")
.slice(0, 6);
});
}
function reset() {
stopTimers();
closeSocket();
realtime.match = null;
realtime.opponentProgress = 0;
}
window.HuluRealtime = { init, startRandom, refreshMatch, reset };
})();
+893
View File
@@ -0,0 +1,893 @@
(function () {
const $tool = (selector, root = document) => root.querySelector(selector);
const $$tool = (selector, root = document) => [...root.querySelectorAll(selector)];
const CALC_FIELDS = {
derivative: ["order"],
integral: ["bounds"],
limit: ["point", "direction"],
series: ["point", "order"],
solve_system: ["variables"],
gradient: ["variables"],
hessian: ["variables"],
base: ["base"],
unit: ["unit"],
};
const CALC_OPERATION_META = {
statistics: {
placeholder: "12, 15, 18, 21, 24",
hint: "使用逗号分隔数据,返回四分位数、方差、标准差、极差等统计量。",
},
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;
}
}
updateCalculatorFields();
}
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 {
exact: resultText(result, "exact"),
decimal: resultText(result, "decimal"),
latex: resultText(result, "latex"),
};
}
function updateCalculatorFields() {
const operation = $tool("#calc-operation").value;
const visible = new Set(CALC_FIELDS[operation] || []);
$$tool("[data-calc-field]").forEach((field) => {
field.hidden = !visible.has(field.dataset.calcField);
});
const meta = CALC_OPERATION_META[operation] || {};
$tool("#calc-input").placeholder = meta.placeholder || "例如:sqrt(2) + 1/3";
$tool("#calc-hint").textContent =
meta.hint || "支持精确常量、受限数学函数和变量;按 Command/Ctrl + Enter 运行。";
$tool("#calc-order").max = operation === "derivative" ? 5 : 12;
if (meta.order) $tool("#calc-order").value = meta.order;
else if (operation === "derivative") $tool("#calc-order").value = 1;
}
async function runCalculator() {
const button = $tool("#calc-run");
const operation = $tool("#calc-operation").value;
const expression = $tool("#calc-input").value.trim();
button.disabled = true;
$tool("#calc-history").textContent = "数学内核正在计算…";
try {
const payload = await api("toolbox/calculate/", {
method: "POST",
body: {
operation,
expression,
variable: $tool("#calc-variable").value,
variables: $tool("#calc-variables").value,
order: $tool("#calc-order").value,
lower: $tool("#calc-lower").value,
upper: $tool("#calc-upper").value,
point: $tool("#calc-point").value,
direction: $tool("#calc-direction").value,
from_base: $tool("#calc-from-base").value,
to_base: $tool("#calc-to-base").value,
from_unit: $tool("#calc-from-unit").value,
to_unit: $tool("#calc-to-unit").value,
},
});
const result = formatCalculatorResult(payload.result);
$tool("#calc-history").textContent = `${operation} · ${expression}`;
$tool("#calc-output").textContent = result.exact || "完成";
$tool("#calc-decimal").textContent = result.decimal || result.exact || "—";
$tool("#calc-latex").textContent = result.latex || result.exact || "—";
$tool("#calc-steps").replaceChildren(
...(payload.steps || []).map((step) => {
const item = document.createElement("li");
item.textContent = step;
return item;
})
);
} catch (error) {
$tool("#calc-history").textContent = error.message;
$tool("#calc-output").textContent = "计算失败";
$tool("#calc-decimal").textContent = "—";
$tool("#calc-latex").textContent = "—";
$tool("#calc-steps").replaceChildren();
} finally {
button.disabled = false;
}
}
function canvasPoint(canvas, event) {
const rect = canvas.getBoundingClientRect();
return {
x: ((event.clientX - rect.left) / rect.width) * canvas.width,
y: ((event.clientY - rect.top) / rect.height) * canvas.height,
};
}
function downloadCanvas(canvas, name) {
const link = document.createElement("a");
link.download = `${name}-${new Date().toISOString().slice(0, 10)}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
}
class Whiteboard {
constructor(canvas) {
this.canvas = canvas;
this.context = canvas.getContext("2d");
this.tool = "pen";
this.history = [];
this.drawing = false;
this.start = null;
this.preview = null;
this.onChange = null;
this.reset();
canvas.addEventListener("pointerdown", (event) => this.startDrawing(event));
canvas.addEventListener("pointermove", (event) => this.move(event));
canvas.addEventListener("pointerup", (event) => this.finish(event));
canvas.addEventListener("pointercancel", () => this.cancel());
}
reset() {
this.context.fillStyle = "#ffffff";
this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
snapshot() {
this.history.push(this.canvas.toDataURL("image/png"));
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) {
event.preventDefault();
this.snapshot();
this.drawing = true;
this.start = canvasPoint(this.canvas, event);
if (this.tool === "text") {
const text = $tool("#whiteboard-text").value.trim();
if (!text) {
this.history.pop();
showToast("先输入要放到白板的文字或公式");
} else {
const size = Math.max(18, Number($tool("#whiteboard-size").value) * 5);
this.context.fillStyle = $tool("#whiteboard-color").value;
this.context.font = `${size}px "SFMono-Regular", "PingFang SC", sans-serif`;
this.context.fillText(text, this.start.x, this.start.y);
this.emitSnapshot();
}
this.drawing = false;
return;
}
this.preview = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height);
this.canvas.setPointerCapture(event.pointerId);
this.context.lineCap = "round";
this.context.lineJoin = "round";
this.context.lineWidth = Number($tool("#whiteboard-size").value);
this.context.strokeStyle =
this.tool === "eraser" ? "#ffffff" : $tool("#whiteboard-color").value;
if (this.tool === "pen" || this.tool === "eraser") {
this.context.beginPath();
this.context.moveTo(this.start.x, this.start.y);
}
}
move(event) {
if (!this.drawing) return;
event.preventDefault();
const point = canvasPoint(this.canvas, event);
if (this.tool === "pen" || this.tool === "eraser") {
this.context.lineTo(point.x, point.y);
this.context.stroke();
return;
}
this.context.putImageData(this.preview, 0, 0);
this.context.beginPath();
if (this.tool === "line") {
this.context.moveTo(this.start.x, this.start.y);
this.context.lineTo(point.x, point.y);
} else {
this.context.rect(
this.start.x,
this.start.y,
point.x - this.start.x,
point.y - this.start.y,
);
}
this.context.stroke();
}
finish(event) {
if (!this.drawing) return;
this.move(event);
this.drawing = false;
this.preview = null;
this.emitSnapshot();
}
cancel() {
if (this.preview) this.context.putImageData(this.preview, 0, 0);
this.drawing = false;
}
undo() {
const source = this.history.pop();
if (!source) 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.emitSnapshot();
};
image.src = source;
}
clear() {
this.snapshot();
this.reset();
this.emitSnapshot();
}
addGrid() {
this.snapshot();
const context = this.context;
context.strokeStyle = "#e1e7e1";
context.lineWidth = 1;
for (let x = 0; x <= this.canvas.width; x += 50) {
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, this.canvas.height);
context.stroke();
}
for (let y = 0; y <= this.canvas.height; y += 50) {
context.beginPath();
context.moveTo(0, y);
context.lineTo(this.canvas.width, y);
context.stroke();
}
context.strokeStyle = "#7a877f";
context.lineWidth = 2;
context.beginPath();
context.moveTo(this.canvas.width / 2, 0);
context.lineTo(this.canvas.width / 2, this.canvas.height);
context.moveTo(0, this.canvas.height / 2);
context.lineTo(this.canvas.width, this.canvas.height / 2);
context.stroke();
this.emitSnapshot();
}
addImage(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const image = new Image();
image.onload = () => {
this.snapshot();
const scale = Math.min(
1,
(this.canvas.width * 0.8) / image.width,
(this.canvas.height * 0.8) / image.height,
);
this.context.drawImage(
image,
30,
30,
image.width * scale,
image.height * scale,
);
this.emitSnapshot();
};
image.src = reader.result;
};
reader.readAsDataURL(file);
}
}
class GeometryBoard {
constructor(canvas) {
this.canvas = canvas;
this.context = canvas.getContext("2d");
this.tool = "point";
this.points = [];
this.segments = [];
this.circles = [];
this.history = [];
this.pending = [];
this.dragging = null;
this.onChange = null;
canvas.addEventListener("pointerdown", (event) => this.pointerDown(event));
canvas.addEventListener("pointermove", (event) => this.pointerMove(event));
canvas.addEventListener("pointerup", (event) => this.pointerUp(event));
canvas.addEventListener("pointercancel", () => {
this.dragging = null;
});
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() {
this.history.push(
JSON.stringify({
points: this.points,
segments: this.segments,
circles: this.circles,
})
);
if (this.history.length > 30) this.history.shift();
}
findOrCreate(point) {
const nearest = this.findNearest(point);
if (nearest >= 0) return nearest;
this.points.push(point);
return this.points.length - 1;
}
findNearest(point) {
let nearest = -1;
let distance = 28;
this.points.forEach((item, index) => {
const current = Math.hypot(item.x - point.x, item.y - point.y);
if (current < distance) {
nearest = index;
distance = current;
}
});
return nearest;
}
pointerDown(event) {
if (this.tool !== "move") return;
event.preventDefault();
const nearest = this.findNearest(canvasPoint(this.canvas, event));
if (nearest < 0 || this.points[nearest].derived) return;
this.save();
this.dragging = nearest;
this.canvas.setPointerCapture(event.pointerId);
}
pointerMove(event) {
if (this.dragging === null) return;
event.preventDefault();
const point = canvasPoint(this.canvas, event);
this.points[this.dragging] = point;
this.draw();
}
pointerUp(event) {
if (this.tool === "move") {
this.pointerMove(event);
this.dragging = null;
this.emitState();
return;
}
this.handlePoint(event);
}
handlePoint(event) {
event.preventDefault();
const point = canvasPoint(this.canvas, event);
this.save();
if (this.tool === "point") {
this.findOrCreate(point);
} else {
this.pending.push(this.findOrCreate(point));
if (this.pending.length === 2) {
const [first, second] = this.pending;
if (first !== second && this.tool === "segment") {
this.segments.push({ first, second });
} else if (first !== second && this.tool === "circle") {
this.circles.push({ center: first, edge: second });
} else if (first !== second && this.tool === "midpoint") {
const a = this.points[first];
const b = this.points[second];
this.points.push({
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2,
derived: true,
parents: [first, second],
});
this.segments.push({ first, second, guide: true });
}
this.pending = [];
}
}
this.draw();
this.emitState();
$tool("#geometry-hint").textContent = this.pending.length
? "再选择一个点完成构造。"
: "可继续创建或切换构造工具。";
}
draw() {
const context = this.context;
const width = this.canvas.width;
const height = this.canvas.height;
this.points.forEach((point) => {
if (!point.derived || !point.parents) return;
const first = this.points[point.parents[0]];
const second = this.points[point.parents[1]];
point.x = (first.x + second.x) / 2;
point.y = (first.y + second.y) / 2;
});
context.fillStyle = "#fbfbf7";
context.fillRect(0, 0, width, height);
context.strokeStyle = "#e7e8e2";
context.lineWidth = 1;
for (let x = 0; x <= width; x += 50) {
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
}
for (let y = 0; y <= height; y += 50) {
context.beginPath();
context.moveTo(0, y);
context.lineTo(width, y);
context.stroke();
}
this.segments.forEach((segment) => {
const first = this.points[segment.first];
const second = this.points[segment.second];
context.strokeStyle = segment.guide ? "#9ca3af" : "#196548";
context.setLineDash(segment.guide ? [8, 6] : []);
context.lineWidth = 3;
context.beginPath();
context.moveTo(first.x, first.y);
context.lineTo(second.x, second.y);
context.stroke();
context.setLineDash([]);
if ($tool("#geometry-labels").checked) {
context.fillStyle = "#47534c";
context.font = "18px sans-serif";
context.fillText(
Math.hypot(first.x - second.x, first.y - second.y).toFixed(1),
(first.x + second.x) / 2 + 8,
(first.y + second.y) / 2 - 8,
);
}
});
this.circles.forEach((circle) => {
const center = this.points[circle.center];
const edge = this.points[circle.edge];
const radius = Math.hypot(center.x - edge.x, center.y - edge.y);
context.strokeStyle = "#5d73e8";
context.lineWidth = 3;
context.beginPath();
context.arc(center.x, center.y, radius, 0, Math.PI * 2);
context.stroke();
});
this.points.forEach((point, index) => {
context.fillStyle = point.derived ? "#d86f45" : "#17211b";
context.beginPath();
context.arc(point.x, point.y, 7, 0, Math.PI * 2);
context.fill();
if ($tool("#geometry-labels").checked) {
context.fillStyle = "#17211b";
context.font = "18px sans-serif";
context.fillText(
`${String.fromCharCode(65 + (index % 26))} (${Math.round(point.x)}, ${Math.round(point.y)})`,
point.x + 10,
point.y - 10,
);
}
});
}
undo() {
const state = this.history.pop();
if (!state) return;
const parsed = JSON.parse(state);
this.points = parsed.points;
this.segments = parsed.segments;
this.circles = parsed.circles;
this.pending = [];
this.draw();
this.emitState();
}
clear() {
this.save();
this.points = [];
this.segments = [];
this.circles = [];
this.pending = [];
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 geometry;
let boardRealtime;
function initDrawingTools() {
whiteboard = new Whiteboard($tool("#whiteboard-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) => {
button.addEventListener("click", () => {
whiteboard.tool = button.dataset.whiteboardTool;
$$tool("[data-whiteboard-tool]").forEach((item) =>
item.classList.toggle("active", item === button)
);
});
});
$tool("#whiteboard-undo").addEventListener("click", () => whiteboard.undo());
$tool("#whiteboard-clear").addEventListener("click", () => whiteboard.clear());
$tool("#whiteboard-grid").addEventListener("click", () => whiteboard.addGrid());
$tool("#whiteboard-image").addEventListener("change", (event) => {
whiteboard.addImage(event.target.files[0]);
event.target.value = "";
});
$tool("#whiteboard-export").addEventListener("click", () =>
downloadCanvas(whiteboard.canvas, "hulumath-whiteboard")
);
$$tool("[data-geometry-tool]").forEach((button) => {
button.addEventListener("click", () => {
geometry.tool = button.dataset.geometryTool;
geometry.pending = [];
$$tool("[data-geometry-tool]").forEach((item) =>
item.classList.toggle("active", item === button)
);
});
});
$tool("#geometry-labels").addEventListener("change", () => geometry.draw());
$tool("#geometry-undo").addEventListener("click", () => geometry.undo());
$tool("#geometry-clear").addEventListener("click", () => geometry.clear());
$tool("#geometry-export").addEventListener("click", () =>
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() {
$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-input").addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") runCalculator();
});
$$tool("[data-calc-example]").forEach((button) => {
button.addEventListener("click", () => {
$tool("#calc-operation").value = button.dataset.calcOperation;
$tool("#calc-input").value = button.dataset.calcExample;
setCalculatorCategory(
calculatorCategoryFor(button.dataset.calcOperation),
false,
);
runCalculator();
});
});
initDrawingTools();
setCalculatorCategory("algebra", false);
window.HuluGraph?.init();
}
function activate(tool) {
if (tool === "graph") window.setTimeout(() => window.HuluGraph?.resize(), 30);
if (tool === "whiteboard") window.setTimeout(() => geometry.draw(), 30);
}
window.HuluToolbox = { init, activate, runCalculator };
})();
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

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