feat: rebuild math life around marks and directed endings

This commit is contained in:
2026-08-10 01:06:06 +08:00
parent b8916180a9
commit aafaf1b77a
14 changed files with 838 additions and 29 deletions
+2
View File
@@ -9,6 +9,7 @@ from .models import (
SkillPackage,
Story,
StoryChoice,
StoryMark,
StoryRun,
StoryVersion,
UserRelationship,
@@ -52,5 +53,6 @@ admin.site.register(MathBTIAssessment)
admin.site.register(MathBTIResult)
admin.site.register(Character)
admin.site.register(StoryChoice)
admin.site.register(StoryMark)
admin.site.register(UserRelationship)
admin.site.register(SkillPackage)
@@ -16,6 +16,7 @@ from math_life.models import (
StoryVersion,
)
from math_life.services import validate_story_content
from math_life.story_v12 import V12_IDENTITY_SCORES, build_v12_story
DISCIPLINE_ICONS = {
"人工智能": "🤖",
@@ -157,12 +158,15 @@ class Command(BaseCommand):
"clan": result["clan_name"],
"mathematician": result["mathematician"],
"description": result["description"],
"initial_abilities": result.get("stats5", {}),
"initial_abilities": V12_IDENTITY_SCORES.get(
code,
result.get("stats5", {}),
),
"portrait": result.get("portrait", ""),
},
)
story_document = load_json(story_path)
story_document = build_v12_story(load_json(story_path))
errors = validate_story_content(story_document)
if errors:
raise CommandError("; ".join(errors))
@@ -176,9 +180,12 @@ class Command(BaseCommand):
"is_visible": True,
},
)
StoryVersion.objects.filter(story=flagship).exclude(version=2).update(
is_published=False
)
StoryVersion.objects.update_or_create(
story=flagship,
version=1,
version=2,
defaults={
"content": story_document,
"is_published": True,
@@ -0,0 +1,36 @@
# Generated by Django 4.2.23 on 2026-08-09 16:51
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('math_life', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='StoryMark',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=60)),
('name', models.CharField(max_length=80)),
('domain', models.CharField(max_length=40)),
('acquired_at', models.DateTimeField(auto_now_add=True)),
('source_run', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acquired_marks', to='math_life.storyrun')),
('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collected_marks', to='math_life.story')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='story_marks', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['acquired_at'],
},
),
migrations.AddConstraint(
model_name='storymark',
constraint=models.UniqueConstraint(fields=('user', 'story', 'code'), name='unique_user_story_mark'),
),
]
@@ -0,0 +1,69 @@
from django.db import migrations
from django.utils import timezone
from math_life.story_v12 import V12_IDENTITY_SCORES, build_v12_story
def publish_marks_story(apps, schema_editor):
MathIdentity = apps.get_model("math_life", "MathIdentity")
Story = apps.get_model("math_life", "Story")
StoryRun = apps.get_model("math_life", "StoryRun")
StoryVersion = apps.get_model("math_life", "StoryVersion")
for code, scores in V12_IDENTITY_SCORES.items():
MathIdentity.objects.filter(code=code).update(initial_abilities=scores)
story = Story.objects.filter(slug="believer").first()
if story is None:
story = Story.objects.filter(slug="believer-math-teen").first()
if story is None:
return
if story.slug != "believer":
story.slug = "believer"
story.save(update_fields=["slug"])
source = (
StoryVersion.objects.filter(story=story)
.order_by("-version")
.values_list("content", flat=True)
.first()
)
if not source:
return
document = (
source
if source.get("system_version") == "marks-v1"
else build_v12_story(source)
)
old_version_ids = list(
StoryVersion.objects.filter(story=story)
.exclude(version=2)
.values_list("id", flat=True)
)
if old_version_ids:
StoryRun.objects.filter(
story_version_id__in=old_version_ids,
status="active",
).update(status="abandoned")
StoryVersion.objects.filter(story=story).update(is_published=False)
StoryVersion.objects.update_or_create(
story=story,
version=2,
defaults={
"content": document,
"is_published": True,
"published_at": timezone.now(),
},
)
class Migration(migrations.Migration):
dependencies = [
("math_life", "0002_storymark_storymark_unique_user_story_mark"),
]
operations = [
migrations.RunPython(
publish_marks_story,
migrations.RunPython.noop,
),
]
+36
View File
@@ -127,6 +127,42 @@ class StoryChoice(models.Model):
ordering = ["sequence"]
class StoryMark(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="story_marks",
)
story = models.ForeignKey(
Story,
on_delete=models.CASCADE,
related_name="collected_marks",
)
source_run = models.ForeignKey(
StoryRun,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="acquired_marks",
)
code = models.CharField(max_length=60)
name = models.CharField(max_length=80)
domain = models.CharField(max_length=40)
acquired_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=("user", "story", "code"),
name="unique_user_story_mark",
)
]
ordering = ["acquired_at"]
def __str__(self):
return f"{self.user} · {self.name}"
class UserRelationship(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
character = models.ForeignKey(Character, on_delete=models.CASCADE)
+112 -11
View File
@@ -4,7 +4,7 @@ from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from .models import StoryChoice, StoryRun, StoryVersion
from .models import StoryChoice, StoryMark, StoryRun, StoryVersion
def score_mathbti(definition, answer_indexes):
@@ -40,6 +40,11 @@ def validate_story_content(content):
errors.append("start_node 不存在")
referenced = set()
mark_codes = {
item.get("code")
for item in content.get("mark_definitions", [])
if isinstance(item, dict)
}
for node_id, node in nodes.items():
if not isinstance(node.get("choices", []), list):
errors.append(f"{node_id}.choices 必须是数组")
@@ -52,6 +57,16 @@ def validate_story_content(content):
errors.append(f"{node_id} 引用了不存在的节点 {target}")
else:
referenced.add(target)
required = choice.get("requires_marks", [])
if not isinstance(required, list) or any(
code not in mark_codes for code in required
):
errors.append(f"{node_id} 的印记条件无效")
effects = choice.get("effects", {})
if content.get("system_version") == "marks-v1" and any(
key not in {"marks", "easter_eggs"} for key in effects
):
errors.append(f"{node_id} 仍包含旧数值效果")
if start in nodes:
reachable = set()
@@ -66,6 +81,9 @@ def validate_story_content(content):
for choice in nodes[node_id].get("choices", [])
if choice.get("next") in nodes
)
ending_rules = content.get("ending_rules", {})
if node_id == ending_rules.get("trigger_node"):
pending.extend(ending_rules.get("domains", {}).values())
unreachable = sorted(set(nodes) - reachable)
if unreachable:
errors.append(f"存在不可达节点: {', '.join(unreachable[:10])}")
@@ -77,6 +95,9 @@ def _merge_effects(state, effects):
for key, value in effects.items():
if isinstance(value, dict):
result[key] = _merge_effects(result.get(key, {}), value)
elif isinstance(value, list):
current = result.get(key, [])
result[key] = list(dict.fromkeys([*current, *value]))
elif isinstance(value, (int, float)):
result[key] = result.get(key, 0) + value
else:
@@ -84,12 +105,67 @@ def _merge_effects(state, effects):
return result
def _node_payload(run):
node = run.story_version.content["nodes"][run.current_node]
choices = [
{"index": index, "text": choice.get("text", "")}
for index, choice in enumerate(node.get("choices", []))
def _mark_lookup(content):
return {
item["code"]: item
for item in content.get("mark_definitions", [])
if isinstance(item, dict) and item.get("code")
}
def _refresh_mark_counts(state, content):
result = deepcopy(state)
lookup = _mark_lookup(content)
counts = {
domain: 0 for domain in content.get("domain_labels", {})
}
for code in result.get("marks", []):
mark = lookup.get(code)
if mark:
counts[mark["domain"]] = counts.get(mark["domain"], 0) + 1
result["mark_counts"] = counts
return result
def _available_choices(run, node):
marks = set(run.state.get("marks", []))
return [
choice
for choice in node.get("choices", [])
if set(choice.get("requires_marks", [])).issubset(marks)
]
def _resolve_ending(content, state, node_id):
rules = content.get("ending_rules", {})
if node_id != rules.get("trigger_node"):
return node_id
counts = state.get("mark_counts", {})
order = rules.get("tie_order", [])
domain = max(order, key=lambda item: counts.get(item, 0))
return rules.get("domains", {}).get(domain, node_id)
def _node_payload(run):
content = run.story_version.content
node = content["nodes"][run.current_node]
available_choices = _available_choices(run, node)
choices = [
{
"index": index,
"text": choice.get("text", ""),
"special": bool(choice.get("requires_marks")),
}
for index, choice in enumerate(available_choices)
]
chapter_code = node.get("chapter") or run.current_node.split("_", 1)[0]
chapter = content.get("chapters", {}).get(chapter_code, {})
collection = list(
StoryMark.objects.filter(
user=run.user,
story=run.story_version.story,
).values("code", "name", "domain", "acquired_at")
)
return {
"run_id": run.id,
"story": run.story_version.story.title,
@@ -97,6 +173,10 @@ def _node_payload(run):
"status": run.status,
"current_node": run.current_node,
"state": run.state,
"chapter": chapter,
"mark_definitions": content.get("mark_definitions", []),
"domain_labels": content.get("domain_labels", {}),
"collection": collection,
"node": {
"scene": node.get("scene", ""),
"character": node.get("character", ""),
@@ -125,7 +205,11 @@ def start_story(user, story):
user=user,
story_version=version,
current_node=version.content["start_node"],
state={},
state=(
_refresh_mark_counts({"marks": [], "easter_eggs": []}, version.content)
if version.content.get("system_version") == "marks-v1"
else {}
),
)
return _node_payload(run)
@@ -151,8 +235,9 @@ def make_choice(*, run_id, user, choice_index, idempotency_key=None):
raise ValidationError("该人生已经结束")
node_id = run.current_node
node = run.story_version.content["nodes"][node_id]
choices = node.get("choices", [])
content = run.story_version.content
node = content["nodes"][node_id]
choices = _available_choices(run, node)
try:
normalized_index = int(choice_index)
if normalized_index < 0 or normalized_index >= len(choices):
@@ -173,8 +258,24 @@ def make_choice(*, run_id, user, choice_index, idempotency_key=None):
effects=effects,
)
run.state = _merge_effects(run.state, effects)
run.current_node = choice["next"]
next_node = run.story_version.content["nodes"][run.current_node]
if content.get("system_version") == "marks-v1":
run.state = _refresh_mark_counts(run.state, content)
mark_lookup = _mark_lookup(content)
for mark_code in effects.get("marks", []):
mark = mark_lookup.get(mark_code)
if mark:
StoryMark.objects.get_or_create(
user=user,
story=run.story_version.story,
code=mark_code,
defaults={
"source_run": run,
"name": mark["name"],
"domain": mark["domain"],
},
)
run.current_node = _resolve_ending(content, run.state, choice["next"])
next_node = content["nodes"][run.current_node]
if not next_node.get("choices"):
run.status = StoryRun.Status.COMPLETED
run.ending_code = run.current_node
+261
View File
@@ -0,0 +1,261 @@
from copy import deepcopy
MARK_DEFINITIONS = [
{"code": "prime_margin", "name": "素数页边注", "domain": "number_theory"},
{"code": "one_to_hundred", "name": "1到100的两行", "domain": "number_theory"},
{
"code": "compass_straightedge",
"name": "圆规与直尺",
"domain": "number_theory",
},
{
"code": "seventeen_gon_night",
"name": "正十七边形的夜",
"domain": "number_theory",
},
{
"code": "fundamental_arithmetic",
"name": "算术基本定理",
"domain": "number_theory",
},
{
"code": "skipped_lemma",
"name": "跳步的引理",
"domain": "algebraic_geometry",
},
{
"code": "shadow_of_sheaf",
"name": "层的影子",
"domain": "algebraic_geometry",
},
{
"code": "denied_cubic",
"name": "被否定的三次",
"domain": "algebraic_geometry",
},
{
"code": "fourth_continue",
"name": "第四次继续",
"domain": "algebraic_geometry",
},
{
"code": "ideal_and_ring",
"name": "理想与环",
"domain": "algebraic_geometry",
},
{"code": "late_draft", "name": "深夜的草稿", "domain": "analysis"},
{"code": "epsilon_promise", "name": "ε-δ 的承诺", "domain": "analysis"},
{"code": "counting_rod", "name": "一根算筹", "domain": "analysis"},
{"code": "pi_seventh_digit", "name": "π 的第七位", "domain": "analysis"},
{"code": "limit_definition", "name": "极限的定义", "domain": "analysis"},
{
"code": "first_model",
"name": "第一次建模",
"domain": "applied_mathematics",
},
{
"code": "cafeteria_queue",
"name": "食堂排队模型",
"domain": "applied_mathematics",
},
{
"code": "five_constants",
"name": "五个常数",
"domain": "applied_mathematics",
},
{
"code": "beauty_in_time",
"name": "美在时间中",
"domain": "applied_mathematics",
},
{
"code": "optimal_solution",
"name": "最优解",
"domain": "applied_mathematics",
},
]
MARK_PLACEMENTS = {
("c1_notice", 0): ["prime_margin"],
("c1_notice", 1): ["compass_straightedge"],
("c1_gauss_try", 0): ["one_to_hundred"],
("c1_gauss_keep", 0): ["seventeen_gon_night"],
("c5_start", 0): ["fundamental_arithmetic"],
("c3_start", 0): ["skipped_lemma"],
("c3_start", 1): ["skipped_lemma"],
("c3_paper", 0): ["denied_cubic"],
("c3_paper", 1): ["shadow_of_sheaf"],
("c3_denied", 0): ["fourth_continue"],
("c5_start", 1): ["ideal_and_ring"],
("c1_midterm", 1): ["late_draft"],
("c1_analysis", 0): ["epsilon_promise"],
("c1_analysis", 1): ["epsilon_promise"],
("c1_zu_enter", 0): ["counting_rod"],
("c1_zu_enter", 1): ["pi_seventh_digit"],
("c4_start", 0): ["limit_definition"],
("c2_model", 0): ["first_model"],
("c2_model_yes", 0): ["cafeteria_queue"],
("c4_sleep", 0): ["five_constants"],
("c4_boundary", 0): ["beauty_in_time"],
("c4_balance", 0): ["optimal_solution"],
}
EASTER_EGGS = [
{
"code": "gauss",
"name": "高斯草稿",
"trigger": "c1_gauss_merge",
"required_marks": ["prime_margin", "one_to_hundred"],
"node": "egg_gauss",
"return_to": "c1_report",
"scene": "你翻回录取通知书旁的页边注。两行数字首尾相加,像一座刚刚亮起的桥。高斯没有给你答案,只把铅笔推回你的手中。",
},
{
"code": "zu_chongzhi",
"name": "祖冲之草稿",
"trigger": "c1_zu_merge",
"required_marks": ["late_draft", "epsilon_promise"],
"node": "egg_zu_chongzhi",
"return_to": "c1_final",
"scene": "深夜草稿上的 ε-δ 与那根算筹叠在一起。精确不是冷冰冰的限制,而是你对下一步作出的承诺。",
},
{
"code": "euler",
"name": "欧拉草稿",
"trigger": "c2_end",
"required_marks": ["first_model", "cafeteria_queue"],
"node": "egg_euler",
"return_to": "c3_start",
"scene": "食堂队伍在草稿上变成变量、约束与目标函数。欧拉在页角写下五个常数:数学的美并不排斥现实,它能让现实获得结构。",
},
{
"code": "noether",
"name": "诺特草稿",
"trigger": "c3_cited",
"required_marks": ["skipped_lemma", "shadow_of_sheaf"],
"node": "egg_noether",
"return_to": "c4_start",
"scene": "你重新看见那个被跳过的引理,结构的影子从局部延伸到整体。诺特说:真正重要的不是补上一步,而是知道这一步为何必须存在。",
},
]
ENDING_RULES = {
"trigger_node": "c8_phd",
"tie_order": [
"number_theory",
"algebraic_geometry",
"analysis",
"applied_mathematics",
],
"domains": {
"number_theory": "ending_number_theory",
"algebraic_geometry": "ending_algebraic_geometry",
"analysis": "ending_analysis",
"applied_mathematics": "ending_applied_mathematics",
},
}
ENDING_NODES = {
"ending_number_theory": {
"character": "导师",
"scene": "你获得统一直博资格,方向选择数论。录取材料最上方,是你一路留下的素数页边注与算术结构。你没有成为第二个高斯,你开始提出自己的问题。",
"choices": [],
},
"ending_algebraic_geometry": {
"character": "导师",
"scene": "你获得统一直博资格,方向选择代数几何。讨论班里被跳过的引理,最终变成你研究理想、环与几何结构的入口。",
"choices": [],
},
"ending_analysis": {
"character": "导师",
"scene": "你获得统一直博资格,方向选择分析。深夜草稿、ε-δ 的承诺和极限定义,成为你继续逼近未知的方式。",
"choices": [],
},
"ending_applied_mathematics": {
"character": "导师",
"scene": "你获得统一直博资格,方向选择应用数学。从食堂排队到最优解,你决定继续研究数学如何在现实中承担责任。",
"choices": [],
},
}
DOMAIN_LABELS = {
"number_theory": "数论",
"algebraic_geometry": "代数几何",
"analysis": "分析",
"applied_mathematics": "应用数学",
}
V12_IDENTITY_SCORES = {
"0000": {"眼光": 88, "人文": 95, "侦探": 84, "建模": 78, "联结": 87},
"0001": {"眼光": 98, "人文": 88, "侦探": 94, "建模": 91, "联结": 99},
"0010": {"眼光": 90, "人文": 86, "侦探": 92, "建模": 88, "联结": 84},
"0011": {"眼光": 89, "人文": 94, "侦探": 94, "建模": 99, "联结": 92},
"0100": {"眼光": 91, "人文": 86, "侦探": 96, "建模": 98, "联结": 90},
"0101": {"眼光": 99, "人文": 86, "侦探": 96, "建模": 100, "联结": 100},
"0110": {"眼光": 99, "人文": 82, "侦探": 98, "建模": 99, "联结": 100},
"0111": {"眼光": 98, "人文": 92, "侦探": 99, "建模": 100, "联结": 99},
"1000": {"眼光": 100, "人文": 80, "侦探": 100, "建模": 99, "联结": 99},
"1001": {"眼光": 99, "人文": 91, "侦探": 98, "建模": 98, "联结": 100},
"1010": {"眼光": 93, "人文": 88, "侦探": 96, "建模": 99, "联结": 91},
"1011": {"眼光": 100, "人文": 94, "侦探": 99, "建模": 82, "联结": 100},
"1100": {"眼光": 90, "人文": 96, "侦探": 84, "建模": 93, "联结": 98},
"1101": {"眼光": 98, "人文": 91, "侦探": 100, "建模": 76, "联结": 99},
"1110": {"眼光": 96, "人文": 98, "侦探": 98, "建模": 99, "联结": 95},
"1111": {"眼光": 98, "人文": 95, "侦探": 90, "建模": 94, "联结": 100},
}
def build_v12_story(source):
document = deepcopy(source)
document["title"] = "信仰者人生:从小镇到直博"
document["description"] = "用印记记录每一次数学抉择,在四个研究方向中找到自己的长期问题。"
document["system_version"] = "marks-v1"
document["mark_definitions"] = MARK_DEFINITIONS
document["domain_labels"] = DOMAIN_LABELS
document["ending_rules"] = ENDING_RULES
document["chapters"] = {
f"c{index}": {"number": index, "title": title}
for index, title in enumerate(
[
"没有竞赛奖状的夏天",
"第一次完整证明",
"被跳过的引理",
"数学之外的时间",
"选择一个长期问题",
"机器到来之后",
"把自己的问题写出来",
"统一直博的第一页",
],
start=1,
)
}
for node in document["nodes"].values():
for choice in node.get("choices", []):
choice["effects"] = {}
for (node_id, choice_index), mark_codes in MARK_PLACEMENTS.items():
choice = document["nodes"][node_id]["choices"][choice_index]
choice["effects"] = {"marks": mark_codes}
for egg in EASTER_EGGS:
document["nodes"][egg["trigger"]]["choices"].append(
{
"text": "✦ 你忽然想起那页草稿……",
"next": egg["node"],
"requires_marks": egg["required_marks"],
"effects": {"easter_eggs": [egg["code"]]},
}
)
document["nodes"][egg["node"]] = {
"character": egg["name"],
"scene": egg["scene"],
"choices": [
{
"text": "把这一页收进数学档案",
"next": egg["return_to"],
"effects": {},
}
],
}
document["nodes"].update(ENDING_NODES)
return document
+123 -1
View File
@@ -1,14 +1,28 @@
import pytest
from django.conf import settings
from rest_framework.exceptions import ValidationError
from accounts.models import User
from math_life.models import Story, StoryChoice, StoryRun, StoryVersion
from math_life.models import (
Story,
StoryChoice,
StoryMark,
StoryRun,
StoryVersion,
)
from math_life.services import (
get_run_payload,
make_choice,
score_mathbti,
start_story,
validate_story_content,
)
from math_life.story_v12 import (
EASTER_EGGS,
MARK_DEFINITIONS,
V12_IDENTITY_SCORES,
build_v12_story,
)
@pytest.fixture
@@ -142,3 +156,111 @@ def test_make_choice_负数索引必须拒绝且存档不变(story_setup):
run = StoryRun.objects.get(id=started["run_id"])
assert run.current_node == "start"
assert StoryChoice.objects.count() == 0
def v12_story_document():
import json
path = settings.PROJECT_ROOT / "docs" / "信仰者线_story.json"
return build_v12_story(json.loads(path.read_text(encoding="utf-8")))
def test_v12_story_包含20印记且完全移除旧数值体系():
document = v12_story_document()
acquired = {
code
for node in document["nodes"].values()
for choice in node.get("choices", [])
for code in choice.get("effects", {}).get("marks", [])
}
effect_keys = {
key
for node in document["nodes"].values()
for choice in node.get("choices", [])
for key in choice.get("effects", {})
}
assert len(MARK_DEFINITIONS) == 20
assert acquired == {item["code"] for item in MARK_DEFINITIONS}
assert not {"xinzhi", "shuli", "xiayi"} & effect_keys
assert validate_story_content(document) == []
def test_v12_数学家五维评分均有高分且不使用过低分():
assert len(V12_IDENTITY_SCORES) == 16
for scores in V12_IDENTITY_SCORES.values():
assert set(scores) == {"眼光", "人文", "侦探", "建模", "联结"}
assert min(scores.values()) >= 75
assert max(scores.values()) >= 90
@pytest.mark.django_db
def test_v12_story_印记永久收集_彩蛋开放并按领域分发结局():
user = User.objects.create_user(
username="marks_story_user",
password="StrongPass_2026",
nickname="印记玩家",
)
story = Story.objects.create(slug="marks-story", title="印记人生")
document = v12_story_document()
StoryVersion.objects.create(
story=story,
version=2,
is_published=True,
content=document,
)
started = start_story(user, story)
first = make_choice(
run_id=started["run_id"],
user=user,
choice_index=0,
idempotency_key="mark-first",
)
assert "prime_margin" in first["state"]["marks"]
assert StoryMark.objects.filter(
user=user,
story=story,
code="prime_margin",
).exists()
run = StoryRun.objects.get(id=started["run_id"])
gauss = next(item for item in EASTER_EGGS if item["code"] == "gauss")
run.current_node = gauss["trigger"]
run.state = {
"marks": gauss["required_marks"],
"easter_eggs": [],
"mark_counts": {
"number_theory": 2,
"algebraic_geometry": 0,
"analysis": 0,
"applied_mathematics": 0,
},
}
run.save(update_fields=["current_node", "state"])
payload = get_run_payload(run)
assert any(choice["special"] for choice in payload["node"]["choices"])
run.current_node = "c8_noether_merge"
run.state = {
"marks": ["epsilon_promise", "late_draft", "limit_definition"],
"easter_eggs": [],
"mark_counts": {
"number_theory": 0,
"algebraic_geometry": 0,
"analysis": 3,
"applied_mathematics": 0,
},
}
run.save(update_fields=["current_node", "state"])
ending = make_choice(
run_id=run.id,
user=user,
choice_index=0,
idempotency_key="analysis-ending",
)
assert ending["status"] == StoryRun.Status.COMPLETED
assert ending["current_node"] == "ending_analysis"
assert ending["chapter"] == {}
assert ending["collection"]