feat: rebuild math life around marks and directed endings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
from copy import deepcopy
|
||||
|
||||
MARK_DEFINITIONS = [
|
||||
{"code": "prime_margin", "name": "素数页边注", "domain": "number_theory"},
|
||||
{"code": "one_to_hundred", "name": "1到100的两行", "domain": "number_theory"},
|
||||
{
|
||||
"code": "compass_straightedge",
|
||||
"name": "圆规与直尺",
|
||||
"domain": "number_theory",
|
||||
},
|
||||
{
|
||||
"code": "seventeen_gon_night",
|
||||
"name": "正十七边形的夜",
|
||||
"domain": "number_theory",
|
||||
},
|
||||
{
|
||||
"code": "fundamental_arithmetic",
|
||||
"name": "算术基本定理",
|
||||
"domain": "number_theory",
|
||||
},
|
||||
{
|
||||
"code": "skipped_lemma",
|
||||
"name": "跳步的引理",
|
||||
"domain": "algebraic_geometry",
|
||||
},
|
||||
{
|
||||
"code": "shadow_of_sheaf",
|
||||
"name": "层的影子",
|
||||
"domain": "algebraic_geometry",
|
||||
},
|
||||
{
|
||||
"code": "denied_cubic",
|
||||
"name": "被否定的三次",
|
||||
"domain": "algebraic_geometry",
|
||||
},
|
||||
{
|
||||
"code": "fourth_continue",
|
||||
"name": "第四次继续",
|
||||
"domain": "algebraic_geometry",
|
||||
},
|
||||
{
|
||||
"code": "ideal_and_ring",
|
||||
"name": "理想与环",
|
||||
"domain": "algebraic_geometry",
|
||||
},
|
||||
{"code": "late_draft", "name": "深夜的草稿", "domain": "analysis"},
|
||||
{"code": "epsilon_promise", "name": "ε-δ 的承诺", "domain": "analysis"},
|
||||
{"code": "counting_rod", "name": "一根算筹", "domain": "analysis"},
|
||||
{"code": "pi_seventh_digit", "name": "π 的第七位", "domain": "analysis"},
|
||||
{"code": "limit_definition", "name": "极限的定义", "domain": "analysis"},
|
||||
{
|
||||
"code": "first_model",
|
||||
"name": "第一次建模",
|
||||
"domain": "applied_mathematics",
|
||||
},
|
||||
{
|
||||
"code": "cafeteria_queue",
|
||||
"name": "食堂排队模型",
|
||||
"domain": "applied_mathematics",
|
||||
},
|
||||
{
|
||||
"code": "five_constants",
|
||||
"name": "五个常数",
|
||||
"domain": "applied_mathematics",
|
||||
},
|
||||
{
|
||||
"code": "beauty_in_time",
|
||||
"name": "美在时间中",
|
||||
"domain": "applied_mathematics",
|
||||
},
|
||||
{
|
||||
"code": "optimal_solution",
|
||||
"name": "最优解",
|
||||
"domain": "applied_mathematics",
|
||||
},
|
||||
]
|
||||
|
||||
MARK_PLACEMENTS = {
|
||||
("c1_notice", 0): ["prime_margin"],
|
||||
("c1_notice", 1): ["compass_straightedge"],
|
||||
("c1_gauss_try", 0): ["one_to_hundred"],
|
||||
("c1_gauss_keep", 0): ["seventeen_gon_night"],
|
||||
("c5_start", 0): ["fundamental_arithmetic"],
|
||||
("c3_start", 0): ["skipped_lemma"],
|
||||
("c3_start", 1): ["skipped_lemma"],
|
||||
("c3_paper", 0): ["denied_cubic"],
|
||||
("c3_paper", 1): ["shadow_of_sheaf"],
|
||||
("c3_denied", 0): ["fourth_continue"],
|
||||
("c5_start", 1): ["ideal_and_ring"],
|
||||
("c1_midterm", 1): ["late_draft"],
|
||||
("c1_analysis", 0): ["epsilon_promise"],
|
||||
("c1_analysis", 1): ["epsilon_promise"],
|
||||
("c1_zu_enter", 0): ["counting_rod"],
|
||||
("c1_zu_enter", 1): ["pi_seventh_digit"],
|
||||
("c4_start", 0): ["limit_definition"],
|
||||
("c2_model", 0): ["first_model"],
|
||||
("c2_model_yes", 0): ["cafeteria_queue"],
|
||||
("c4_sleep", 0): ["five_constants"],
|
||||
("c4_boundary", 0): ["beauty_in_time"],
|
||||
("c4_balance", 0): ["optimal_solution"],
|
||||
}
|
||||
|
||||
EASTER_EGGS = [
|
||||
{
|
||||
"code": "gauss",
|
||||
"name": "高斯草稿",
|
||||
"trigger": "c1_gauss_merge",
|
||||
"required_marks": ["prime_margin", "one_to_hundred"],
|
||||
"node": "egg_gauss",
|
||||
"return_to": "c1_report",
|
||||
"scene": "你翻回录取通知书旁的页边注。两行数字首尾相加,像一座刚刚亮起的桥。高斯没有给你答案,只把铅笔推回你的手中。",
|
||||
},
|
||||
{
|
||||
"code": "zu_chongzhi",
|
||||
"name": "祖冲之草稿",
|
||||
"trigger": "c1_zu_merge",
|
||||
"required_marks": ["late_draft", "epsilon_promise"],
|
||||
"node": "egg_zu_chongzhi",
|
||||
"return_to": "c1_final",
|
||||
"scene": "深夜草稿上的 ε-δ 与那根算筹叠在一起。精确不是冷冰冰的限制,而是你对下一步作出的承诺。",
|
||||
},
|
||||
{
|
||||
"code": "euler",
|
||||
"name": "欧拉草稿",
|
||||
"trigger": "c2_end",
|
||||
"required_marks": ["first_model", "cafeteria_queue"],
|
||||
"node": "egg_euler",
|
||||
"return_to": "c3_start",
|
||||
"scene": "食堂队伍在草稿上变成变量、约束与目标函数。欧拉在页角写下五个常数:数学的美并不排斥现实,它能让现实获得结构。",
|
||||
},
|
||||
{
|
||||
"code": "noether",
|
||||
"name": "诺特草稿",
|
||||
"trigger": "c3_cited",
|
||||
"required_marks": ["skipped_lemma", "shadow_of_sheaf"],
|
||||
"node": "egg_noether",
|
||||
"return_to": "c4_start",
|
||||
"scene": "你重新看见那个被跳过的引理,结构的影子从局部延伸到整体。诺特说:真正重要的不是补上一步,而是知道这一步为何必须存在。",
|
||||
},
|
||||
]
|
||||
|
||||
ENDING_RULES = {
|
||||
"trigger_node": "c8_phd",
|
||||
"tie_order": [
|
||||
"number_theory",
|
||||
"algebraic_geometry",
|
||||
"analysis",
|
||||
"applied_mathematics",
|
||||
],
|
||||
"domains": {
|
||||
"number_theory": "ending_number_theory",
|
||||
"algebraic_geometry": "ending_algebraic_geometry",
|
||||
"analysis": "ending_analysis",
|
||||
"applied_mathematics": "ending_applied_mathematics",
|
||||
},
|
||||
}
|
||||
|
||||
ENDING_NODES = {
|
||||
"ending_number_theory": {
|
||||
"character": "导师",
|
||||
"scene": "你获得统一直博资格,方向选择数论。录取材料最上方,是你一路留下的素数页边注与算术结构。你没有成为第二个高斯,你开始提出自己的问题。",
|
||||
"choices": [],
|
||||
},
|
||||
"ending_algebraic_geometry": {
|
||||
"character": "导师",
|
||||
"scene": "你获得统一直博资格,方向选择代数几何。讨论班里被跳过的引理,最终变成你研究理想、环与几何结构的入口。",
|
||||
"choices": [],
|
||||
},
|
||||
"ending_analysis": {
|
||||
"character": "导师",
|
||||
"scene": "你获得统一直博资格,方向选择分析。深夜草稿、ε-δ 的承诺和极限定义,成为你继续逼近未知的方式。",
|
||||
"choices": [],
|
||||
},
|
||||
"ending_applied_mathematics": {
|
||||
"character": "导师",
|
||||
"scene": "你获得统一直博资格,方向选择应用数学。从食堂排队到最优解,你决定继续研究数学如何在现实中承担责任。",
|
||||
"choices": [],
|
||||
},
|
||||
}
|
||||
|
||||
DOMAIN_LABELS = {
|
||||
"number_theory": "数论",
|
||||
"algebraic_geometry": "代数几何",
|
||||
"analysis": "分析",
|
||||
"applied_mathematics": "应用数学",
|
||||
}
|
||||
|
||||
V12_IDENTITY_SCORES = {
|
||||
"0000": {"眼光": 88, "人文": 95, "侦探": 84, "建模": 78, "联结": 87},
|
||||
"0001": {"眼光": 98, "人文": 88, "侦探": 94, "建模": 91, "联结": 99},
|
||||
"0010": {"眼光": 90, "人文": 86, "侦探": 92, "建模": 88, "联结": 84},
|
||||
"0011": {"眼光": 89, "人文": 94, "侦探": 94, "建模": 99, "联结": 92},
|
||||
"0100": {"眼光": 91, "人文": 86, "侦探": 96, "建模": 98, "联结": 90},
|
||||
"0101": {"眼光": 99, "人文": 86, "侦探": 96, "建模": 100, "联结": 100},
|
||||
"0110": {"眼光": 99, "人文": 82, "侦探": 98, "建模": 99, "联结": 100},
|
||||
"0111": {"眼光": 98, "人文": 92, "侦探": 99, "建模": 100, "联结": 99},
|
||||
"1000": {"眼光": 100, "人文": 80, "侦探": 100, "建模": 99, "联结": 99},
|
||||
"1001": {"眼光": 99, "人文": 91, "侦探": 98, "建模": 98, "联结": 100},
|
||||
"1010": {"眼光": 93, "人文": 88, "侦探": 96, "建模": 99, "联结": 91},
|
||||
"1011": {"眼光": 100, "人文": 94, "侦探": 99, "建模": 82, "联结": 100},
|
||||
"1100": {"眼光": 90, "人文": 96, "侦探": 84, "建模": 93, "联结": 98},
|
||||
"1101": {"眼光": 98, "人文": 91, "侦探": 100, "建模": 76, "联结": 99},
|
||||
"1110": {"眼光": 96, "人文": 98, "侦探": 98, "建模": 99, "联结": 95},
|
||||
"1111": {"眼光": 98, "人文": 95, "侦探": 90, "建模": 94, "联结": 100},
|
||||
}
|
||||
|
||||
|
||||
def build_v12_story(source):
|
||||
document = deepcopy(source)
|
||||
document["title"] = "信仰者人生:从小镇到直博"
|
||||
document["description"] = "用印记记录每一次数学抉择,在四个研究方向中找到自己的长期问题。"
|
||||
document["system_version"] = "marks-v1"
|
||||
document["mark_definitions"] = MARK_DEFINITIONS
|
||||
document["domain_labels"] = DOMAIN_LABELS
|
||||
document["ending_rules"] = ENDING_RULES
|
||||
document["chapters"] = {
|
||||
f"c{index}": {"number": index, "title": title}
|
||||
for index, title in enumerate(
|
||||
[
|
||||
"没有竞赛奖状的夏天",
|
||||
"第一次完整证明",
|
||||
"被跳过的引理",
|
||||
"数学之外的时间",
|
||||
"选择一个长期问题",
|
||||
"机器到来之后",
|
||||
"把自己的问题写出来",
|
||||
"统一直博的第一页",
|
||||
],
|
||||
start=1,
|
||||
)
|
||||
}
|
||||
|
||||
for node in document["nodes"].values():
|
||||
for choice in node.get("choices", []):
|
||||
choice["effects"] = {}
|
||||
for (node_id, choice_index), mark_codes in MARK_PLACEMENTS.items():
|
||||
choice = document["nodes"][node_id]["choices"][choice_index]
|
||||
choice["effects"] = {"marks": mark_codes}
|
||||
|
||||
for egg in EASTER_EGGS:
|
||||
document["nodes"][egg["trigger"]]["choices"].append(
|
||||
{
|
||||
"text": "✦ 你忽然想起那页草稿……",
|
||||
"next": egg["node"],
|
||||
"requires_marks": egg["required_marks"],
|
||||
"effects": {"easter_eggs": [egg["code"]]},
|
||||
}
|
||||
)
|
||||
document["nodes"][egg["node"]] = {
|
||||
"character": egg["name"],
|
||||
"scene": egg["scene"],
|
||||
"choices": [
|
||||
{
|
||||
"text": "把这一页收进数学档案",
|
||||
"next": egg["return_to"],
|
||||
"effects": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
document["nodes"].update(ENDING_NODES)
|
||||
return document
|
||||
@@ -1,14 +1,28 @@
|
||||
import pytest
|
||||
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"]
|
||||
|
||||
@@ -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")
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user