2.2 模式校验与语义校验

模式校验挡「字段写错了」,语义校验挡「字段写对了却指向不存在的东西」。本节把这两类失效分开处理,给出六项校验的 Python 实现与真源 0 违规的实测结果,再对真源做三处定向破坏,确认对应校验会开火且无连带误报,TS 侧复用同一套判据求值。

上一节把字段摊开成表,这一节回答「表里的内容对不对」。仍先划界:《游戏设计》卷一:玩法机制与可执行规格 校验的是数值与规则(伤害不越界、资源不溢出),消费对象是一局战斗的状态轨迹;本节校验的是结构与条件(引用不悬空、命名空间不串、条件可求值),消费对象是故事在玩家选择下的展开路径。数值越界会让一局战斗失真,引用悬空则会让一条分支在运行时直接断掉。

2.2.1 两类校验各挡什么

模式校验和语义校验处理的是两类完全不同的失效,混在一起做会两头都不准。

维度模式校验语义校验
挡住的错字段写错:类型不符、必填缺失、枚举越界字段写对却指向不存在的东西
典型例子tension=6、stance=2、ops 少了 !=goto 指向不存在的节点、set 写了 flags 之外的键
判据来源字段表本身(第 2.1 节)声明集合 + 文法声明
实现位置top_keys / enum_range / cond_grammarrefs / namespace / cond_evaluable
漏检后果数值失真,仍能跑完分支断裂,运行到该处即出错

六项校验各自独立产出一份违规清单,真源上要求全部为 0。光有「真源 0 违规」还不够——一个恒返回空列表的校验器也能满足它,所以必须再做定向破坏:改坏一处、确认对应校验开火、并记录有没有连带误报。

$ cd $HOME/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/na_02_schema_check.py
#!/usr/bin/env python3
# file: sim/na_02_schema_check.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_02_schema_check.py
"""模式校验(类型 / 必填 / 枚举)与语义校验(引用完整 / 命名空间 / 可求值)。

模式校验挡住「字段写错了」,语义校验挡住「字段写对了、却指向不存在的东西」。
真源上两者都必须 0 违规;再对真源做 3 处定向破坏,确认对应校验会开火。
"""
from __future__ import annotations

import copy
import json
import os
import sys

TOP = {"meta", "world", "characters", "beats", "scene_graph", "dialogue", "conditions", "state", "endings", "metrics"}
NS = {"set": "flags", "add": "vars", "grant": "items", "revoke": "items"}
ARITY = {"flag": 3, "item": 3, "aff": 4, "and": 3, "or": 3, "not": 2}
CMP = (">=", "<", "==", "!=")
KIND = {"flag": "flags", "item": "items", "aff": "vars"}


def load():
    here = os.path.dirname(os.path.abspath(__file__))
    with open(os.path.join(here, "../config/story_spec.json"), encoding="utf-8") as fh:
        return json.load(fh)


def sites(spec):
    """所有承载条件的字段,附出处,便于报错定位。"""
    out = []
    for n in spec["dialogue"]:
        out.append((f"d[{n['id']}]", n.get("line_when")))
        out += [(f"c[{c['id']}]", c.get("when")) for c in n.get("choices") or []]
    out += [(f"e[{e['id']}]", e.get("when")) for e in spec["endings"]]
    out += [(f"n[{n['id']}]", n.get("enter_when")) for n in spec["scene_graph"]["nodes"]]
    out += [(f"edge[{e['from']}->{e['to']}]", e.get("when")) for e in spec["scene_graph"]["edges"]]
    out += [(f"loc[{l['id']}]", l.get("unlock_when")) for l in spec["world"]["locations"]]
    return out


def leaves(c):
    if c is None:
        return
    if c[0] in ("and", "or"):
        yield from leaves(c[1]); yield from leaves(c[2])
    elif c[0] == "not":
        yield from leaves(c[1])
    else:
        yield c


def evaluate(c, st):
    k = c[0]
    if k in ("flag", "item"):
        return st[KIND[k]].get(c[1], 0) == c[2]
    if k == "aff":
        v = st["aff"].get(c[1], 0)
        return {">=": v >= c[3], "<": v < c[3], "==": v == c[3], "!=": v != c[3]}[c[2]]
    if k == "and":
        return evaluate(c[1], st) and evaluate(c[2], st)
    if k == "or":
        return evaluate(c[1], st) or evaluate(c[2], st)
    return not evaluate(c[1], st)


def check(spec):
    """返回 校验名 -> 违规清单。"""
    e = {}
    e["top_keys"] = [] if set(spec) == TOP else [f"顶层键 {sorted(set(spec) ^ TOP)}"]
    acts = spec["meta"]["acts"]
    e["enum_range"] = (
        [f"stance {f['id']}.{k}={v}" for f in spec["world"]["factions"] for k, v in f["stance"].items() if v not in (-1, 0, 1)]
        + [f"tension {b['id']}={b['tension']}" for b in spec["beats"] if not 1 <= b["tension"] <= 5]
        + [f"act {x['id']}={x['act']}" for x in spec["beats"] + spec["scene_graph"]["nodes"] + spec["world"]["locations"] if not 1 <= x["act"] <= acts]
        + [f"curve {v}" for v in spec["metrics"]["target_tension_curve"] if not 1 <= v <= 5])
    g = spec.get("conditions") or {}
    e["cond_grammar"] = ([] if set(g.get("ops") or []) == set(CMP) else [f"ops={g.get('ops')}"]) + \
                        ([] if set(g.get("forms") or {}) == set(ARITY) else [f"forms={sorted(g.get('forms') or {})}"])
    ids = {n["id"] for n in spec["dialogue"]}
    nodes = {n["id"] for n in spec["scene_graph"]["nodes"]}
    speakers = {c["id"] for c in spec["characters"]} | {"narrator"}
    targets = {c["goto"] for n in spec["dialogue"] for c in n.get("choices") or []} | {spec["meta"]["start"]}
    e["refs"] = ([f"goto {c['id']}->{c['goto']} 不存在" for n in spec["dialogue"] for c in n.get("choices") or [] if c["goto"] not in ids]
                 + [f"scene {n['id']}={n['scene']}" for n in spec["dialogue"] if n["scene"] not in nodes]
                 + [f"speaker {n['id']}={n['speaker']}" for n in spec["dialogue"] if n["speaker"] not in speakers]
                 + [f"orphan {x}" for x in sorted(ids - targets)])
    decl = {"flags": set(spec["state"]["flags"]), "vars": set(spec["state"]["vars"]), "items": set(spec["state"]["items"])}
    bad = [f"c[{c['id']}].{op} 写了 {NS[op]} 之外的 {k}" for n in spec["dialogue"] for c in n.get("choices") or []
           for op, ef in (c.get("effects") or {}).items() for k in ef if k not in decl[NS[op]]]
    bad += [f"{w} 未声明的 {leaf[0]} {leaf[1]}" for w, c in sites(spec) for leaf in leaves(c) if leaf[1] not in decl[KIND[leaf[0]]]]
    e["namespace"] = bad
    st = {"aff": {c["id"]: c["affinity"]["init"] for c in spec["characters"]},
          "flags": {f: 0 for f in spec["state"]["flags"]}, "items": {i: 0 for i in spec["state"]["items"]}}
    bad = []
    for where, c in sites(spec):
        stack = [c] if c else []
        while stack:
            x = stack.pop()
            if x[0] not in ARITY or len(x) != ARITY[x[0]]:
                bad.append(f"{where} 形状非法 {x}")
                continue
            if x[0] == "aff" and x[2] not in CMP:
                bad.append(f"{where} 比较算子 {x[2]}")
            if x[0] in ("and", "or"):
                stack += [x[1], x[2]]
            elif x[0] == "not":
                stack.append(x[1])
        if c:
            try:
                evaluate(c, st)
            except Exception as exc:  # noqa: BLE001
                bad.append(f"{where} 求值抛错 {exc!r}")
    e["cond_evaluable"] = bad
    return e


def main() -> int:
    spec = load()
    rep = check(spec)
    print(f"{'校验':<16} 违规")
    for name, bad in rep.items():
        print(f"{name:<16} {len(bad)}")
    print(f"真源违规总数 = {sum(len(v) for v in rep.values())}")
    assert not any(rep.values()), rep

    def m_tension(s): s["beats"][0]["tension"] = 6
    def m_goto(s): s["dialogue"][0]["choices"][0]["goto"] = "d_nowhere"
    def m_ns(s): s["dialogue"][0]["choices"][0]["effects"]["set"] = {"lantern": 1}

    print(f"\n{'破坏点':<10} {'应开火':<14} 开火  连带")
    for want, mutate in [("enum_range", m_tension), ("refs", m_goto), ("namespace", m_ns)]:
        s2 = copy.deepcopy(spec)
        mutate(s2)
        r2 = check(s2)
        extra = sorted(k for k, v in r2.items() if k != want and v)
        print(f"{mutate.__name__:<10} {want:<14} {'是' if r2[want] else '否':<4}  {extra or '无'}")
        assert r2[want], f"{mutate.__name__} 未触发 {want}"
    print("OK na_02_schema_check:模式 3 项 + 语义 3 项,真源 0 违规,3 处定向破坏全部被捕获")
    return 0


if __name__ == "__main__":
    sys.exit(main())
校验               违规
top_keys         0
enum_range       0
cond_grammar     0
refs             0
namespace        0
cond_evaluable   0
真源违规总数 = 0

破坏点        应开火            开火  连带
m_tension  enum_range     是     无
m_goto     refs           是     无
m_ns       namespace      是     无
OK na_02_schema_check:模式 3 项 + 语义 3 项,真源 0 违规,3 处定向破坏全部被捕获

「连带」一列是这套校验器最容易出问题的地方。删掉整个 conditions 段会同时触发 top_keys 和 cond_grammar,这属于合理连带而非误报;但如果改一个 tension 却连带把 refs 也点着了,就说明两项校验的边界画错了。三处破坏的连带都是「无」,边界是干净的。

2.2.3 TS 侧复用同一套判据

同一套判据在 TS 侧实现一遍,不是为了双保险,而是因为运行时求值器就在 TS 里。校验器判「能不能求值」,引擎判「求值结果是什么」,两者必须共用同一份算子表。

$ cd $HOME/.inkwellbook/inkwell && node --test 'core/test/na_02_schema.test.ts'
// file: core/test/na_02_schema.test.ts
// snippet: 语义校验与可求值校验(导入、常量与 checkEnum 见完整文件)
// tier: P
// verify: node --test 'core/test/na_02_schema.test.ts'
const checkSemantic = (s: Doc): string[] => {
  const bad: string[] = [];
  const ids = new Set(s.dialogue.map((d) => d.id));
  const scenes = new Set(s.scene_graph.nodes.map((n) => n.id));
  const speakers = new Set(s.characters.map((c) => c.id).concat("narrator"));
  const decl: Record<string, Set<string>> = { flags: new Set(s.state.flags), vars: new Set(s.state.vars), items: new Set(s.state.items) };
  const targets = new Set<string>([s.meta.start]);
  for (const d of s.dialogue) {
    if (!scenes.has(d.scene)) bad.push(`scene ${d.id}=${d.scene}`);
    if (!speakers.has(d.speaker)) bad.push(`speaker ${d.id}=${d.speaker}`);
    for (const c of d.choices) {
      if (!ids.has(c.goto)) bad.push(`goto ${c.id}->${c.goto}`);
      targets.add(c.goto);
      for (const op of Object.keys(c.effects)) {
        if (!(op in NS)) { bad.push(`未知算子 ${op}`); continue; }
        for (const k of Object.keys((c.effects as Record<string, Record<string, number>>)[op] ?? {})) {
          if (!decl[NS[op]].has(k)) bad.push(`${c.id}.${op} 写了 ${NS[op]} 之外的 ${k}`);
        }
      }
    }
  }
  for (const id of ids) if (!targets.has(id)) bad.push(`orphan ${id}`);
  const stack = [...allConds(s)];
  while (stack.length) {
    const x = stack.pop() as Cond;
    if (!(x[0] in ARITY) || x.length !== ARITY[x[0]]) { bad.push(`形状非法 ${JSON.stringify(x)}`); continue; }
    if (x[0] === "aff" && !CMP.has(x[2] as string)) bad.push(`比较算子 ${x[2]}`);
    if (x[0] === "and" || x[0] === "or") stack.push(x[1], x[2]);
    else if (x[0] === "not") stack.push(x[1]);
  }
  return bad;
};

const checkEvaluable = (s: Doc): string[] => {
  const st = initState(s as unknown as Spec);
  const bad: string[] = [];
  const stack = [...allConds(s)];
  while (stack.length) {
    const c = stack.pop() as Cond;
    if (c[0] === "aff" && !CMP.has(c[2] as string)) { bad.push(`比较算子 ${c[2]}`); continue; }
    if (c[0] === "and" || c[0] === "or") stack.push(c[1], c[2]);
    else if (c[0] === "not") stack.push(c[1]);
    try {
      condOk(c, st);
    } catch (err) {
      bad.push(`求值抛错 ${String(err)}`);
    }
  }
  return bad;
};
  enum: tension b01=6
  semantic: goto c_pier_1a->d_nowhere
  semantic: c_pier_1a.set 写了 flags 之外的 lantern
  evaluable: 比较算子 ~=
✔ 真源上三项校验均为 0 违规 (12.9825ms)
✔ 负向对照:三类破坏各被对应校验捕获 (102.72425ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 917.807625

最后一行日志是这一节的关键证据:checkEvaluable 对 ["aff","hui","~=",10] 主动报「比较算子 ~=」。运行时求值器遇到未知算子不会抛错——它会走 != 的分支给出一个看似合理的结果——所以校验器必须自己先把算子表卡住,不能指望运行时兜底。

阅读导航:上一节:2.1 story_spec.json 的字段全表 · 下一节:2.3 规格的版本与迁移 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长