diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py index bdde153..87edb75 100644 --- a/backend/common/test_frontend_assets.py +++ b/backend/common/test_frontend_assets.py @@ -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 "= 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 diff --git a/backend/math_life/story_v12.py b/backend/math_life/story_v12.py new file mode 100644 index 0000000..39a545a --- /dev/null +++ b/backend/math_life/story_v12.py @@ -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 diff --git a/backend/math_life/test_services.py b/backend/math_life/test_services.py index 62e583e..f743304 100644 --- a/backend/math_life/test_services.py +++ b/backend/math_life/test_services.py @@ -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"] diff --git a/backend/progression/views.py b/backend/progression/views.py index 241e80b..b15f194 100644 --- a/backend/progression/views.py +++ b/backend/progression/views.py @@ -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") + ], } ) diff --git a/backend/static/css/app.css b/backend/static/css/app.css index 1474464..d00107c 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -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; } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index de8dafa..4ce7814 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -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) => { diff --git a/backend/templates/index.html b/backend/templates/index.html index de7491e..376692b 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -98,12 +98,7 @@ - - -
- 数学人生
交叉点
-
01
@@ -414,6 +409,28 @@
+ +
diff --git a/docs/MATHBTI_V1_2_SCORE_RATIONALE.md b/docs/MATHBTI_V1_2_SCORE_RATIONALE.md new file mode 100644 index 0000000..f422e74 --- /dev/null +++ b/docs/MATHBTI_V1_2_SCORE_RATIONALE.md @@ -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 数学史人物档案: +- NASA Katherine Johnson 官方人物档案: + +- NASA Katherine G. Johnson 工作档案: + +- Encyclopaedia Britannica,Hypatia: + +- Encyclopaedia Britannica,Isaac Newton: + +- 中国科学院与华罗庚相关公开资料入口: + +- 《隋书·律历志》关于祖冲之圆周率上下界与历法的记载,可结合中华书局点校本核对。 +- 秦九韶《数书九章》、赵爽《周髀算经注》是两位古代数学家的主要一手文献。 + +评分发生争议时,应先核对上述资料中的具体贡献,再调整对应维度,不因知名度直接 +提高或降低全部分数。