feat: rebuild math life around marks and directed endings
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user