Files
Hulumath-Web/backend/math_life/services.py
T
Jacky 1f98f7152d
CI / test (push) Failing after 1m31s
v0.2 Preview: Construction Plan.
2026-08-08 21:25:27 +08:00

193 lines
6.3 KiB
Python

from copy import deepcopy
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from .models import StoryChoice, StoryRun, StoryVersion
def score_mathbti(definition, answer_indexes):
questions = definition.get("questions", [])
if len(answer_indexes) != len(questions):
raise ValidationError({"answers": "答案数量与题目数量不一致"})
axis_scores = {axis["id"]: 0 for axis in definition.get("axes", [])}
for question, raw_index in zip(questions, answer_indexes):
try:
option_index = int(raw_index)
options = question["options"]
if option_index < 0 or option_index >= len(options):
raise IndexError
option = options[option_index]
except (IndexError, KeyError, TypeError, ValueError) as exc:
raise ValidationError({"answers": f"题目 {question.get('id')} 的答案无效"}) from exc
axis_scores[question["axis"]] += int(option["score"])
cutoff = int(definition.get("scoring", {}).get("cutoff", 4))
axis_order = definition.get("scoring", {}).get("axes", list(axis_scores))
identity_code = "".join("1" if axis_scores[axis] > cutoff else "0" for axis in axis_order)
return identity_code, axis_scores
def validate_story_content(content):
nodes = content.get("nodes")
start = content.get("start_node")
errors = []
if not isinstance(nodes, dict) or not nodes:
return ["nodes 必须是非空对象"]
if start not in nodes:
errors.append("start_node 不存在")
referenced = set()
for node_id, node in nodes.items():
if not isinstance(node.get("choices", []), list):
errors.append(f"{node_id}.choices 必须是数组")
continue
for choice in node.get("choices", []):
target = choice.get("next")
if not target:
errors.append(f"{node_id} 存在缺少 next 的选项")
elif target not in nodes:
errors.append(f"{node_id} 引用了不存在的节点 {target}")
else:
referenced.add(target)
if start in nodes:
reachable = set()
pending = [start]
while pending:
node_id = pending.pop()
if node_id in reachable:
continue
reachable.add(node_id)
pending.extend(
choice.get("next")
for choice in nodes[node_id].get("choices", [])
if choice.get("next") in nodes
)
unreachable = sorted(set(nodes) - reachable)
if unreachable:
errors.append(f"存在不可达节点: {', '.join(unreachable[:10])}")
return errors
def _merge_effects(state, effects):
result = deepcopy(state)
for key, value in effects.items():
if isinstance(value, dict):
result[key] = _merge_effects(result.get(key, {}), value)
elif isinstance(value, (int, float)):
result[key] = result.get(key, 0) + value
else:
result[key] = value
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", []))
]
return {
"run_id": run.id,
"story": run.story_version.story.title,
"version": run.story_version.version,
"status": run.status,
"current_node": run.current_node,
"state": run.state,
"node": {
"scene": node.get("scene", ""),
"character": node.get("character", ""),
"sprite": node.get("sprite", ""),
"choices": choices,
},
}
@transaction.atomic
def start_story(user, story):
version = (
StoryVersion.objects.select_related("story")
.filter(story=story, is_published=True)
.order_by("-version")
.first()
)
if version is None:
raise ValidationError("该人生尚未发布")
active = StoryRun.objects.filter(
user=user, story_version=version, status=StoryRun.Status.ACTIVE
).first()
if active:
return _node_payload(active)
run = StoryRun.objects.create(
user=user,
story_version=version,
current_node=version.content["start_node"],
state={},
)
return _node_payload(run)
def get_run_payload(run):
return _node_payload(run)
@transaction.atomic
def make_choice(*, run_id, user, choice_index, idempotency_key=None):
run = (
StoryRun.objects.select_for_update()
.select_related("story_version__story")
.get(id=run_id, user=user)
)
if idempotency_key:
existing = StoryChoice.objects.filter(
run=run, idempotency_key=idempotency_key
).first()
if existing:
return _node_payload(run)
if run.status != StoryRun.Status.ACTIVE:
raise ValidationError("该人生已经结束")
node_id = run.current_node
node = run.story_version.content["nodes"][node_id]
choices = node.get("choices", [])
try:
normalized_index = int(choice_index)
if normalized_index < 0 or normalized_index >= len(choices):
raise IndexError
choice = choices[normalized_index]
except (IndexError, TypeError, ValueError) as exc:
raise ValidationError({"choice_index": "选项不存在"}) from exc
sequence = run.choices.count() + 1
effects = choice.get("effects", {})
StoryChoice.objects.create(
run=run,
sequence=sequence,
idempotency_key=idempotency_key,
node_id=node_id,
choice_index=normalized_index,
choice_text=choice.get("text", ""),
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 not next_node.get("choices"):
run.status = StoryRun.Status.COMPLETED
run.ending_code = run.current_node
run.completed_at = timezone.now()
run.save(
update_fields=[
"state",
"current_node",
"status",
"ending_code",
"completed_at",
"updated_at",
]
)
return _node_payload(run)