feat: rebuild math life around marks and directed endings

This commit is contained in:
2026-08-10 01:06:06 +08:00
parent b8916180a9
commit aafaf1b77a
14 changed files with 838 additions and 29 deletions
+15
View File
@@ -165,3 +165,18 @@ def test_contest_统一玩家池并提供三种实时玩法():
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
+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"]
+10
View File
@@ -104,5 +104,15 @@ 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")
],
}
)
+13 -4
View File
@@ -176,6 +176,7 @@ button { color: inherit; }
.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; }
.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); }
@@ -268,6 +269,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); }
@@ -295,6 +306,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
.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); }
.tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; }
.math-games-section { margin-top: 45px; }.game-card { min-height: 260px; padding: 21px; }.game-card-controls { align-items: stretch; }.game-card-controls .primary-button { flex: 1; }.twenty-four-numbers { gap: 7px; }.twenty-four-numbers button { border-radius: 13px; font-size: 28px; }.sudoku-board input { font-size: clamp(13px, 4.5vw, 20px); }.sudoku-actions { display: grid; grid-template-columns: 1fr 1fr; }.game-keypad { gap: 5px; }
.challenge-panel { grid-template-columns: 1fr; padding: 20px; }.challenge-actions form { grid-template-columns: 1fr; }.challenge-actions .dark-button { width: 100%; }.challenge-code { width: 100%; padding: 14px 10px; font-size: 27px; }.realtime-status-line { display: grid; }.realtime-progress-panel { grid-template-columns: 1fr 1fr; }
@@ -309,9 +321,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;
@@ -365,7 +374,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; }
+74 -5
View File
@@ -313,18 +313,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");
@@ -333,13 +380,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 {
@@ -818,6 +872,20 @@ 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";
@@ -1205,6 +1273,7 @@ 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);
$("#story-experience-close").addEventListener("click", closeStoryExperience);
$("#save-formula").addEventListener("click", saveFormula);
$("#latex-source").addEventListener("input", (event) => { renderLatexPreview(event.target.value); });
$$(".tool-card").forEach((button) => {
+22 -5
View File
@@ -98,12 +98,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">
@@ -414,6 +409,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>
+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/>
- 《隋书·律历志》关于祖冲之圆周率上下界与历法的记载,可结合中华书局点校本核对。
- 秦九韶《数书九章》、赵爽《周髀算经注》是两位古代数学家的主要一手文献。
评分发生争议时,应先核对上述资料中的具体贡献,再调整对应维度,不因知名度直接
提高或降低全部分数。