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
+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