init: 剧情事件协作 Web 编辑器独立仓(从 SGame/tools/event_authoring 拆出)

This commit is contained in:
bia
2026-06-08 16:50:27 +08:00
commit f5669dc01d
32 changed files with 3497 additions and 0 deletions

185
ir_core/validate.py Normal file
View File

@ -0,0 +1,185 @@
# -*- 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))