Files
story-edit-web/ir_core/validate.py

186 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""Story IR 配置期校验。挡掉调研发现的卡流程 bug校验不过不产出任何配置。
返回 (errors, warnings)errors 非空即拒绝编译warnings 仅提示(如点位集缺失)。
M4 新增out_ref 结构校验、选项 skip 校验、点位集坐标引用校验。
"""
import os
import re
from .compile import expand_out_refs
from .dictionary import CompileError
ITEM_RE = re.compile(r"^[VP]\d+$")
# 点位集位置:本文件在 SGame/tools/event_authoring/ir_core/ -> 上溯 4 级到项目根
_POINTS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
"Assets", "StreamingAssets", "Story", "PointSets")
def validate(ir, dic, points_dir=None, strict_points=False):
errs, warns = [], []
# ---- out_ref 结构预检(在展开前;展开依赖此处通过)----
seqs = {s["id"]: s for s in ir.get("sequences", [])}
for n in ir.get("nodes", []):
if n.get("kind") == "out_ref":
ref = n.get("ref")
if ref not in seqs:
errs.append("[out_ref失效] 节点 %s 引用了不存在的子序列 %r" % (n["id"], ref))
elif not seqs[ref].get("nodes"):
errs.append("[空子序列] 子序列 %r 没有任何节点(被 %s 引用)" % (ref, n["id"]))
for sn in seqs.get(ref, {}).get("nodes", []):
if sn.get("kind") == "out_ref":
errs.append("[嵌套out_ref] 子序列 %r 节点 %s 含嵌套 out_refM4 暂不支持"
% (ref, sn["id"]))
if errs:
return errs, warns # 结构坏了就别展开了,避免 KeyError 噪声
# ---- 展开 out_ref 后做节点级校验goto/win/lose/skip 目标含子序列内 id----
try:
nodes_list = expand_out_refs(ir)
except CompileError as e:
return ["[展开失败] %s" % e], warns
nodes = {n["id"]: n for n in nodes_list}
endings = {e["id"]: e for e in ir.get("endings", [])}
all_ids = set(nodes) | set(endings)
slots = {r["slot"] for r in ir.get("roles", [])}
def check_target(src, tid, field):
if tid not in all_ids:
errs.append("[跳转失效] 节点 %s%s 指向不存在的目标 %r" % (src, field, tid))
def check_slot(src, s, field):
if s and s not in slots:
errs.append("[未声明角色] 节点 %s%s 引用了未在 roles 声明的 slot %r" % (src, field, s))
def check_grants(src, grants):
for g in grants or []:
k = g.get("kind")
if not dic.is_known_grant(k):
errs.append("[未知奖励] 节点 %s 出现未登记的 grant kind %r" % (src, k)); continue
if k == "道具" and not ITEM_RE.match(str(g.get("item", ""))):
errs.append("[非法道具ID] 节点 %s 的道具 %r 不是合法 ID" % (src, g.get("item")))
spec = dic.grant_spec(k)
if spec.get("form") in ("money", "item", "friend") and \
not isinstance(g.get("value"), (int, float)):
errs.append("[非法数值] 节点 %s 的奖励数值 %r 不是数字" % (src, g.get("value")))
if dic.grant_needs_target(k):
check_slot(src, g.get("target"), "奖励target")
def check_cond(src, c):
if not c:
return
if not dic.is_known_cond(c.get("kind")):
errs.append("[非法条件] 节点 %s 的 condition %r 无法编译" % (src, c)); return
spec = dic.cond_spec(c.get("kind"))
if c.get("op") not in spec.get("ops", {}):
errs.append("[非法条件] 节点 %s 的 condition %r 比较符不支持" % (src, c))
elif not isinstance(c.get("value"), (int, float)):
errs.append("[非法条件数值] 节点 %s 的 condition 阈值 %r 不是数字" % (src, c.get("value")))
for nid, n in nodes.items():
kind = n.get("kind")
if n.get("next"):
check_target(nid, n["next"], "next")
if n.get("speaker"):
check_slot(nid, n["speaker"], "speaker")
if n.get("actor"):
check_slot(nid, n["actor"], "actor")
if kind in ("choice", "choice_once"):
opts = n.get("options", [])
if not opts:
errs.append("[空选择] 节点 %s 没有任何选项" % nid)
if not any("condition" not in o for o in opts):
errs.append("[选项无兜底] 节点 %s 的所有选项都带条件,可能全部不满足导致空面板卡死" % nid)
for o in opts:
check_target(nid, o.get("goto"), "option.goto")
check_cond(nid, o.get("condition"))
if o.get("reward"):
check_grants(nid, o["reward"].get("grants"))
if o.get("skip"):
sk = o["skip"]
check_target(nid, sk.get("node"), "option.skip.node")
grants = (sk.get("reward") or {}).get("grants")
if not grants:
errs.append("[空skip奖励] 节点 %s 选项 skip 缺 reward.grants押注无彩头" % nid)
check_grants(nid, grants)
if kind == "random":
for b in n.get("branches", []):
check_target(nid, b.get("goto"), "branch.goto")
if not isinstance(b.get("weight"), (int, float)):
errs.append("[非法权重] 节点 %s 的随机权重 %r 不是数字" % (nid, b.get("weight")))
if kind == "fight":
if n.get("fight_type") not in (1, 2):
errs.append("[非法战斗类型] 节点 %s 的 fight_type 必须是 1(击倒)/2(死斗)" % nid)
if not n.get("camp2"):
errs.append("[战斗缺敌方] 节点 %s 的 camp2 为空" % nid)
for s in (n.get("camp2") or []) + (n.get("camp1") or []):
check_slot(nid, s, "fight阵营")
check_target(nid, n.get("win"), "fight.win")
check_target(nid, n.get("lose"), "fight.lose")
if kind == "move":
check_slot(nid, n.get("actor"), "move.actor")
if kind == "reward":
check_grants(nid, n.get("grants"))
for e in ir.get("endings", []):
check_grants(e["id"], e.get("grants"))
# ---- 点位集坐标引用校验 ----
_check_point_set(ir, errs, warns, points_dir, strict_points)
return errs, warns
def collect_point_refs(ir):
"""收集 IR 里**实际需要物理站位**的点位名(角色出生点/移动目标/镜头点/战斗阵营)。
仅声明、从未在节点出现的 role如未登场的旁系角色不计——运行时不为其摆人。
"""
refs = set()
all_nodes = list(ir.get("nodes", []))
for s in ir.get("sequences", []):
all_nodes += s.get("nodes", [])
for n in all_nodes:
for f in ("speaker", "actor"):
v = n.get(f)
if v and v != "P1":
refs.add(v)
if n.get("kind") == "move" and n.get("to") and n["to"] != "P1":
refs.add(n["to"])
if n.get("camera"):
refs.add(n["camera"])
if n.get("kind") == "fight":
for s in (n.get("camp1") or []) + (n.get("camp2") or []):
if s and s != "P1":
refs.add(s)
return refs
def _check_point_set(ir, errs, warns, points_dir, strict_points):
import json
name = (ir.get("stage") or {}).get("point_set") or ir["id"]
base = points_dir or _POINTS_DIR
path = os.path.join(base, name + ".points.json")
refs = collect_point_refs(ir)
if not os.path.exists(path):
msg = "[点位集缺失] 未找到 %s.points.json%s);坐标校验跳过" % (name, path)
if strict_points:
errs.append(msg + "--strict-points 下视为错)")
else:
warns.append(msg)
return
try:
with open(path, encoding="utf-8") as f:
ps = json.load(f)
except Exception as e:
errs.append("[点位集损坏] %s 解析失败: %s" % (path, e)); return
have = {p.get("name") for p in ps.get("points", [])}
for r in sorted(refs):
if r not in have:
errs.append("[点位缺失] 点位 %r 在点位集 %s.points.json 中不存在(需取点工具补录)"
% (r, name))