8 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
60 changed files with 4305 additions and 553 deletions
+1
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
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: install migrate seed run run-asgi test check
.PHONY: install migrate seed local-admin run run-asgi test check
install:
python3 -m venv .venv
@@ -11,6 +11,9 @@ seed:
cd backend && ../.venv/bin/python manage.py seed_initial_content
cd backend && ../.venv/bin/python manage.py seed_contests
local-admin:
cd backend && ../.venv/bin/python manage.py init_local_admin
run:
cd backend && ../.venv/bin/python manage.py runserver
+17 -3
View File
@@ -2,6 +2,9 @@
面向全年龄数学兴趣用户的“数学人生宇宙”。当前仓库包含可运行的 Django 模块化单体、响应式 Web 客户端、运营后台、内容种子、实时比赛基础设施和生产部署配置。
当前发布版本:**v1.2.1**。变更与迁移说明见
[v1.2.1 Release Notes](docs/RELEASE_NOTES_V1.2.1.md)。
## 参与贡献
提交代码、内容或文档前,请先完整阅读 [代码贡献指南](CONTRIBUTING.md)。
@@ -14,13 +17,14 @@
- 12 题 MathBTI、16 种数学人格、人物卡与数学精灵初始化
- 统一版本化剧情引擎、85 节点信仰者主线、2 个人物 Skill 样板
- 剧情服务端存档、嵌套资源效果、结局与幂等选择
- 入门、标准、进阶三赛道的实时 1v1今日挑战单人闯关、24 点和数独
- 统一玩家池的口算、数独 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 和自动化测试
@@ -39,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
```
## 测试
+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()
+113
View File
@@ -61,9 +61,16 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在():
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
@@ -74,6 +81,51 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在():
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")
@@ -94,6 +146,27 @@ def test_latex_默认源码可渲染且_katex_静态资源完整():
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(
@@ -122,3 +195,43 @@ def test_realtime_match_联机码与_websocket_前端资源存在():
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,
]
)
),
+1
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"
+4 -2
View File
@@ -34,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)
@@ -72,13 +72,14 @@ class RealtimeMatchAdmin(admin.ModelAdmin):
"id",
"contest",
"match_type",
"game_kind",
"challenge_code",
"player_one",
"player_two",
"status",
"created_at",
)
list_filter = ("match_type", "status", "contest__track")
list_filter = ("match_type", "game_kind", "status", "contest__track")
search_fields = (
"challenge_code",
"player_one__username",
@@ -94,6 +95,7 @@ class MathGameAttemptAdmin(admin.ModelAdmin):
"id",
"user",
"kind",
"match",
"difficulty",
"status",
"score",
+14 -4
View File
@@ -57,6 +57,7 @@ def _grid_from_text(value):
def game_payload(attempt):
return {
"attempt_id": attempt.id,
"match_id": attempt.match_id,
"kind": attempt.kind,
"difficulty": attempt.difficulty,
"status": attempt.status,
@@ -68,7 +69,7 @@ def game_payload(attempt):
}
def start_game(user, kind, difficulty):
def build_game(kind, difficulty=MathGameAttempt.Difficulty.STANDARD):
if kind not in MathGameAttempt.Kind.values:
raise ValidationError({"kind": "不支持的数学玩法"})
if difficulty not in MathGameAttempt.Difficulty.values:
@@ -82,6 +83,11 @@ def start_game(user, kind, 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,
@@ -164,10 +170,14 @@ def _validate_sudoku_grid(raw_grid, puzzle, solution):
@transaction.atomic
def submit_game(user, attempt_id, submission, submission_key):
attempt = MathGameAttempt.objects.select_for_update().get(id=attempt_id, user=user)
if attempt.status == MathGameAttempt.Status.COMPLETED:
if submission_key and attempt.submission_key == submission_key:
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("这局游戏已经完成")
raise ValidationError("这局游戏已经结束")
if not submission_key:
raise ValidationError({"Idempotency-Key": "提交必须提供幂等键"})
@@ -1,9 +1,9 @@
import random
from django.core.management.base import BaseCommand
from contest.models import Contest, ContestQuestion, Question, QuestionVersion
import random
def _generate_beginner():
"""入门:100以内加减乘除,约 400 题。"""
@@ -117,6 +117,21 @@ def _generate_advanced():
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]
@@ -195,7 +210,6 @@ class Command(BaseCommand):
ContestQuestion.objects.filter(contest=contest).delete()
pool = list(versions[track])
random.shuffle(pool)
selected = pool[:8]
ContestQuestion.objects.bulk_create(
[
ContestQuestion(
@@ -204,7 +218,7 @@ class Command(BaseCommand):
order=index,
points=100,
)
for index, version in enumerate(selected, start=1)
for index, version in enumerate(pool, start=1)
]
)
total = sum(map(len, QUESTIONS.values()))
@@ -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,
),
]
+25 -2
View File
@@ -87,6 +87,11 @@ class RealtimeMatch(models.Model):
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(
@@ -95,6 +100,11 @@ class RealtimeMatch(models.Model):
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"
)
@@ -126,11 +136,12 @@ class RealtimeMatch(models.Model):
fields=(
"contest",
"match_type",
"game_kind",
"status",
"player_one_rating",
"created_at",
),
name="matchmaking_lookup_idx",
name="matchmaking_mode_idx",
)
]
@@ -152,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)
@@ -232,6 +244,13 @@ class MathGameAttempt(models.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,
@@ -254,7 +273,11 @@ class MathGameAttempt(models.Model):
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(
+243 -70
View File
@@ -16,12 +16,14 @@ 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):
@@ -54,6 +56,13 @@ def _validate_realtime_contest(contest):
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())
@@ -71,13 +80,13 @@ def _active_match_for(user):
)
def _cancel_other_waiting_matches(user, match_type):
def _cancel_other_waiting_matches(user, match_type, game_kind):
matches = list(
RealtimeMatch.objects.filter(
player_one=user,
status=RealtimeMatch.Status.WAITING,
)
.exclude(match_type=match_type)
.exclude(match_type=match_type, game_kind=game_kind)
.values_list("id", flat=True)
)
if matches:
@@ -88,7 +97,29 @@ def _cancel_other_waiting_matches(user, match_type):
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
@@ -97,13 +128,49 @@ def _activate_match(match, user):
match.save(
update_fields=["player_two", "player_two_rating", "status", "started_at"]
)
ContestAttempt.objects.bulk_create(
[
ContestAttempt(contest=match.contest, user=match.player_one, match=match),
ContestAttempt(contest=match.contest, user=user, match=match),
]
)
match.attempts.update(started_at=now)
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
@@ -119,9 +186,9 @@ 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,
@@ -169,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)
@@ -193,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 = {}
@@ -209,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
)
@@ -260,20 +329,26 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key):
@transaction.atomic
def find_match(user, contest):
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:
if active.contest_id == contest.id and active.game_kind == game_kind:
return active
raise ValidationError("你已有一场进行中的实时比赛")
_cancel_other_waiting_matches(user, RealtimeMatch.MatchType.RANDOM)
_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")
@@ -287,6 +362,7 @@ def find_match(user, contest):
.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),
@@ -300,6 +376,7 @@ def find_match(user, contest):
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,
@@ -309,18 +386,24 @@ def find_match(user, contest):
@transaction.atomic
def create_challenge(user, contest):
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)
_cancel_other_waiting_matches(
user,
RealtimeMatch.MatchType.CHALLENGE,
game_kind,
)
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(),
)
@@ -332,6 +415,7 @@ def create_challenge(user, contest):
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,
@@ -396,11 +480,19 @@ def cancel_waiting_match(user, match_id):
def match_payload(match, user):
from .game_services import game_payload
reveal_results = match.status == RealtimeMatch.Status.COMPLETED
attempts = {
attempt.user_id: attempt
for attempt in match.attempts.select_related("user", "contest").all()
}
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
@@ -409,9 +501,18 @@ def match_payload(match, user):
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
@@ -420,7 +521,11 @@ def match_payload(match, user):
else None
),
"status": match.status,
"contest": match.contest.title,
"contest": (
match.contest.title
if match.game_kind == RealtimeMatch.GameKind.QUIZ
else match.get_game_kind_display()
),
"duration_seconds": match.contest.duration_seconds,
"expires_at": match.expires_at,
"started_at": match.started_at,
@@ -432,7 +537,9 @@ def match_payload(match, user):
"score": opponent_attempt.score if reveal_results and opponent_attempt else None,
"correct_count": (
opponent_attempt.correct_count
if reveal_results and opponent_attempt
if reveal_results
and opponent_attempt
and match.game_kind == RealtimeMatch.GameKind.QUIZ
else None
),
"duration_ms": (
@@ -444,11 +551,7 @@ def match_payload(match, user):
if opponent
else None
),
"attempt": (
attempt_payload(attempt, include_results=reveal_results)
if attempt
else None
),
"attempt": attempt_data,
"result": (
{
"winner": (
@@ -487,13 +590,34 @@ def refresh_match_state(match_id):
deadline = match.started_at + timedelta(seconds=match.contest.duration_seconds)
if timezone.now() >= deadline:
now = timezone.now()
for attempt in match.attempts.filter(status=ContestAttempt.Status.ACTIVE):
elapsed_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000))
attempt.status = ContestAttempt.Status.EXPIRED
attempt.duration_ms = elapsed_ms
attempt.submitted_at = now
attempt.save(update_fields=["status", "duration_ms", "submitted_at"])
match = finalize_match(match.id)
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
@@ -502,36 +626,7 @@ def _elo_delta(rating, opponent_rating, score, k=32):
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:
diff = first.duration_ms - second.duration_ms
if abs(diff) <= 100:
first_result = second_result = 0.5
elif diff < 0:
first_result, second_result = 1.0, 0.0
match.winner_id = first.user_id
else:
first_result, second_result = 0.0, 1.0
match.winner_id = second.user_id
def _settle_match(match, first_result, second_result, winner_id):
users = {
user.id: user
for user in User.objects.select_for_update().filter(
@@ -555,8 +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,
)
+1
View File
@@ -174,4 +174,5 @@ def test_math_game_api_目录公开但开局需要登录(client, game_user):
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()
+51
View File
@@ -203,3 +203,54 @@ def test_challenge_api_已使用联机码返回具体原因(realtime_api_setup):
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
+119
View File
@@ -5,10 +5,12 @@ 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,
@@ -17,6 +19,7 @@ from contest.models import (
from contest.services import (
cancel_waiting_match,
create_challenge,
finalize_game_match,
finalize_match,
find_match,
join_challenge,
@@ -190,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)
@@ -360,3 +378,104 @@ def test_challenge_owner_可取消等待中的联机码(realtime_contest):
),
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
+23 -3
View File
@@ -8,9 +8,11 @@ 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,
@@ -64,7 +66,11 @@ class AttemptSubmitView(APIView):
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)
@@ -83,7 +89,11 @@ class MatchStateView(APIView):
class ChallengeCreateView(APIView):
def post(self, request, slug):
contest = get_object_or_404(Contest, slug=slug)
match = create_challenge(request.user, contest)
match = create_challenge(
request.user,
contest,
request.data.get("game_kind", RealtimeMatch.GameKind.QUIZ),
)
return Response(match_payload(match, request.user), status=status.HTTP_201_CREATED)
@@ -158,19 +168,29 @@ class MathGameStartView(APIView):
payload = start_game(
request.user,
kind,
request.data.get("difficulty", MathGameAttempt.Difficulty.STANDARD),
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)
+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 = {
"人工智能": "🤖",
@@ -157,12 +158,15 @@ 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))
@@ -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"]
+62
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": {
@@ -52,5 +103,16 @@ class ProgressionProfileView(APIView):
}
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")
],
}
)
+73 -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,6 +134,10 @@ 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; }
@@ -150,7 +156,7 @@ button { color: inherit; }
.game-card-top { display: flex; justify-content: space-between; align-items: start; }.game-card-top > span { display: grid; place-items: center; width: 58px; height: 58px; border-radius: 16px; background: var(--ink); color: var(--lime); font: 700 20px Georgia, serif; }.game-card-top small { color: var(--muted); }
.game-card > 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 { aspect-ratio: 1; border: 1px solid var(--line); border-radius: 18px; background: var(--ink); color: var(--lime); font: 36px Georgia, serif; cursor: pointer; }
.twenty-four-numbers { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0; }.twenty-four-numbers button, .twenty-four-numbers > span { display: grid; place-items: center; aspect-ratio: 1; border: 1px solid var(--line); border-radius: 18px; background: var(--ink); color: var(--lime); font: 36px Georgia, serif; cursor: pointer; }
.twenty-four-form { display: grid; gap: 12px; }.game-keypad { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }.game-keypad button { border: 1px solid var(--line); border-radius: 9px; padding: 10px; background: white; cursor: pointer; }
.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; }
@@ -162,21 +168,35 @@ button { color: inherit; }
.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-x: auto; overflow-y: hidden; padding: 18px 4px; font-size: 28px; line-height: 1.6; }
.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: flex-end; }
.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; }
.profile-subtitle { margin: 30px 0 12px; font: 24px Georgia, serif; }.profile-game-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 9px; }.profile-game-list > div { padding: 13px; border-radius: 11px; background: #eef1eb; }.profile-game-list b, .profile-game-list span { display: block; }.profile-game-list span { margin-top: 5px; color: var(--muted); font-size: 11px; }
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 22px; }
.profile-mark-list { display: flex; flex-wrap: wrap; gap: 8px; }.profile-mark-list span { border: 1px solid rgba(25,101,72,.2); border-radius: 99px; padding: 7px 10px; background: rgba(204,232,91,.12); color: var(--green); font-size: 10px; }
.home-match-history[hidden] { display: none; }
.match-history-list { display: grid; gap: 10px; }
.match-history-item { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 18px; border: 1px solid var(--line); border-left: 4px solid var(--green); border-radius: 13px; background: rgba(255,255,252,.8); }
.match-history-item.result-loss { border-left-color: #c05245; }.match-history-item.result-draw { border-left-color: #9a8455; }
.match-history-item > div, .match-history-item b, .match-history-item span, .match-history-item strong { display: block; }
.match-history-item > div:last-child { text-align: right; }.match-history-item span { margin-top: 5px; color: var(--muted); font-size: 11px; }.match-history-item strong { color: var(--green); }
.tool-groups { display: grid; grid-template-columns: 1.15fr 1fr .78fr; gap: 14px; margin-bottom: 24px; align-items: stretch; }
.tool-group { min-width: 0; padding: 18px; border: 1px solid var(--line); border-radius: 20px; background: rgba(255,255,252,.52); }
.tool-group > header { display: flex; align-items: center; gap: 12px; min-height: 52px; margin-bottom: 14px; }
.tool-group > header > span { display: grid; place-items: center; width: 35px; height: 35px; flex: 0 0 35px; border-radius: 11px; background: var(--ink); color: var(--lime); font: 11px Georgia, serif; }
.tool-group > header b, .tool-group > header small { display: block; }.tool-group > header b { font: 18px Georgia, serif; }.tool-group > header small { margin-top: 4px; color: var(--muted); font-size: 10px; }
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 22px; }
.tool-group .tool-grid { height: calc(100% - 66px); margin-bottom: 0; }
.tool-card {
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; }
@@ -188,28 +208,54 @@ button { color: inherit; }
.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: 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-controls textarea { min-height: 100px; resize: vertical; font: 13px/1.6 "SFMono-Regular", Consolas, monospace; }
.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-analysis { padding: 11px; border-radius: 10px; background: #eef1eb; color: var(--muted); font-size: 11px; line-height: 1.6; }
#graph-canvas { width: 100%; height: auto; border: 1px solid var(--line); border-radius: 17px; background: #fbfbf7; }
.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; }
@@ -254,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); }
@@ -269,7 +325,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
.app-shell { grid-template-columns: 80px 1fr; }.sidebar { padding: 25px 13px; }.brand span:last-child, .nav-item:not(.active) span, .nav-item { font-size: 0; }
.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; }.calculator-controls { grid-template-columns: repeat(2, 1fr); }
.tool-groups, .graph-shell { grid-template-columns: 1fr; }.tool-group .tool-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.tool-grid { grid-template-columns: repeat(3, 1fr); }.symbol-grid { grid-template-columns: repeat(3, 1fr); }.video-grid { grid-template-columns: 1fr 1fr; }.calculator-controls { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
.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; }
@@ -278,7 +334,12 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
.hero { min-height: 500px; padding: 35px 25px; }.hero h1 { font-size: 44px; }.hero-actions { align-items: stretch; flex-direction: column; }
.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: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; }
.preview-pane { min-height: 320px; }.match-history-item { align-items: flex-start; flex-direction: column; }.match-history-item > div:last-child { text-align: left; }
.match-mode-switch { grid-template-columns: 1fr; }
.board-online-panel { grid-template-columns: 1fr; }.board-online-panel > div, .board-online-panel form { display: grid; grid-template-columns: 1fr; }.board-online-panel p, .board-online-panel > strong { grid-column: 1; }
.story-experience { padding: 14px 14px 40px; }.story-experience-header { align-items: flex-start; }.story-experience-header .ghost-button { width: auto; padding: 9px 11px; font-size: 10px; }.story-chapter-bar, .story-experience-layout { grid-template-columns: 1fr; }.story-scene-panel { min-height: 0; padding: 25px 20px; }.story-scene-panel .scene { min-height: 120px; }.story-mark-panel { position: static; }.story-domain-counts { grid-template-columns: repeat(2, 1fr); }
.wechat-entry { align-items: flex-start; flex-direction: column; padding: 24px; }.wechat-entry-actions { width: 100%; display: grid; grid-template-columns: 1fr 1fr; }.wechat-entry-actions > * { justify-content: center; text-align: center; }
.tool-group { padding: 14px; }.tool-group .tool-grid { grid-template-columns: 1fr; }.tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.advanced-calculator .calculator-controls > label:first-child { grid-column: 1 / -1; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.graph-controls { padding: 14px; }.graph-function-row { grid-template-columns: 17px 27px auto minmax(60px, 1fr) 27px; gap: 5px; }.graph-function-row > input[type="color"], .graph-function-row > button { width: 27px; height: 27px; }.graph-range-grid { grid-template-columns: 1fr 1fr; }#graph-canvas { aspect-ratio: 4 / 3; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; }
.math-games-section { margin-top: 45px; }.game-card { min-height: 260px; padding: 21px; }.game-card-controls { align-items: stretch; }.game-card-controls .primary-button { flex: 1; }.twenty-four-numbers { gap: 7px; }.twenty-four-numbers button { border-radius: 13px; font-size: 28px; }.sudoku-board input { font-size: clamp(13px, 4.5vw, 20px); }.sudoku-actions { display: grid; grid-template-columns: 1fr 1fr; }.game-keypad { gap: 5px; }
.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; }
@@ -292,9 +353,6 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
.life-map { margin-top: 28px; }
.life-map-canvas { position: relative; max-width: 1200px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; padding: 30px 0; }
.life-map-lines { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; pointer-events: none; }
.life-map-center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 1; display: grid; place-items: center; text-align: center; pointer-events: none; }
.life-map-center b { display: grid; place-items: center; width: 56px; height: 56px; border-radius: 50%; background: var(--ink); color: var(--lime); font: 22px Georgia, serif; margin-bottom: 6px; }
.life-map-center span { font-size: 9px; color: var(--muted); letter-spacing: .12em; line-height: 1.4; }
.route-card {
position: relative; width: 100%; padding: 28px 26px 24px; border-radius: 22px; z-index: 2;
@@ -348,7 +406,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
@media (max-width: 1050px) {
.life-map-canvas { grid-template-columns: 1fr; }
.life-map-lines, .life-map-center { display: none; }
.life-map-lines { display: none; }
.alumni-entry-content { flex-direction: column; align-items: flex-start; }
.alumni-entry-action { align-items: flex-start; }
.skill-grid { grid-template-columns: 1fr 1fr; }
+173 -91
View File
@@ -29,7 +29,7 @@ const state = {
user: null,
stories: [],
contests: [],
track: "standard",
matchMode: "quiz",
videoCatalog: null,
videos: [],
videoAbility: "",
@@ -148,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}`));
@@ -178,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() {
@@ -188,6 +199,7 @@ async function loadUser() {
state.user = null;
}
updateUserUI();
await loadHomeMatchHistory();
}
function card({ meta, title, body, foot, action, onClick, disabled = false }) {
@@ -311,18 +323,65 @@ async function beginStory(slug) {
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");
@@ -331,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 {
@@ -444,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),
@@ -807,6 +882,29 @@ 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";
@@ -816,7 +914,7 @@ async function loadProfile() {
profile.recent_games.forEach((game) => {
const item = document.createElement("div");
const name = document.createElement("b");
name.textContent = `${game.label} · ${game.difficulty}`;
name.textContent = game.label;
const detail = document.createElement("span");
detail.textContent =
game.status === "completed"
@@ -832,6 +930,51 @@ async function loadProfile() {
}
}
function renderMatchHistory(root, matches) {
root.replaceChildren(
...matches.map((match) => {
const item = document.createElement("article");
item.className = `match-history-item result-${match.result}`;
const summary = document.createElement("div");
const title = document.createElement("b");
title.textContent = `${match.contest} · 对手 ${match.opponent}`;
const time = document.createElement("span");
time.textContent = new Date(match.completed_at).toLocaleString();
summary.append(title, time);
const result = document.createElement("div");
const resultLabel = document.createElement("strong");
resultLabel.textContent =
match.result === "win" ? "胜利" : match.result === "loss" ? "惜败" : "平局";
const delta = document.createElement("span");
const sign = match.rating_delta > 0 ? "+" : "";
delta.textContent = `Rating ${sign}${match.rating_delta}${match.rating_after}`;
result.append(resultLabel, delta);
item.append(summary, result);
return item;
})
);
}
async function loadHomeMatchHistory() {
const section = $("#home-match-history");
if (!state.user) {
section.hidden = true;
return;
}
try {
const profile = await api("progression/me/");
if (!profile.recent_matches?.length) {
section.hidden = true;
return;
}
renderMatchHistory($("#home-match-list"), profile.recent_matches);
section.hidden = false;
} catch (error) {
section.hidden = true;
if (![401, 403].includes(error.status)) console.warn(error);
}
}
const MATH_SYMBOLS = [
["∑", "求和", "一组数或表达式的总和", "\\sum"],
["∏", "连乘", "一组数或表达式的乘积", "\\prod"],
@@ -966,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) =>
@@ -1009,70 +1140,7 @@ function renderSymbols(query = "") {
);
}
function drawGraph() {
const canvas = $("#graph-canvas");
const context = canvas.getContext("2d");
const expression = $("#graph-expression").value;
const range = Number($("#graph-range").value);
const width = canvas.width;
const height = canvas.height;
$("#graph-range-label").textContent = `${range}${range}`;
$("#graph-error").textContent = "";
context.clearRect(0, 0, width, height);
context.fillStyle = "#fbfbf7";
context.fillRect(0, 0, width, height);
const toX = (x) => ((x + range) / (range * 2)) * width;
const toY = (y) => height / 2 - (y / range) * (height / 2);
context.strokeStyle = "#e4e5df";
context.lineWidth = 1;
for (let value = -range; value <= range; value += 1) {
context.beginPath();
context.moveTo(toX(value), 0);
context.lineTo(toX(value), height);
context.stroke();
context.beginPath();
context.moveTo(0, toY(value));
context.lineTo(width, toY(value));
context.stroke();
}
context.strokeStyle = "#718078";
context.lineWidth = 2;
context.beginPath();
context.moveTo(0, height / 2);
context.lineTo(width, height / 2);
context.moveTo(width / 2, 0);
context.lineTo(width / 2, height);
context.stroke();
context.strokeStyle = "#196548";
context.lineWidth = 3;
context.beginPath();
let drawing = false;
try {
for (let pixel = 0; pixel <= width; pixel += 2) {
const x = (pixel / width) * range * 2 - range;
const y = evaluateExpression(expression, { x });
const screenY = toY(y);
if (screenY < -height * 2 || screenY > height * 3) {
drawing = false;
continue;
}
if (!drawing) context.moveTo(pixel, screenY);
else context.lineTo(pixel, screenY);
drawing = true;
}
context.stroke();
} catch (error) {
$("#graph-error").textContent = error.message;
}
}
function switchTool(tool) {
if (tool === "mental") {
navigate("contest");
return;
}
$$(".tool-card").forEach((card) => {
card.classList.toggle("active", card.dataset.tool === tool);
});
@@ -1140,6 +1208,8 @@ 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) => { renderLatexPreview(event.target.value); });
$$(".tool-card").forEach((button) => {
@@ -1189,6 +1259,7 @@ function bindUI() {
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()));
@@ -1198,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();
@@ -1209,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;
@@ -1221,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;
+5 -24
View File
@@ -14,23 +14,6 @@
},
};
function difficultySelect() {
const select = document.createElement("select");
select.className = "game-difficulty";
[
["easy", "入门"],
["standard", "标准"],
["hard", "进阶"],
].forEach(([value, label]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
if (value === "standard") option.selected = true;
select.append(option);
});
return select;
}
function gameCard(game) {
const meta = GAME_META[game.kind];
const article = document.createElement("article");
@@ -50,12 +33,11 @@
summary.textContent = game.summary;
const controls = document.createElement("div");
controls.className = "game-card-controls";
const difficulty = difficultySelect();
const start = document.createElement("button");
start.className = "primary-button";
start.textContent = meta.action;
start.addEventListener("click", () => startGame(game.kind, difficulty.value));
controls.append(difficulty, start);
start.addEventListener("click", () => startGame(game.kind));
controls.append(start);
article.append(top, kicker, title, summary, controls);
return article;
}
@@ -70,12 +52,12 @@
}
}
async function startGame(kind, difficulty) {
async function startGame(kind) {
if (!requireAuth()) return;
try {
const attempt = await api(`contests/games/${kind}/start/`, {
method: "POST",
body: { difficulty },
body: {},
});
renderGame(attempt);
$game("#experience-dialog").showModal();
@@ -88,8 +70,7 @@
const fragment = document.createDocumentFragment();
const label = document.createElement("span");
label.className = "kicker";
label.textContent =
`${GAME_META[attempt.kind].kicker} · ${attempt.difficulty.toUpperCase()}`;
label.textContent = GAME_META[attempt.kind].kicker;
const title = document.createElement("h2");
title.textContent = GAME_META[attempt.kind].title;
fragment.append(label, title);
+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 };
})();
+182 -33
View File
@@ -7,6 +7,11 @@
opponentProgress: 0,
reconnectTimer: null,
};
const GAME_LABELS = {
quiz: "口算竞速",
sudoku: "数独 Timerun",
twenty_four: "24 点竞速",
};
function stopTimers() {
if (realtime.pollTimer) window.clearInterval(realtime.pollTimer);
@@ -90,7 +95,7 @@
match.opponent?.status &&
match.opponent.status !== "active"
) {
realtime.opponentProgress = match.attempt?.questions.length || 0;
realtime.opponentProgress = matchProgressTotal(match);
updateProgressUI();
updateConnectionStatus("对手已提交,完成后将立即结算");
}
@@ -130,7 +135,7 @@
const label = document.createElement("span");
label.className = "kicker";
label.textContent =
`${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · REALTIME`;
`${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · ${GAME_LABELS[match.game_kind] || "实时竞技"}`;
const title = document.createElement("h2");
title.textContent = match.contest;
const status = document.createElement("div");
@@ -182,7 +187,7 @@
message.textContent =
match.match_type === "challenge"
? "把联机码发给朋友。对方加入后会自动开始,无需再次点击。"
: "正在寻找同赛道、相近 Rating 的玩家。";
: `正在寻找${GAME_LABELS[match.game_kind] || "同玩法"}对手。`;
panel.append(pulse, message);
if (match.challenge_code) {
const code = document.createElement("button");
@@ -217,30 +222,41 @@
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 / ${match.attempt.questions.length}</b>`;
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} / ${match.attempt.questions.length}</b>`;
`<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} / ${realtime.match.attempt.questions.length}`;
self.textContent = `${selfCount} / ${total}`;
}
}
const opponent = document.querySelector("#opponent-progress");
if (opponent && realtime.match?.attempt) {
opponent.textContent =
`${realtime.opponentProgress} / ${realtime.match.attempt.questions.length}`;
opponent.textContent = `${realtime.opponentProgress} / ${total}`;
}
}
@@ -252,6 +268,109 @@
}
}
async function submitRealtimeGame(attempt, payload) {
realtime.match = await api(
`contests/games/attempts/${attempt.attempt_id}/submit/`,
{
method: "POST",
headers: { "Idempotency-Key": createIdempotencyKey() },
body: payload,
},
);
renderMatch();
}
function renderTwentyFourGame(root, match) {
const attempt = match.attempt;
const numbers = document.createElement("div");
numbers.className = "twenty-four-numbers";
attempt.puzzle.numbers.forEach((number) => {
const tile = document.createElement("span");
tile.textContent = number;
numbers.append(tile);
});
const form = document.createElement("form");
form.className = "twenty-four-form";
const input = document.createElement("input");
input.className = "formula-input";
input.placeholder = "四个数字各用一次,例如:6/(1-3/4)";
input.autocomplete = "off";
input.addEventListener("input", () => {
sendProgress(input.value.trim() ? 1 : 0);
updateProgressUI(input.value.trim() ? 1 : 0);
});
const error = document.createElement("p");
error.className = "form-error";
const submit = document.createElement("button");
submit.className = "primary-button";
submit.type = "submit";
submit.textContent = "验证并锁定答案";
form.append(input, error, submit);
form.addEventListener("submit", async (event) => {
event.preventDefault();
submit.disabled = true;
error.textContent = "";
try {
await submitRealtimeGame(attempt, { expression: input.value });
} catch (requestError) {
error.textContent = requestError.message;
submit.disabled = false;
}
});
root.append(numbers, form);
}
function renderSudokuGame(root, match) {
const attempt = match.attempt;
const board = document.createElement("div");
board.className = "sudoku-board";
attempt.puzzle.grid.forEach((rowValues, row) => {
rowValues.forEach((givenValue, column) => {
const input = document.createElement("input");
input.inputMode = "numeric";
input.pattern = "[1-9]";
input.maxLength = 1;
input.dataset.row = row;
input.dataset.column = column;
input.value = givenValue || "";
input.readOnly = Boolean(givenValue);
input.className = givenValue ? "given" : "";
input.setAttribute("aria-label", `${row + 1} 行第 ${column + 1}`);
input.addEventListener("input", () => {
input.value = input.value.replace(/[^1-9]/g, "").slice(0, 1);
const count = [...board.querySelectorAll("input")].filter(
(item) => item.value
).length;
sendProgress(count);
updateProgressUI(count);
});
board.append(input);
});
});
const error = document.createElement("p");
error.className = "form-error";
const submit = document.createElement("button");
submit.className = "primary-button";
submit.type = "button";
submit.textContent = "检查并锁定数独";
submit.addEventListener("click", async () => {
submit.disabled = true;
error.textContent = "";
const grid = Array.from({ length: 9 }, () => Array(9).fill(0));
board.querySelectorAll("input").forEach((input) => {
grid[Number(input.dataset.row)][Number(input.dataset.column)] =
Number(input.value || 0);
});
try {
await submitRealtimeGame(attempt, { grid });
} catch (requestError) {
error.textContent = requestError.message;
submit.disabled = false;
}
});
root.append(board, error, submit);
}
function renderActive(root, match) {
progressPanel(root, match);
const attempt = match.attempt;
@@ -264,6 +383,16 @@
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) => {
@@ -328,7 +457,15 @@
: "平局";
const score = document.createElement("p");
score.textContent =
`${match.attempt.score} 分 · ${match.opponent.score}${match.opponent.nickname}`;
match.game_kind === "quiz"
? `${match.attempt.score} 分 · ${match.opponent.score}${match.opponent.nickname}`
: `${GAME_LABELS[match.game_kind]} · ${
match.attempt.status === "completed" ? "你已完成" : "你未完成"
} · ${
match.opponent.status === "completed"
? `${match.opponent.nickname} 已完成`
: `${match.opponent.nickname} 未完成`
}`;
const timeLine = document.createElement("p");
timeLine.className = "realtime-time-line";
const selfMs = match.attempt.duration_ms || 0;
@@ -336,7 +473,11 @@
const selfSec = (selfMs / 1000).toFixed(1);
const opponentSec = (opponentMs / 1000).toFixed(1);
timeLine.textContent = `${selfSec}s · ${match.opponent.nickname} ${opponentSec}s`;
if (match.attempt.score === match.opponent.score && match.result.winner !== "draw") {
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";
@@ -350,25 +491,30 @@
result.append(outcome, score, timeLine, rating);
root.append(result);
// 刷新右上角用户 Rating 显示
if (typeof loadUser === "function") loadUser();
if (state.user) {
state.user.rating = match.result.rating_after;
updateUserUI();
}
loadUser();
const review = document.createElement("div");
review.className = "realtime-review";
match.attempt.questions.forEach((question) => {
const item = document.createElement("article");
const title = document.createElement("b");
title.textContent = `${question.order}. ${question.prompt}`;
const answer = document.createElement("p");
answer.textContent =
`你的答案:${question.submitted_answer || "未作答"} · 正确答案:${question.correct_answer}`;
const explanation = document.createElement("small");
explanation.textContent = question.explanation || "";
item.className = question.is_correct ? "correct" : "incorrect";
item.append(title, answer, explanation);
review.append(item);
});
root.append(review);
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() {
@@ -388,15 +534,18 @@
}
function currentRealtimeContest() {
return state.contests.find(
(contest) => contest.kind === "realtime" && contest.track === state.track
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: {},
body: { game_kind: state.matchMode },
});
openMatch(match);
}
@@ -405,13 +554,13 @@
if (!requireAuth()) return;
const contest = currentRealtimeContest();
if (!contest) {
showToast("当前赛道没有可用的实时比赛");
showToast("当前没有可用的实时比赛");
return;
}
try {
const match = await api(`contests/${contest.slug}/challenges/`, {
method: "POST",
body: {},
body: { game_kind: state.matchMode },
});
openMatch(match);
} catch (error) {
+376 -193
View File
@@ -5,25 +5,107 @@
const CALC_FIELDS = {
derivative: ["order"],
integral: ["bounds"],
limit: ["point"],
limit: ["point", "direction"],
series: ["point", "order"],
solve_system: ["variables"],
gradient: ["variables"],
hessian: ["variables"],
base: ["base"],
unit: ["unit"],
};
function formatCalculatorResult(result) {
if (Array.isArray(result)) {
return {
exact: result.map((item) => item.exact).join(", "),
decimal: result.map((item) => item.decimal).join(", "),
latex: result.map((item) => item.latex).join(", "),
};
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;
}
}
if (result && typeof result === "object" && "exact" in result) return result;
const entries = Object.entries(result || {});
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: entries.map(([key, value]) => `${key}: ${value}`).join(" · "),
decimal: entries.map(([key, value]) => `${key}: ${value}`).join(" · "),
latex: entries.map(([key, value]) => `${key}=${value}`).join(", "),
exact: resultText(result, "exact"),
decimal: resultText(result, "decimal"),
latex: resultText(result, "latex"),
};
}
@@ -33,17 +115,13 @@
$$tool("[data-calc-field]").forEach((field) => {
field.hidden = !visible.has(field.dataset.calcField);
});
const input = $tool("#calc-input");
const placeholders = {
statistics: "12, 15, 18, 21, 24",
base: "FF",
matrix_det: "1,2;3,4",
matrix_inverse: "1,2;3,4",
matrix_rref: "1,2,3;2,4,6",
matrix_transpose: "1,2,3;4,5,6",
solve: "x^2 - 5*x + 6 = 0",
};
input.placeholder = placeholders[operation] || "例如:sqrt(2) + 1/3";
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() {
@@ -59,10 +137,12 @@
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,
@@ -92,163 +172,6 @@
}
}
const GRAPH_COLORS = ["#196548", "#d86f45", "#5d73e8", "#8667b5"];
function drawFunctionPath(context, expression, variables, range, width, height, color) {
const toY = (value) => height / 2 - (value / range) * (height / 2);
context.strokeStyle = color;
context.lineWidth = 3;
context.beginPath();
let drawing = false;
const samples = [];
for (let pixel = 0; pixel <= width; pixel += 2) {
const x = (pixel / width) * range * 2 - range;
let y;
try {
y = evaluateExpression(expression, { ...variables, x });
} catch (error) {
if (pixel === 0) throw error;
drawing = false;
continue;
}
samples.push({ x, y });
const screenY = toY(y);
if (!Number.isFinite(screenY) || screenY < -height * 2 || screenY > height * 3) {
drawing = false;
continue;
}
if (!drawing) context.moveTo(pixel, screenY);
else context.lineTo(pixel, screenY);
drawing = true;
}
context.stroke();
return samples;
}
function graphAnalysis(samples) {
const roots = [];
let extrema = 0;
for (let index = 1; index < samples.length; index += 1) {
const before = samples[index - 1];
const current = samples[index];
if (before.y === 0 || before.y * current.y < 0) {
const root = (before.x + current.x) / 2;
if (!roots.length || Math.abs(root - roots.at(-1)) > 0.15) roots.push(root);
}
if (index > 1) {
const previousSlope = before.y - samples[index - 2].y;
const currentSlope = current.y - before.y;
if (previousSlope * currentSlope < 0) extrema += 1;
}
}
return { roots: roots.slice(0, 8), extrema };
}
function drawGraph() {
const canvas = $tool("#graph-canvas");
const context = canvas.getContext("2d");
const expressions = $tool("#graph-expression").value
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 4);
const range = Number($tool("#graph-range").value);
const parameter = Number($tool("#graph-parameter").value);
const width = canvas.width;
const height = canvas.height;
$tool("#graph-range-label").textContent = `${range}${range}`;
$tool("#graph-parameter-label").textContent = `a = ${parameter}`;
$tool("#graph-error").textContent = "";
context.clearRect(0, 0, width, height);
context.fillStyle = "#fbfbf7";
context.fillRect(0, 0, width, height);
const toX = (x) => ((x + range) / (range * 2)) * width;
const toY = (y) => height / 2 - (y / range) * (height / 2);
context.strokeStyle = "#e4e5df";
context.lineWidth = 1;
for (let value = -range; value <= range; value += 1) {
context.beginPath();
context.moveTo(toX(value), 0);
context.lineTo(toX(value), height);
context.moveTo(0, toY(value));
context.lineTo(width, toY(value));
context.stroke();
}
context.strokeStyle = "#718078";
context.lineWidth = 2;
context.beginPath();
context.moveTo(0, height / 2);
context.lineTo(width, height / 2);
context.moveTo(width / 2, 0);
context.lineTo(width / 2, height);
context.stroke();
if (!expressions.length) {
$tool("#graph-error").textContent = "请至少输入一个函数";
return;
}
try {
const firstSamples = drawFunctionPath(
context,
expressions[0],
{ a: parameter },
range,
width,
height,
GRAPH_COLORS[0],
);
expressions.slice(1).forEach((expression, index) => {
drawFunctionPath(
context,
expression,
{ a: parameter },
range,
width,
height,
GRAPH_COLORS[index + 1],
);
});
if ($tool("#graph-integral").checked) {
context.fillStyle = "rgba(25, 101, 72, .13)";
context.beginPath();
context.moveTo(0, height / 2);
firstSamples.forEach((item) => context.lineTo(toX(item.x), toY(item.y)));
context.lineTo(width, height / 2);
context.closePath();
context.fill();
}
if ($tool("#graph-derivative").checked) {
const derivative = firstSamples.slice(1, -1).map((item, index) => {
const before = firstSamples[index];
const after = firstSamples[index + 2];
return { x: item.x, y: (after.y - before.y) / (after.x - before.x) };
});
context.strokeStyle = "#111827";
context.lineWidth = 2;
context.setLineDash([9, 7]);
context.beginPath();
derivative.forEach((item, index) => {
const x = toX(item.x);
const y = toY(item.y);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
context.setLineDash([]);
}
const analysis = graphAnalysis(firstSamples);
const rootText = analysis.roots.length
? analysis.roots.map((item) => item.toFixed(2)).join("、")
: "当前范围未发现";
$tool("#graph-analysis").textContent =
`第一条曲线:近似零点 ${rootText};检测到 ${analysis.extrema} 个极值转折。`;
} catch (error) {
$tool("#graph-error").textContent = error.message;
}
}
function canvasPoint(canvas, event) {
const rect = canvas.getBoundingClientRect();
return {
@@ -273,6 +196,7 @@
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));
@@ -290,6 +214,23 @@
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();
@@ -305,6 +246,7 @@
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;
@@ -352,6 +294,7 @@
this.move(event);
this.drawing = false;
this.preview = null;
this.emitSnapshot();
}
cancel() {
@@ -366,6 +309,7 @@
image.onload = () => {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.drawImage(image, 0, 0);
this.emitSnapshot();
};
image.src = source;
}
@@ -373,6 +317,7 @@
clear() {
this.snapshot();
this.reset();
this.emitSnapshot();
}
addGrid() {
@@ -400,6 +345,7 @@
context.moveTo(0, this.canvas.height / 2);
context.lineTo(this.canvas.width, this.canvas.height / 2);
context.stroke();
this.emitSnapshot();
}
addImage(file) {
@@ -421,6 +367,7 @@
image.width * scale,
image.height * scale,
);
this.emitSnapshot();
};
image.src = reader.result;
};
@@ -439,6 +386,7 @@
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));
@@ -448,6 +396,34 @@
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({
@@ -501,6 +477,7 @@
if (this.tool === "move") {
this.pointerMove(event);
this.dragging = null;
this.emitState();
return;
}
this.handlePoint(event);
@@ -535,6 +512,7 @@
}
}
this.draw();
this.emitState();
$tool("#geometry-hint").textContent = this.pending.length
? "再选择一个点完成构造。"
: "可继续创建或切换构造工具。";
@@ -624,6 +602,7 @@
this.circles = parsed.circles;
this.pending = [];
this.draw();
this.emitState();
}
clear() {
@@ -633,15 +612,196 @@
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;
@@ -675,10 +835,35 @@
$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", updateCalculatorFields);
$tool("#calc-operation").addEventListener("change", () => {
setCalculatorCategory(
calculatorCategoryFor($tool("#calc-operation").value),
false,
);
});
$$tool("[data-calc-category]").forEach((button) => {
button.addEventListener("click", () => {
setCalculatorCategory(button.dataset.calcCategory);
});
});
$tool("#calc-run").addEventListener("click", runCalculator);
$tool("#calc-input").addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") runCalculator();
@@ -687,24 +872,22 @@
button.addEventListener("click", () => {
$tool("#calc-operation").value = button.dataset.calcOperation;
$tool("#calc-input").value = button.dataset.calcExample;
updateCalculatorFields();
setCalculatorCategory(
calculatorCategoryFor(button.dataset.calcOperation),
false,
);
runCalculator();
});
});
["#graph-run", "#graph-range", "#graph-parameter", "#graph-derivative", "#graph-integral"]
.forEach((selector) => $tool(selector).addEventListener("input", drawGraph));
$tool("#graph-expression").addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") drawGraph();
});
initDrawingTools();
updateCalculatorFields();
drawGraph();
setCalculatorCategory("algebra", false);
window.HuluGraph?.init();
}
function activate(tool) {
if (tool === "graph") window.setTimeout(drawGraph, 30);
if (tool === "geometry") window.setTimeout(() => geometry.draw(), 30);
if (tool === "graph") window.setTimeout(() => window.HuluGraph?.resize(), 30);
if (tool === "whiteboard") window.setTimeout(() => geometry.draw(), 30);
}
window.HuluToolbox = { init, activate, runCalculator, drawGraph };
window.HuluToolbox = { init, activate, runCalculator };
})();
+212 -84
View File
@@ -28,6 +28,7 @@
</nav>
<div class="sidebar-foot">
<div class="system-status"><i></i>系统在线</div>
<small class="app-version">Hulumath v{{ app_version }}</small>
<button id="auth-button" class="ghost-button">登录 / 注册</button>
</div>
</aside>
@@ -66,6 +67,18 @@
</article>
</div>
<section class="wechat-entry">
<div>
<span class="kicker">WECHAT OFFICIAL ACCOUNT</span>
<h2>在公众号继续收到数学人生更新</h2>
<p>微信内搜索公众号「葫芦数学」,获取新剧情、比赛活动与版本公告。</p>
</div>
<div class="wechat-entry-actions">
<button id="wechat-copy" class="ghost-button" data-wechat-name="葫芦数学">复制公众号名称</button>
<a class="primary-button" href="weixin://">打开微信</a>
</div>
</section>
<section class="section">
<div class="section-heading">
<div><span class="kicker">FOUR SPIRITS</span><h2>四种数学精神</h2></div>
@@ -78,6 +91,13 @@
<article class="spirit intuitive"><b>04</b><h3>直觉者</h3><p>当形式尚未成形,你敢不敢追随结构感?</p><span>想象 · 洞察 · 创造</span></article>
</div>
</section>
<section class="section home-match-history" id="home-match-history" hidden>
<div class="section-heading">
<div><span class="kicker">RECENT MATCHES</span><h2>最近对局</h2></div>
<button class="text-button" data-jump="profile">查看完整数学档案</button>
</div>
<div id="home-match-list" class="match-history-list"></div>
</section>
</section>
<section class="view" id="view-life">
@@ -91,12 +111,7 @@
<path d="M600 260 Q800 120 980 180" fill="none" stroke="rgba(25,101,72,.12)" stroke-width="2" stroke-dasharray="10 12"/>
<path d="M600 260 Q400 400 220 420" fill="none" stroke="rgba(25,101,72,.12)" stroke-width="2" stroke-dasharray="10 12"/>
<path d="M600 260 Q800 400 980 420" fill="none" stroke="rgba(25,101,72,.12)" stroke-width="2" stroke-dasharray="10 12"/>
<circle cx="600" cy="260" r="90" fill="rgba(25,101,72,.04)" stroke="rgba(25,101,72,.16)" stroke-width="2"/>
<circle cx="600" cy="260" r="70" fill="none" stroke="rgba(25,101,72,.12)" stroke-width="2" stroke-dasharray="6 8"/>
</svg>
<div class="life-map-center">
<b></b><span>数学人生<br>交叉点</span>
</div>
<article class="route-card route-believer" data-clan="believer" data-route="believer">
<span class="route-index">01</span>
<div class="route-spirit">
@@ -168,9 +183,11 @@
</section>
<section class="view" id="view-contest">
<div class="page-title"><span class="kicker">CONTEST ARENA</span><h1>比赛</h1><p>实时 1v1、今日挑战和单人闯关,按水平赛道独立计算</p></div>
<div class="track-switch" id="track-switch">
<button class="active" data-track="standard">标准</button><button data-track="beginner">入门</button><button data-track="advanced">进阶</button>
<div class="page-title"><span class="kicker">CONTEST ARENA</span><h1>比赛</h1><p>不再按难度拆分玩家池。选择玩法后即可随机匹配或用联机码约战</p></div>
<div class="match-mode-switch" id="match-mode-switch" aria-label="实时比赛玩法">
<button class="active" data-match-mode="quiz"><b>口算竞速</b><small>同题抢分</small></button>
<button data-match-mode="sudoku"><b>数独 Timerun</b><small>正确完成者比时间</small></button>
<button data-match-mode="twenty_four"><b>24 点竞速</b><small>最先组出 24</small></button>
</div>
<section class="challenge-panel">
<div>
@@ -179,7 +196,7 @@
<p>创建 6 位联机码发给朋友,或输入朋友的联机码加入同一场对局。</p>
</div>
<div class="challenge-actions">
<button id="challenge-create" class="primary-button">创建当前赛道约战</button>
<button id="challenge-create" class="primary-button">创建口算竞速约战</button>
<form id="challenge-join-form">
<input id="challenge-code-input" maxlength="6" autocomplete="off" placeholder="输入 6 位联机码" aria-label="联机码">
<button class="dark-button" type="submit">加入约战</button>
@@ -197,47 +214,84 @@
</section>
<section class="view" id="view-toolbox">
<div class="page-title"><span class="kicker">MATHEMATICAL WORKBENCH</span><h1>工具箱</h1><p>计算、查询、绘图与表达,把想法直接变成可以继续工作的对象</p></div>
<div class="tool-grid" id="tool-grid">
<button class="tool-card" data-tool="mental"><span></span><b>口算竞技</b><small>限时挑战你的心算速度</small></button>
<button class="tool-card active" data-tool="calculator"><span>123</span><b>数学计算工作台</b><small>代数、微积分、矩阵与统计</small></button>
<button class="tool-card" data-tool="symbols"><span>Σ</span><b>数学符号查询</b><small>含义、读法与 LaTeX 写法</small></button>
<button class="tool-card" data-tool="graph"><span></span><b>函数图形绘制</b><small>多函数、参数、导数与分析</small></button>
<button class="tool-card" data-tool="whiteboard"><span></span><b>数学白板</b><small>书写、图形、撤销与导出</small></button>
<button class="tool-card" data-tool="geometry"><span></span><b>几何画板</b><small>点线圆、中点与动态测量</small></button>
<button class="tool-card" data-tool="latex"><span>TeX</span><b>LaTeX Lab</b><small>编辑、预览、课程与公式库</small></button>
<div class="page-title"><span class="kicker">MATHEMATICAL WORKBENCH</span><h1>工具箱</h1><p>从符号计算、函数探索到数学表达与协作创作,按任务组织完整工作流</p></div>
<div class="tool-groups" id="tool-grid">
<section class="tool-group">
<header><span>01</span><div><b>计算与探索</b><small>从表达式到结构与图像</small></div></header>
<div class="tool-grid">
<button class="tool-card active" data-tool="calculator"><span>CAS</span><b>高级计算工作台</b><small>代数、微积分、方程组、矩阵与数据分析</small></button>
<button class="tool-card" data-tool="graph"><span>ƒ(x)</span><b>函数探索器</b><small>响应式曲线、独立视窗、平移缩放与分析</small></button>
</div>
</section>
<section class="tool-group">
<header><span>02</span><div><b>知识与表达</b><small>查询、排版与规范表达</small></div></header>
<div class="tool-grid">
<button class="tool-card" data-tool="symbols"><span>Σ</span><b>数学符号查询</b><small>含义、读法与 LaTeX 写法</small></button>
<button class="tool-card" data-tool="latex"><span>TeX</span><b>LaTeX Lab</b><small>编辑、实时预览与版本保存</small></button>
</div>
</section>
<section class="tool-group">
<header><span>03</span><div><b>创作与协作</b><small>把思路画出来并实时分享</small></div></header>
<div class="tool-grid">
<button class="tool-card" data-tool="whiteboard"><span>✎△</span><b>数学画板</b><small>白板、几何、联机展示与你画我猜</small></button>
</div>
</section>
</div>
<div class="tool-workspace active" id="tool-calculator">
<div class="workspace-heading"><div><span class="kicker">COMPUTATION STUDIO</span><h2>数学计算工作台</h2></div><p>精确值、方程、微积分、矩阵、统计与进制转换由受限数学内核计算。</p></div>
<div class="workspace-heading"><div><span class="kicker">SYMBOLIC COMPUTATION STUDIO</span><h2>高级计算工作台</h2></div><p>受限 SymPy 内核提供精确符号计算、多元分析、线性代数和数据换算。</p></div>
<div class="calculator-shell advanced-calculator">
<div class="calc-category-tabs" aria-label="计算分类">
<button class="active" data-calc-category="algebra">代数与方程</button>
<button data-calc-category="calculus">微积分与多元分析</button>
<button data-calc-category="linear">线性代数</button>
<button data-calc-category="data">数据与换算</button>
</div>
<div class="calculator-controls">
<label>计算类型
<select id="calc-operation">
<option value="calculate">精确计算</option>
<option value="simplify">表达式化简</option>
<option value="expand">代数展开</option>
<option value="factor">因式分解</option>
<option value="solve">方程</option>
<option value="derivative">求导</option>
<option value="integral">积分</option>
<option value="limit">极限</option>
<option value="matrix_det">矩阵行列式</option>
<option value="matrix_inverse">逆矩阵</option>
<option value="matrix_rref">矩阵行最简形</option>
<option value="matrix_transpose">矩阵转置</option>
<option value="statistics">描述统计</option>
<option value="base">进制转换</option>
<option value="unit">单位换算</option>
<optgroup label="代数与方程" data-calc-category-options="algebra">
<option value="calculate">精确计算</option>
<option value="simplify">表达式化简</option>
<option value="expand">代数展开</option>
<option value="factor">因式分</option>
<option value="solve">符号解方程</option>
<option value="solve_system">解多元方程组</option>
<option value="polynomial_roots">多项式全部数值根</option>
</optgroup>
<optgroup label="微积分与多元分析" data-calc-category-options="calculus">
<option value="derivative">高阶导数</option>
<option value="integral">定积分 / 不定积分</option>
<option value="limit">单侧 / 双侧极限</option>
<option value="series">Taylor / Laurent 级数</option>
<option value="gradient">梯度</option>
<option value="hessian">Hessian 矩阵</option>
</optgroup>
<optgroup label="线性代数" data-calc-category-options="linear">
<option value="matrix_det">矩阵行列式</option>
<option value="matrix_inverse">逆矩阵</option>
<option value="matrix_rref">矩阵行最简形</option>
<option value="matrix_transpose">矩阵转置</option>
<option value="matrix_rank">矩阵秩</option>
<option value="matrix_nullspace">零空间基</option>
<option value="matrix_eigenvalues">特征值与重数</option>
</optgroup>
<optgroup label="数据与换算" data-calc-category-options="data">
<option value="statistics">增强描述统计</option>
<option value="base">进制转换</option>
<option value="unit">单位换算</option>
</optgroup>
</select>
</label>
<label>变量
<select id="calc-variable"><option>x</option><option>y</option><option>z</option><option>a</option><option>b</option><option>t</option><option>n</option></select>
</label>
<label class="calc-field" data-calc-field="order">阶数<input id="calc-order" type="number" min="1" max="5" value="1"></label>
<label class="calc-field" data-calc-field="variables">变量列表<input id="calc-variables" value="x,y" placeholder="例如 x,y"></label>
<label class="calc-field" data-calc-field="order">阶数<input id="calc-order" type="number" min="1" max="12" value="1"></label>
<label class="calc-field" data-calc-field="bounds">下限<input id="calc-lower" placeholder="可留空"></label>
<label class="calc-field" data-calc-field="bounds">上限<input id="calc-upper" placeholder="可留空"></label>
<label class="calc-field" data-calc-field="point">趋近值<input id="calc-point" value="0"></label>
<label class="calc-field" data-calc-field="direction">方向<select id="calc-direction"><option value="+-">双侧</option><option value="+">右极限</option><option value="-">左极限</option></select></label>
<label class="calc-field" data-calc-field="base">原进制<input id="calc-from-base" type="number" min="2" max="36" value="10"></label>
<label class="calc-field" data-calc-field="base">目标进制<input id="calc-to-base" type="number" min="2" max="36" value="2"></label>
<label class="calc-field" data-calc-field="unit">原单位<select id="calc-from-unit"><option>mm</option><option>cm</option><option selected>m</option><option>km</option><option>in</option><option>ft</option><option>g</option><option>kg</option><option>lb</option><option>s</option><option>min</option><option>h</option><option>deg</option><option>rad</option></select></label>
@@ -246,13 +300,13 @@
<label class="calculator-expression-label">表达式或数据
<textarea id="calc-input" class="formula-input" autocomplete="off" aria-label="计算表达式">sqrt(2) + 1/3</textarea>
</label>
<p id="calc-hint" class="calc-hint">支持精确常量、函数与变量;按 Command/Ctrl + Enter 运行。</p>
<div class="calculator-actions calc-examples">
<button data-calc-operation="solve" data-calc-example="x^2 - 5*x + 6 = 0">方程</button>
<button data-calc-operation="derivative" data-calc-example="sin(x) + x^3">求导</button>
<button data-calc-operation="integral" data-calc-example="x^2">积分</button>
<button data-calc-operation="matrix_inverse" data-calc-example="1,2;3,4">逆矩阵</button>
<button data-calc-operation="statistics" data-calc-example="12,15,18,21,24">统计</button>
<button data-calc-operation="unit" data-calc-example="1.75">单位换算</button>
<button data-calc-operation="solve_system" data-calc-example="x + y = 5; x - y = 1">方程</button>
<button data-calc-operation="series" data-calc-example="exp(x)*cos(x)">级数展开</button>
<button data-calc-operation="hessian" data-calc-example="x^2 + x*y + y^2">Hessian</button>
<button data-calc-operation="matrix_eigenvalues" data-calc-example="2,1;1,2">特征值</button>
<button data-calc-operation="statistics" data-calc-example="12,15,18,21,24">增强统计</button>
<button id="calc-run" class="primary-button">开始计算</button>
</div>
<div class="calculator-result" aria-live="polite">
@@ -272,57 +326,108 @@
</div>
<div class="tool-workspace" id="tool-graph">
<div class="workspace-heading"><div><span class="kicker">FUNCTION PLOTTER</span><h2>函数图形绘制</h2></div><p>行一个函数;支持参数 a、数值导数、积分区域和曲线分析</p></div>
<div class="graph-shell">
<div class="graph-controls">
<label>函数列表<textarea id="graph-expression" class="formula-input">sin(x) + a*x/3&#10;0.08*x^2 - 2</textarea></label>
<label>X 范围<input id="graph-range" type="range" min="5" max="30" value="10"><span id="graph-range-label">10 到 10</span></label>
<label>参数 a<input id="graph-parameter" type="range" min="-5" max="5" step="0.1" value="1"><span id="graph-parameter-label">a = 1</span></label>
<label class="check-row"><input id="graph-derivative" type="checkbox"> 绘制第一条函数的导数</label>
<label class="check-row"><input id="graph-integral" type="checkbox"> 填充第一条函数与 x 轴区域</label>
<button id="graph-run" class="primary-button">绘制函数</button>
<div class="workspace-heading"><div><span class="kicker">INTERACTIVE FUNCTION EXPLORER</span><h2>函数探索器</h2></div><p>条曲线独立编辑;画布随容器缩放,支持平移、滚轮缩放、自动取景和清晰刻度</p></div>
<div class="graph-shell advanced-graph-shell">
<aside class="graph-controls">
<details class="function-syntax-help" open>
<summary>函数书写规则</summary>
<p>直接写右侧:<code>sin(x)</code><code>x^2 - 2*x + 1</code><code>sqrt(abs(x))</code>。乘法写 <code>*</code>,幂写 <code>^</code>,可使用参数 <code>a</code></p>
</details>
<div class="graph-functions-heading"><b>函数列表</b><button id="graph-add-function" type="button">+ 添加曲线</button></div>
<div id="graph-function-list" class="graph-function-list"></div>
<div class="graph-parameter-control">
<label>参数 a <output id="graph-parameter-label">1.0</output></label>
<input id="graph-parameter" type="range" min="-10" max="10" step="0.1" value="1">
</div>
<details class="graph-viewport-controls" open>
<summary>坐标视窗</summary>
<div class="graph-range-grid">
<label>X 最小<input id="graph-x-min" type="number" step="any" value="-10"></label>
<label>X 最大<input id="graph-x-max" type="number" step="any" value="10"></label>
<label>Y 最小<input id="graph-y-min" type="number" step="any" value="-6"></label>
<label>Y 最大<input id="graph-y-max" type="number" step="any" value="6"></label>
</div>
<label class="check-row"><input id="graph-auto-y" type="checkbox" checked> 根据可见曲线自动计算 Y 范围</label>
</details>
<div class="graph-option-grid">
<label class="check-row"><input id="graph-grid" type="checkbox" checked> 网格与刻度</label>
<label class="check-row"><input id="graph-derivative" type="checkbox"> 第一条曲线导数</label>
<label class="check-row"><input id="graph-integral" type="checkbox"> 第一条曲线积分区域</label>
</div>
<div class="graph-actions">
<button id="graph-auto-fit" type="button">自动取景</button>
<button id="graph-reset-view" type="button">重置视窗</button>
<button id="graph-run" class="primary-button">重新绘制</button>
</div>
<p id="graph-error"></p>
</aside>
<section class="graph-stage">
<div id="graph-legend" class="graph-legend"></div>
<canvas id="graph-canvas" aria-label="可交互函数坐标图"></canvas>
<div class="graph-stage-hint">拖动画布平移 · 滚轮或触控板缩放 · 双击自动取景</div>
<div id="graph-analysis" class="graph-analysis"></div>
</div>
<canvas id="graph-canvas" width="900" height="480" aria-label="函数曲线图"></canvas>
</section>
</div>
</div>
<div class="tool-workspace" id="tool-whiteboard">
<div class="workspace-heading"><div><span class="kicker">MATH WHITEBOARD</span><h2>数学</h2></div><p>支持鼠标、触控笔和手机触摸,作品仅在当前设备编辑,可导出 PNG</p></div>
<div class="drawing-toolbar" id="whiteboard-toolbar">
<button class="active" data-whiteboard-tool="pen">画笔</button>
<button data-whiteboard-tool="line">直线</button>
<button data-whiteboard-tool="rect">矩形</button>
<button data-whiteboard-tool="text">文字 / 公式</button>
<button data-whiteboard-tool="eraser">橡皮</button>
<input id="whiteboard-text" class="toolbar-text-input" placeholder="输入文字或公式">
<label>颜色<input id="whiteboard-color" type="color" value="#17211b"></label>
<label>粗细<input id="whiteboard-size" type="range" min="2" max="24" value="4"></label>
<button id="whiteboard-grid">添加坐标纸</button>
<label class="file-tool">导入图片<input id="whiteboard-image" type="file" accept="image/*"></label>
<button id="whiteboard-undo">撤销</button>
<button id="whiteboard-clear">清空</button>
<button id="whiteboard-export" class="primary-button">导出 PNG</button>
<div class="workspace-heading"><div><span class="kicker">MATH BOARD</span><h2>统一数学</h2></div><p>在白板与几何构造之间切换,可用联机码同步展示或发起数学你画我猜</p></div>
<div class="board-mode-switch">
<button class="active" data-board-pane-toggle="whiteboard">自由白板</button>
<button data-board-pane-toggle="geometry">几何构造</button>
</div>
<div class="canvas-stage"><canvas id="whiteboard-canvas" width="1200" height="700" aria-label="数学白板"></canvas></div>
</div>
<div class="tool-workspace" id="tool-geometry">
<div class="workspace-heading"><div><span class="kicker">GEOMETRY BOARD</span><h2>几何画板</h2></div><p>依次点击构造点、线段和圆;支持中点、长度与坐标测量。</p></div>
<div class="drawing-toolbar" id="geometry-toolbar">
<button class="active" data-geometry-tool="point"></button>
<button data-geometry-tool="segment">线段</button>
<button data-geometry-tool="circle"></button>
<button data-geometry-tool="midpoint">中点</button>
<button data-geometry-tool="move">拖动</button>
<label class="check-row"><input id="geometry-labels" type="checkbox" checked> 坐标与测量</label>
<button id="geometry-undo">撤销</button>
<button id="geometry-clear">清空</button>
<button id="geometry-export" class="primary-button">导出 PNG</button>
<section class="board-online-panel">
<div>
<select id="board-session-mode" aria-label="画板联机模式">
<option value="collaborate">协作展示</option>
<option value="draw_guess">数学你画我猜</option>
</select>
<button id="board-create" class="primary-button">创建联机码</button>
</div>
<form id="board-join-form">
<input id="board-code-input" maxlength="6" autocomplete="off" placeholder="输入 6 位联机码" aria-label="画板联机码">
<button type="submit">加入画板</button>
</form>
<p id="board-session-status">当前为本地画板,创建或加入后开始实时同步。</p>
<strong id="board-target" hidden></strong>
<form id="board-guess-form" hidden>
<input id="board-guess-input" maxlength="40" placeholder="猜一个数学对象">
<button type="submit">提交猜测</button>
</form>
</section>
<div class="board-pane active" data-board-pane="whiteboard">
<div class="drawing-toolbar" id="whiteboard-toolbar">
<button class="active" data-whiteboard-tool="pen">画笔</button>
<button data-whiteboard-tool="line">直线</button>
<button data-whiteboard-tool="rect">矩形</button>
<button data-whiteboard-tool="text">文字 / 公式</button>
<button data-whiteboard-tool="eraser">橡皮</button>
<input id="whiteboard-text" class="toolbar-text-input" placeholder="输入文字或公式">
<label>颜色<input id="whiteboard-color" type="color" value="#17211b"></label>
<label>粗细<input id="whiteboard-size" type="range" min="2" max="24" value="4"></label>
<button id="whiteboard-grid">添加坐标纸</button>
<label class="file-tool">导入图片<input id="whiteboard-image" type="file" accept="image/*"></label>
<button id="whiteboard-undo">撤销</button>
<button id="whiteboard-clear">清空</button>
<button id="whiteboard-export" class="primary-button">导出 PNG</button>
</div>
<div class="canvas-stage"><canvas id="whiteboard-canvas" width="1200" height="700" aria-label="数学白板"></canvas></div>
</div>
<div class="board-pane" data-board-pane="geometry">
<div class="drawing-toolbar" id="geometry-toolbar">
<button class="active" data-geometry-tool="point"></button>
<button data-geometry-tool="segment">线段</button>
<button data-geometry-tool="circle"></button>
<button data-geometry-tool="midpoint">中点</button>
<button data-geometry-tool="move">拖动</button>
<label class="check-row"><input id="geometry-labels" type="checkbox" checked> 坐标与测量</label>
<button id="geometry-undo">撤销</button>
<button id="geometry-clear">清空</button>
<button id="geometry-export" class="primary-button">导出 PNG</button>
</div>
<div class="canvas-stage geometry-stage"><canvas id="geometry-canvas" width="1200" height="700" aria-label="几何画板"></canvas></div>
<p id="geometry-hint" class="canvas-hint">点击画布创建第一个点。</p>
</div>
<div class="canvas-stage geometry-stage"><canvas id="geometry-canvas" width="1200" height="700" aria-label="几何画板"></canvas></div>
<p id="geometry-hint" class="canvas-hint">点击画布创建第一个点。</p>
</div>
<div class="tool-workspace" id="tool-latex">
@@ -379,6 +484,28 @@
</main>
</div>
<section id="story-experience" class="story-experience hidden" aria-label="数学人生体验">
<header class="story-experience-header">
<div>
<span class="kicker">MATHEMATICAL LIFE</span>
<h1 id="story-experience-title">信仰者人生</h1>
</div>
<button id="story-experience-close" class="ghost-button">保存并返回大厅</button>
</header>
<div class="story-chapter-bar">
<div><span id="story-chapter-number">第 1 章</span><b id="story-chapter-title">人生起点</b></div>
<div class="quiz-progress"><i id="story-chapter-progress"></i></div>
</div>
<div class="story-experience-layout">
<main id="story-experience-content" class="story-scene-panel"></main>
<aside class="story-mark-panel">
<div><span class="kicker">MARK ARCHIVE</span><h2>数学印记</h2></div>
<div id="story-domain-counts" class="story-domain-counts"></div>
<div id="story-mark-list" class="story-mark-list"></div>
</aside>
</div>
</section>
<dialog id="auth-dialog">
<button class="dialog-close" aria-label="关闭">×</button>
<div class="dialog-tabs"><button class="active" data-auth-tab="login">登录</button><button data-auth-tab="register">邀请码注册</button></div>
@@ -413,6 +540,7 @@
<div class="toast" id="toast" role="status"></div>
<div class="csrf-token">{% csrf_token %}</div>
<script src="{% static 'vendor/katex/katex.min.js' %}" defer></script>
<script src="{% static 'js/graph.js' %}" defer></script>
<script src="{% static 'js/toolbox.js' %}" defer></script>
<script src="{% static 'js/games.js' %}" defer></script>
<script src="{% static 'js/realtime.js' %}" defer></script>
+20
View File
@@ -0,0 +1,20 @@
from django.contrib import admin
from .models import BoardSession
@admin.register(BoardSession)
class BoardSessionAdmin(admin.ModelAdmin):
list_display = (
"code",
"mode",
"host",
"guest",
"status",
"guest_score",
"created_at",
)
list_filter = ("mode", "status")
search_fields = ("code", "host__username", "guest__username")
readonly_fields = ("code", "target", "created_at", "completed_at")
ordering = ("-created_at",)
+147
View File
@@ -0,0 +1,147 @@
import secrets
from datetime import timedelta
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from .models import BoardSession
BOARD_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
BOARD_TTL = timedelta(minutes=30)
DRAW_GUESS_TARGETS = (
"抛物线",
"三角形",
"勾股定理",
"质数",
"",
"正弦函数",
"分数",
"坐标系",
)
def _new_board_code():
for _ in range(20):
code = "".join(secrets.choice(BOARD_CODE_ALPHABET) for _ in range(6))
if not BoardSession.objects.filter(code=code).exists():
return code
raise ValidationError("暂时无法生成画板联机码,请稍后重试")
def broadcast_board(session_id, reason):
channel_layer = get_channel_layer()
if channel_layer is None:
return
async_to_sync(channel_layer.group_send)(
f"board_{session_id}",
{
"type": "board.state",
"reason": reason,
},
)
def board_payload(session, user):
is_host = session.host_id == user.id
reveal_target = (
session.mode == BoardSession.Mode.DRAW_GUESS
and (is_host or session.status == BoardSession.Status.COMPLETED)
)
return {
"session_id": session.id,
"code": session.code,
"mode": session.mode,
"mode_label": session.get_mode_display(),
"status": session.status,
"role": "host" if is_host else "guest",
"target": session.target if reveal_target else None,
"host": session.host.nickname,
"guest": session.guest.nickname if session.guest else None,
"host_score": session.host_score,
"guest_score": session.guest_score,
"expires_at": session.expires_at,
"websocket_path": f"/ws/v1/toolbox/boards/{session.id}/",
}
@transaction.atomic
def create_board(user, mode):
if mode not in BoardSession.Mode.values:
raise ValidationError({"mode": "不支持的画板联机模式"})
BoardSession.objects.filter(
host=user,
status=BoardSession.Status.WAITING,
).update(status=BoardSession.Status.CANCELLED)
return BoardSession.objects.create(
code=_new_board_code(),
mode=mode,
host=user,
target=(
secrets.choice(DRAW_GUESS_TARGETS)
if mode == BoardSession.Mode.DRAW_GUESS
else ""
),
expires_at=timezone.now() + BOARD_TTL,
)
@transaction.atomic
def join_board(user, code):
normalized = str(code or "").strip().upper()
if len(normalized) != 6 or any(
character not in BOARD_CODE_ALPHABET for character in normalized
):
raise ValidationError({"code": "画板联机码应为 6 位大写字母或数字"})
try:
session = (
BoardSession.objects.select_for_update()
.select_related("host", "guest")
.get(code=normalized)
)
except BoardSession.DoesNotExist as exc:
raise ValidationError({"code": "画板联机码不存在"}) from exc
if session.host_id == user.id:
raise ValidationError({"code": "不能加入自己创建的画板"})
if session.status != BoardSession.Status.WAITING:
raise ValidationError({"code": "画板联机码已失效或已被使用"})
if session.expires_at <= timezone.now():
session.status = BoardSession.Status.CANCELLED
session.save(update_fields=["status"])
raise ValidationError({"code": "画板联机码已经过期"})
session.guest = user
session.status = BoardSession.Status.ACTIVE
session.save(update_fields=["guest", "status"])
transaction.on_commit(lambda: broadcast_board(session.id, "joined"))
return session
@transaction.atomic
def submit_guess(user, session_id, raw_guess):
session = (
BoardSession.objects.select_for_update()
.select_related("host", "guest")
.get(id=session_id)
)
if session.guest_id != user.id:
raise ValidationError("只有猜题方可以提交答案")
if (
session.mode != BoardSession.Mode.DRAW_GUESS
or session.status != BoardSession.Status.ACTIVE
):
raise ValidationError("当前画板不接受猜题")
guess = str(raw_guess or "").strip()
if not guess or len(guess) > 40:
raise ValidationError({"guess": "请输入不超过 40 个字符的数学对象"})
correct = guess.replace(" ", "").lower() == session.target.replace(" ", "").lower()
if correct:
session.guest_score += 1
session.status = BoardSession.Status.COMPLETED
session.completed_at = timezone.now()
session.save(
update_fields=["guest_score", "status", "completed_at"]
)
transaction.on_commit(lambda: broadcast_board(session.id, "completed"))
return session, correct
+100
View File
@@ -0,0 +1,100 @@
import json
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from .models import BoardSession
class BoardConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
self.session_id = self.scope["url_route"]["kwargs"]["session_id"]
self.group_name = f"board_{self.session_id}"
user = self.scope["user"]
if not user.is_authenticated or not await self._is_participant(user.id):
await self.close(code=4403)
return
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
await self.send_json(
{
"type": "connected",
"session_id": str(self.session_id),
}
)
async def disconnect(self, close_code):
if hasattr(self, "group_name"):
await self.channel_layer.group_discard(
self.group_name,
self.channel_name,
)
async def receive_json(self, content, **kwargs):
event_type = content.get("type")
if event_type == "ping":
await self.send_json({"type": "pong"})
return
if event_type not in {"canvas", "geometry"}:
await self.send_json({"type": "error", "message": "不支持的画板消息"})
return
if not await self._is_active():
await self.send_json({"type": "error", "message": "画板尚未开始或已经结束"})
return
payload = content.get("payload")
if event_type == "canvas":
valid = (
isinstance(payload, str)
and payload.startswith("data:image/")
and len(payload) <= 700_000
)
else:
valid = isinstance(payload, dict) and len(
json.dumps(payload, ensure_ascii=False)
) <= 100_000
if not valid:
await self.send_json({"type": "error", "message": "画板消息无效或过大"})
return
await self.channel_layer.group_send(
self.group_name,
{
"type": "board.update",
"event_type": event_type,
"payload": payload,
"user_id": str(self.scope["user"].id),
},
)
async def board_update(self, event):
await self.send_json(
{
"type": event["event_type"],
"payload": event["payload"],
"user_id": event["user_id"],
}
)
async def board_state(self, event):
await self.send_json(
{
"type": "state",
"reason": event["reason"],
"session_id": str(self.session_id),
}
)
@database_sync_to_async
def _is_participant(self, user_id):
return BoardSession.objects.filter(id=self.session_id).filter(
host_id=user_id
).exists() or BoardSession.objects.filter(
id=self.session_id,
guest_id=user_id,
).exists()
@database_sync_to_async
def _is_active(self):
return BoardSession.objects.filter(
id=self.session_id,
status=BoardSession.Status.ACTIVE,
).exists()
+136 -3
View File
@@ -1,13 +1,17 @@
import ast
import math
from statistics import mean, median, pstdev, pvariance
from statistics import mean, median, pstdev, pvariance, quantiles
import sympy as sp
from mpmath.libmp.libhyper import NoConvergence
from rest_framework.exceptions import ValidationError
MAX_EXPRESSION_LENGTH = 500
MAX_AST_NODES = 120
MAX_MATRIX_CELLS = 36
MAX_SERIES_ORDER = 12
MAX_SYSTEM_EQUATIONS = 6
MAX_POLYNOMIAL_DEGREE = 12
SYMBOLS = {name: sp.Symbol(name, real=True) for name in ("x", "y", "z", "a", "b", "t", "n")}
CONSTANTS = {"pi": sp.pi, "e": sp.E, "E": sp.E, "i": sp.I, "I": sp.I}
FUNCTIONS = {
@@ -188,6 +192,31 @@ def parse_number_list(source):
return values
def parse_variables(raw_variables, fallback="x"):
names = [
item.strip()
for item in str(raw_variables or fallback).split(",")
if item.strip()
]
if not names or len(names) > 4 or len(set(names)) != len(names):
raise ValidationError({"variables": "变量应为 1 到 4 个不重复名称"})
try:
return [SYMBOLS[name] for name in names]
except KeyError as exc:
raise ValidationError(
{"variables": "变量仅支持 x、y、z、a、b、t、n"}
) from exc
def parse_equation_system(source):
parts = [item.strip() for item in str(source or "").split(";") if item.strip()]
if not 1 <= len(parts) <= MAX_SYSTEM_EQUATIONS:
raise ValidationError(
{"expression": "方程组应使用分号分隔,最多支持 6 个方程"}
)
return [parse_equation(item) for item in parts]
def calculate(payload):
operation = str(payload.get("operation", "calculate"))
source = payload.get("expression", "")
@@ -198,14 +227,23 @@ def calculate(payload):
if operation == "statistics":
values = parse_number_list(source)
quartile_values = (
quantiles(values, n=4, method="inclusive")
if len(values) > 1
else [values[0], values[0], values[0]]
)
result = {
"count": len(values),
"sum": sum(values),
"mean": mean(values),
"median": median(values),
"variance": pvariance(values),
"standard_deviation": pstdev(values),
"minimum": min(values),
"q1": quartile_values[0],
"q3": quartile_values[2],
"maximum": max(values),
"range": max(values) - min(values),
}
return {
"operation": operation,
@@ -278,11 +316,43 @@ def calculate(payload):
elif operation == "matrix_transpose":
result = matrix.T
steps = ["读取矩阵", "交换行列"]
elif operation == "matrix_rank":
result = matrix.rank()
steps = ["读取矩阵", "执行行变换", "计算矩阵秩"]
elif operation == "matrix_nullspace":
result = matrix.nullspace()
steps = ["读取矩阵", "求解齐次线性方程组", "得到零空间基"]
elif operation == "matrix_eigenvalues":
if not matrix.is_square:
raise ValidationError({"expression": "特征值要求方阵"})
result = matrix.eigenvals()
steps = ["读取方阵", "构造特征多项式", "计算特征值及重数"]
else:
raise ValidationError({"operation": "不支持的矩阵操作"})
return {"operation": operation, "result": serialize_math(result), "steps": steps}
expression = parse_equation(source) if operation == "solve" else parse_expression(source)
if operation == "solve_system":
variables = parse_variables(payload.get("variables"), variable_name)
equations = parse_equation_system(source)
result = sp.solve(equations, variables, dict=True)
if len(result) > 50:
raise ValidationError({"expression": "方程组解的数量过多"})
return {
"operation": operation,
"result": serialize_math(result),
"steps": [
f"读取 {len(equations)} 个方程",
f"{', '.join(str(item) for item in variables)} 为未知量",
"联立消元并求解",
],
}
equation_operations = {"solve", "polynomial_roots"}
expression = (
parse_equation(source)
if operation in equation_operations
else parse_expression(source)
)
steps = ["解析受限数学表达式"]
if operation == "calculate":
result = sp.simplify(expression)
@@ -301,8 +371,39 @@ def calculate(payload):
if len(result) > 50:
raise ValidationError({"expression": "解的数量过多"})
steps.extend([f"{variable_name} 为未知量", "求解方程"])
elif operation == "polynomial_roots":
polynomial_expression = (
expression.lhs - expression.rhs
if isinstance(expression, sp.Equality)
else expression
)
if polynomial_expression.free_symbols - {variable}:
raise ValidationError({"expression": "数值求根仅支持所选变量"})
try:
polynomial = sp.Poly(polynomial_expression, variable)
except sp.PolynomialError as exc:
raise ValidationError({"expression": "请输入单变量多项式"}) from exc
if not 1 <= polynomial.degree() <= MAX_POLYNOMIAL_DEGREE:
raise ValidationError(
{"expression": "数值求根支持 1 到 12 次单变量多项式"}
)
try:
result = list(sp.nroots(polynomial, n=12, maxsteps=100))
except NoConvergence as exc:
raise ValidationError(
{"expression": "数值求根未收敛,请简化多项式后重试"}
) from exc
steps.extend(
[
f"构造 {polynomial.degree()} 次多项式",
"使用高精度数值方法计算全部根",
]
)
elif operation == "derivative":
order = int(payload.get("order", 1))
try:
order = int(payload.get("order", 1))
except (TypeError, ValueError) as exc:
raise ValidationError({"order": "导数阶数必须是整数"}) from exc
if not 1 <= order <= 5:
raise ValidationError({"order": "导数阶数必须在 1 到 5 之间"})
result = sp.diff(expression, variable, order)
@@ -328,6 +429,38 @@ def calculate(payload):
raise ValidationError({"direction": "极限方向必须为 +、- 或 +-"})
result = sp.limit(expression, variable, point, dir=direction)
steps.append(f"{variable_name} 趋近 {point}")
elif operation == "series":
point = parse_expression(payload.get("point", "0"))
try:
order = int(payload.get("order", 6))
except (TypeError, ValueError) as exc:
raise ValidationError({"order": "级数展开阶数必须是整数"}) from exc
if not 1 <= order <= MAX_SERIES_ORDER:
raise ValidationError(
{"order": f"级数展开阶数必须在 1 到 {MAX_SERIES_ORDER} 之间"}
)
result = sp.series(expression, variable, point, order)
steps.append(
f"{variable_name} = {point} 附近展开到 {order - 1}"
)
elif operation == "gradient":
variables = parse_variables(payload.get("variables"), variable_name)
result = sp.Matrix([sp.diff(expression, item) for item in variables])
steps.extend(
[
f"选取变量 {', '.join(str(item) for item in variables)}",
"分别计算一阶偏导并组成梯度",
]
)
elif operation == "hessian":
variables = parse_variables(payload.get("variables"), variable_name)
result = sp.hessian(expression, variables)
steps.extend(
[
f"选取变量 {', '.join(str(item) for item in variables)}",
"计算全部二阶偏导并组成 Hessian 矩阵",
]
)
else:
raise ValidationError({"operation": "不支持的计算类型"})
return {"operation": operation, "result": serialize_math(result), "steps": steps}
@@ -0,0 +1,38 @@
# Generated by Django 4.2.23 on 2026-08-09 16:35
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BoardSession',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('code', models.CharField(max_length=6, unique=True)),
('mode', models.CharField(choices=[('collaborate', '协作展示'), ('draw_guess', '数学你画我猜')], default='collaborate', max_length=16)),
('target', models.CharField(blank=True, max_length=40)),
('host_score', models.PositiveSmallIntegerField(default=0)),
('guest_score', models.PositiveSmallIntegerField(default=0)),
('status', models.CharField(choices=[('waiting', '等待加入'), ('active', '进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='waiting', max_length=16)),
('expires_at', models.DateTimeField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('guest', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='joined_board_sessions', to=settings.AUTH_USER_MODEL)),
('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hosted_board_sessions', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]
+53
View File
@@ -0,0 +1,53 @@
import uuid
from django.conf import settings
from django.db import models
class BoardSession(models.Model):
class Mode(models.TextChoices):
COLLABORATE = "collaborate", "协作展示"
DRAW_GUESS = "draw_guess", "数学你画我猜"
class Status(models.TextChoices):
WAITING = "waiting", "等待加入"
ACTIVE = "active", "进行中"
COMPLETED = "completed", "已完成"
CANCELLED = "cancelled", "已取消"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
code = models.CharField(max_length=6, unique=True)
mode = models.CharField(
max_length=16,
choices=Mode.choices,
default=Mode.COLLABORATE,
)
host = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="hosted_board_sessions",
)
guest = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="joined_board_sessions",
)
target = models.CharField(max_length=40, blank=True)
host_score = models.PositiveSmallIntegerField(default=0)
guest_score = models.PositiveSmallIntegerField(default=0)
status = models.CharField(
max_length=16,
choices=Status.choices,
default=Status.WAITING,
)
expires_at = models.DateTimeField()
created_at = models.DateTimeField(auto_now_add=True)
completed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.code} · {self.get_mode_display()}"
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from .consumers import BoardConsumer
websocket_urlpatterns = [
path(
"ws/v1/toolbox/boards/<uuid:session_id>/",
BoardConsumer.as_asgi(),
),
]
+78
View File
@@ -0,0 +1,78 @@
from datetime import timedelta
import pytest
from asgiref.sync import async_to_sync
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.urls import path
from django.utils import timezone
from accounts.models import User
from toolbox.consumers import BoardConsumer
from toolbox.models import BoardSession
@pytest.mark.django_db(transaction=True)
def test_board_consumer_参与者同步画布并拒绝局外人():
host = User.objects.create_user(
username="board_socket_host",
password="StrongPass_2026",
nickname="WS 房主",
)
guest = User.objects.create_user(
username="board_socket_guest",
password="StrongPass_2026",
nickname="WS 访客",
)
outsider = User.objects.create_user(
username="board_socket_outsider",
password="StrongPass_2026",
nickname="WS 局外人",
)
session = BoardSession.objects.create(
code="ABC234",
host=host,
guest=guest,
status=BoardSession.Status.ACTIVE,
expires_at=timezone.now() + timedelta(minutes=30),
)
application = URLRouter(
[
path(
"ws/test/<uuid:session_id>/",
BoardConsumer.as_asgi(),
)
]
)
async def scenario():
outsider_socket = WebsocketCommunicator(
application,
f"/ws/test/{session.id}/",
)
outsider_socket.scope["user"] = outsider
connected, close_code = await outsider_socket.connect()
assert not connected
assert close_code == 4403
host_socket = WebsocketCommunicator(application, f"/ws/test/{session.id}/")
guest_socket = WebsocketCommunicator(application, f"/ws/test/{session.id}/")
host_socket.scope["user"] = host
guest_socket.scope["user"] = guest
assert (await host_socket.connect())[0]
assert (await guest_socket.connect())[0]
assert (await host_socket.receive_json_from())["type"] == "connected"
assert (await guest_socket.receive_json_from())["type"] == "connected"
payload = "data:image/png;base64,AAAA"
await host_socket.send_json_to({"type": "canvas", "payload": payload})
host_event = await host_socket.receive_json_from()
guest_event = await guest_socket.receive_json_from()
assert host_event["payload"] == payload
assert guest_event["payload"] == payload
assert guest_event["user_id"] == str(host.id)
await host_socket.disconnect()
await guest_socket.disconnect()
async_to_sync(scenario)()
+102
View File
@@ -49,6 +49,108 @@ def test_calculate_方程矩阵统计与进制():
assert combinations["result"]["exact"] == "120"
def test_calculate_高级微积分与多元分析():
series = calculate(
{
"operation": "series",
"expression": "exp(x)",
"variable": "x",
"point": "0",
"order": 5,
}
)
gradient = calculate(
{
"operation": "gradient",
"expression": "x^2*y + sin(y)",
"variables": "x,y",
}
)
hessian = calculate(
{
"operation": "hessian",
"expression": "x^2 + x*y + y^2",
"variables": "x,y",
}
)
roots = calculate(
{
"operation": "polynomial_roots",
"expression": "x^3 - 1 = 0",
"variable": "x",
}
)
assert "x**4/24" in series["result"]["exact"]
assert gradient["result"]["exact"] == "[[2*x*y], [x**2 + cos(y)]]"
assert hessian["result"]["exact"] == "[[2, 1], [1, 2]]"
assert len(roots["result"]) == 3
def test_calculate_线性代数与方程组():
system = calculate(
{
"operation": "solve_system",
"expression": "x + y = 5; x - y = 1",
"variables": "x,y",
}
)
rank = calculate(
{"operation": "matrix_rank", "expression": "1,2,3;2,4,6"}
)
nullspace = calculate(
{"operation": "matrix_nullspace", "expression": "1,2;2,4"}
)
eigenvalues = calculate(
{"operation": "matrix_eigenvalues", "expression": "2,0;0,3"}
)
assert system["result"][0]["x"]["exact"] == "3"
assert system["result"][0]["y"]["exact"] == "2"
assert rank["result"]["exact"] == "1"
assert nullspace["result"][0]["exact"] == "[[-2], [1]]"
assert set(eigenvalues["result"]) == {"2", "3"}
def test_calculate_统计包含四分位数与极差():
result = calculate(
{"operation": "statistics", "expression": "1,2,3,4,5"}
)["result"]
assert result["sum"] == 15
assert result["q1"] == 2
assert result["q3"] == 4
assert result["range"] == 4
@pytest.mark.parametrize(
("payload", "field"),
[
(
{
"operation": "series",
"expression": "exp(x)",
"order": "not-an-integer",
},
"order",
),
(
{
"operation": "polynomial_roots",
"expression": "x + y",
"variable": "x",
},
"expression",
),
],
)
def test_calculate_高级操作返回可读校验错误(payload, field):
with pytest.raises(ValidationError) as exc_info:
calculate(payload)
assert field in exc_info.value.detail
@pytest.mark.parametrize(
"source",
[
+54
View File
@@ -1,5 +1,8 @@
import pytest
from accounts.models import User
from toolbox.models import BoardSession
@pytest.mark.django_db
def test_calculator_api_公开访问并返回精确值(client):
@@ -23,3 +26,54 @@ def test_calculator_api_危险表达式返回四百(client):
assert response.status_code == 400
assert "error" in response.json()
@pytest.mark.django_db
def test_board_api_联机码加入你画我猜并服务端计分(client):
host = User.objects.create_user(
username="board_host",
password="StrongPass_2026",
nickname="画板房主",
)
guest = User.objects.create_user(
username="board_guest",
password="StrongPass_2026",
nickname="猜题玩家",
)
client.force_login(host)
created = client.post(
"/api/v1/toolbox/boards/",
{"mode": "draw_guess"},
content_type="application/json",
)
target = created.json()["target"]
code = created.json()["code"]
session_id = created.json()["session_id"]
client.force_login(guest)
joined = client.post(
"/api/v1/toolbox/boards/join/",
{"code": code.lower()},
content_type="application/json",
)
wrong = client.post(
f"/api/v1/toolbox/boards/{session_id}/guess/",
{"guess": "不是答案"},
content_type="application/json",
)
correct = client.post(
f"/api/v1/toolbox/boards/{session_id}/guess/",
{"guess": target},
content_type="application/json",
)
assert created.status_code == 201
assert created.json()["role"] == "host"
assert target
assert joined.status_code == 200
assert joined.json()["target"] is None
assert joined.json()["status"] == BoardSession.Status.ACTIVE
assert wrong.json()["correct"] is False
assert correct.json()["correct"] is True
assert correct.json()["guest_score"] == 1
assert correct.json()["status"] == BoardSession.Status.COMPLETED
+15 -1
View File
@@ -1,7 +1,21 @@
from django.urls import path
from .views import CalculatorView
from .views import (
BoardCreateView,
BoardGuessView,
BoardJoinView,
BoardStateView,
CalculatorView,
)
urlpatterns = [
path("calculate/", CalculatorView.as_view(), name="toolbox-calculate"),
path("boards/", BoardCreateView.as_view(), name="board-create"),
path("boards/join/", BoardJoinView.as_view(), name="board-join"),
path("boards/<uuid:session_id>/", BoardStateView.as_view(), name="board-state"),
path(
"boards/<uuid:session_id>/guess/",
BoardGuessView.as_view(),
name="board-guess",
),
]
+48
View File
@@ -1,10 +1,19 @@
from django.db.models import Q
from django.shortcuts import get_object_or_404
from rest_framework import permissions
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .board_services import (
board_payload,
create_board,
join_board,
submit_guess,
)
from .engine import calculate
from .models import BoardSession
class CalculatorView(APIView):
@@ -20,3 +29,42 @@ class CalculatorView(APIView):
except (ArithmeticError, NotImplementedError, TypeError, ValueError) as exc:
raise ValidationError({"expression": "该计算暂时无法完成,请缩小表达式范围"}) from exc
return Response(payload)
class BoardCreateView(APIView):
def post(self, request):
session = create_board(
request.user,
request.data.get("mode", BoardSession.Mode.COLLABORATE),
)
return Response(board_payload(session, request.user), status=201)
class BoardJoinView(APIView):
def post(self, request):
session = join_board(request.user, request.data.get("code"))
return Response(board_payload(session, request.user))
class BoardStateView(APIView):
def get(self, request, session_id):
session = get_object_or_404(
BoardSession.objects.select_related("host", "guest").filter(
Q(host=request.user) | Q(guest=request.user)
),
id=session_id,
)
return Response(board_payload(session, request.user))
class BoardGuessView(APIView):
def post(self, request, session_id):
session, correct = submit_guess(
request.user,
session_id,
request.data.get("guess"),
)
payload = board_payload(session, request.user)
payload["correct"] = correct
payload["message"] = "猜对了,得 1 分" if correct else "还不对,再观察一下画板"
return Response(payload)
+55
View File
@@ -0,0 +1,55 @@
# MathBTI v1.2 五维评分依据
本文件解释 16 位数学家在 v1.2 中的五维初始分。分数用于产品中的人物画像,
不是对数学家成就、人格或历史地位的排名。
## 评分口径
- **眼光**:提出新问题、识别新结构或开创新方向的能力。
- **人文**:教学、传播、公共影响及克服时代壁垒的证据。
- **侦探**:证明、校验、发现隐藏条件和纠正错误的能力。
- **建模**:把现实、物理、工程或计算问题转化为数学的能力。
- **联结**:连接不同数学分支,或连接数学与其他学科的能力。
采用 0–100 的产品量表。入选人物均已有重要历史贡献,因此最低分不低于 76;
每人至少一项达到 90。相差 1–3 分不表示严格可测的能力差距,只用于表达证据重心。
## 评分表
| 人物 | 眼光 | 人文 | 侦探 | 建模 | 联结 | 主要依据 |
| --- | ---: | ---: | ---: | ---: | ---: | --- |
| 希帕提娅 | 88 | 95 | 84 | 78 | 87 | 保存并讲授丢番图、阿波罗尼奥斯与天文学传统,兼具数学家、教师和哲学家身份 |
| 庞加莱 | 98 | 88 | 94 | 91 | 99 | 拓扑、动力系统、微分方程、天体力学及科学哲学之间的系统联结 |
| 赵爽 | 90 | 86 | 92 | 88 | 84 | 以弦图和出入相补法论证勾股关系,并处理测日等几何问题 |
| 凯瑟琳·约翰逊 | 89 | 94 | 94 | 99 | 92 | 轨道、再入和会合计算;人工复核电子计算机结果;突破种族与性别壁垒 |
| 秦九韶 | 91 | 86 | 96 | 98 | 90 | 大衍求一术、正负开方术及《数书九章》中的历法、工程和赋税问题 |
| 冯·诺依曼 | 99 | 86 | 96 | 100 | 100 | 数学基础、量子力学、博弈论、计算机体系结构和数值计算的跨域工作 |
| 牛顿 | 99 | 82 | 98 | 99 | 100 | 微积分、级数、光学、力学和万有引力的统一数学框架 |
| 图灵 | 98 | 92 | 99 | 100 | 99 | 可计算性、密码分析、计算机设计和形态发生模型 |
| 高斯 | 100 | 80 | 100 | 99 | 99 | 数论、代数、几何、天文轨道、测地和电磁学的高强度原创与校验 |
| 欧拉 | 99 | 91 | 98 | 98 | 100 | 分析、数论、图论、力学和流体等领域的统一记号与方法 |
| 祖冲之 | 93 | 88 | 96 | 99 | 91 | 圆周率界、历法、天文周期、机械与测量计算 |
| 埃米·诺特 | 100 | 94 | 99 | 82 | 100 | 抽象代数结构与物理守恒律/对称性的根本联结,并长期教学传播 |
| 斐波那契 | 90 | 96 | 84 | 93 | 98 | 将印度—阿拉伯数字和商业算法系统带入拉丁欧洲 |
| 伽罗瓦 | 98 | 91 | 100 | 76 | 99 | 以群结构刻画方程可解性,建立代数不同对象之间的新联系 |
| 华罗庚 | 96 | 98 | 98 | 99 | 95 | 解析数论、中国数学学派建设,以及优选法、统筹法的工业推广 |
| Ada Lovelace | 98 | 95 | 90 | 94 | 100 | 认识分析机可处理数字之外的符号,写出算法并连接计算、音乐与科学想象 |
## 可追溯来源
- MacTutor 数学史人物档案:<https://mathshistory.st-andrews.ac.uk/Biographies/>
- NASA Katherine Johnson 官方人物档案:
<https://science.nasa.gov/people/katherine-johnson/>
- NASA Katherine G. Johnson 工作档案:
<https://www.nasa.gov/people-of-nasa/katherine-g-johnson/>
- Encyclopaedia BritannicaHypatia
<https://www.britannica.com/biography/Hypatia>
- Encyclopaedia BritannicaIsaac Newton
<https://www.britannica.com/biography/Isaac-Newton>
- 中国科学院与华罗庚相关公开资料入口:
<https://www.cas.cn/zt/rwzt/gwcyz/>
- 《隋书·律历志》关于祖冲之圆周率上下界与历法的记载,可结合中华书局点校本核对。
- 秦九韶《数书九章》、赵爽《周髀算经注》是两位古代数学家的主要一手文献。
评分发生争议时,应先核对上述资料中的具体贡献,再调整对应维度,不因知名度直接
提高或降低全部分数。
+97
View File
@@ -0,0 +1,97 @@
# Hulumath v1.2.0 Release Notes
发布日期:待 `release/v1.2.0` 合并并通过生产冒烟后填写。
## 版本主题
v1.2 将比赛、数学画板和数学人生从首发功能升级为可持续使用的完整闭环,同时清理
账号、档案、LaTeX 和本地调试中的 P0 问题。
## 主要变化
### 比赛与记录
- 取消比赛难度入口,实时匹配使用统一玩家池。
- 每局从完整题库生成不可变随机快照;历史 Attempt 不受影响。
- 题库包含入门 400、标准 400、进阶 200 道基础题。
- 实时匹配新增数独 Timerun 与 24 点竞速。
- 三种玩法共享联机码、WebSocket 状态、服务端计时、Rating 和历史记录。
- 结算响应立即刷新右上角 Rating。
- 首页和“我的”展示实时比赛历史、对手、结果、时间和 Rating 变化。
### 数学工具箱
- 数学白板与几何画板合并为“数学画板”单入口。
- 支持 6 位联机码、白板快照和几何状态实时同步。
- 新增数学你画我猜:服务端出题、猜题判定和计分。
- 函数绘图增加语法规则、示例和错误反馈。
- LaTeX 长公式横向滚动,保存按钮保持在独立操作行。
### 数学人生与 MathBTI
- 信仰者人生从弹窗升级为独立全屏体验,提供章节、进度和印记档案。
- `xinzhi``shuli``xiayi` 旧数值完全退出 v2 剧本。
- 新增 4 领域 × 5 个印记,共 20 个永久收藏印记。
- 新增高斯、祖冲之、欧拉、诺特 4 个组合彩蛋。
- 统一直博结局按单次人生中印记最多的领域分发到数论、代数几何、分析或应用数学。
- 16 位数学家五维评分重新评定,并提供史料和评分口径文档。
- 清理数学人生大厅的“数学人生交叉点”和背景圆球。
### 账号、入口与运维
- 登录或注册成功后,“我的”页面立即刷新。
- 新增仅限 `DEBUG=true` 的本地管理员初始化命令:`make local-admin`
- 首页新增公众号入口,可复制“葫芦数学”并尝试唤起微信。
- `/health/` 返回 `version: "1.2.0"`
- 生产静态资源检查、数据库备份、迁移、HTTP/WebSocket 冒烟和失败回退保持启用。
## Issue 对照
| Issue | 状态 | v1.2 验收位置 |
| --- | --- | --- |
| #10 | 完成 | 统一玩家池、前端移除难度 |
| #15 | 完成 | 数学你画我猜 |
| #17 | 完成 | LaTeX 三段式布局与长公式滚动 |
| #18 | 完成 | 大厅残留元素清理 |
| #21 | 完成 | Rating 即时刷新 |
| #22 | 完成 | Attempt 随机题目快照 |
| #24 | 并入 #36 | 独立剧情界面;需仓库所有者手动关闭 #24 |
| #25 | 完成 | 首页与档案比赛记录 |
| #26 | 完成 | 工具按计算、查询、绘图、画板、表达排序 |
| #27 | 完成 | 统一画板与联机码 |
| #28 | 完成 | 1000 道分层题库 |
| #29 | 完成 | 数独与 24 点实时竞速 |
| #30 | 完成 | 主页公众号入口 |
| #31 | 完成 | 本地管理员可登录 |
| #32 | 完成 | 登录后档案刷新 |
| #33 | 完成 | 函数语法说明 |
| #34 | 完成 | 安全的本地管理员初始化方案 |
| #35 | 完成 | 16 位数学家五维重评与依据 |
| #36 | 完成 | 20 印记、4 彩蛋、4 结局、独立界面 |
## 数据库迁移
- Contest Attempt 新增题目快照。
- Realtime Match 新增玩法类型;Math Game Attempt 可关联实时比赛。
- ContestQuestion 题池扩充为当前赛道的全部最新有效题目。
- 新增 Toolbox BoardSession。
- 新增 StoryMark,并发布信仰者人生 StoryVersion v2。
- 旧信仰者 v1 取消发布;旧进行中存档标记为已放弃,历史选择保留。
## 发布检查
```bash
make check
make test
cd backend && ../.venv/bin/python manage.py migrate --plan
cd backend && ../.venv/bin/python manage.py collectstatic --noinput
```
生产部署后检查:
```text
/health/ version == 1.2.0
首页、后台、视频目录正常
联机比赛 WebSocket 正常
统一画板 WebSocket 正常
```
+58
View File
@@ -0,0 +1,58 @@
# Hulumath v1.2.1 Release Notes
发布日期:待功能分支合并并通过生产冒烟后填写。
## 版本主题
v1.2.1 聚焦数学工具箱:按实际任务重新分类入口,扩展符号计算能力,并将函数绘图升级为可交互、响应式的函数探索器。数学画板与 LaTeX Lab 保持 v1.2.0 的行为。
## 主要变化
### 工具分类
- 删除工具箱中的“口算竞技”入口;口算玩法仍归属比赛模块。
- 工具按“计算与探索”“知识与表达”“创作与协作”分组。
- 高级计算工作台按代数、微积分、线性代数、数据换算提供分类切换。
### 高级计算工作台
- 新增 Taylor/Laurent 级数、梯度、Hessian 矩阵。
- 新增多元方程组求解和 1 至 12 次多项式数值求根。
- 新增矩阵秩、零空间基、特征值及代数重数。
- 描述统计新增总和、四分位数和极差。
- 继续使用受限 AST 与 SymPy 内核,不执行用户输入代码。
### 函数探索器
- 动态增删最多 8 条曲线,每条曲线可独立显示、隐藏和设置颜色。
- X/Y 视窗独立输入,支持自动 Y 范围与重置视窗。
- Canvas 随容器响应式缩放,并按设备像素比生成清晰位图。
- 支持拖动平移、滚轮或触控板缩放、双击自动取景。
- 坐标轴增加刻度、数值标签和方向箭头。
- 保留参数 `a`、第一条曲线导数、积分区域、近似零点和极值分析。
- 支持 `sqrt(x)` 等只在部分视窗有定义的函数,不因单个采样点无效而中止绘图。
## 兼容与迁移
- 本版本没有数据库迁移。
- 画板 WebSocket、LaTeX 文档和已保存数据结构不变。
- 发布时仍须先执行 `collectstatic`,成功后再执行 `migrate`
## 发布检查
```bash
make check
make test
cd backend && ../.venv/bin/python manage.py collectstatic --noinput
cd backend && ../.venv/bin/python manage.py migrate --noinput
```
生产部署后检查:
```text
/health/ version == 1.2.1
高级计算分类与示例可运行
函数列表可动态增删
桌面和手机端坐标轴、缩放与平移正常
数学画板与 LaTeX Lab 回归正常
```
+7 -2
View File
@@ -31,7 +31,11 @@ def main():
args = parser.parse_args()
health = json.loads(fetch(args.base_url, "/health/"))
if health != {"status": "ok", "database": "ok"}:
if (
health.get("status") != "ok"
or health.get("database") != "ok"
or not health.get("version")
):
raise RuntimeError(f"unexpected health payload: {health}")
homepage = fetch(args.base_url, "/").decode("utf-8")
@@ -49,7 +53,8 @@ def main():
asyncio.run(check_websocket(args.ws_url))
print(
"Production smoke checks passed: "
f"health, homepage, admin, {catalog['total']} videos, websocket"
f"health v{health['version']}, homepage, admin, "
f"{catalog['total']} videos, websocket"
)