16.1 引擎的装配检查表

装配检查表把本卷十五节交付的零件收成十二项可断言的检查:规格可解析、引用完整、写入域合法、整数不变量残差 0、规模对账、BFS 与基线一致、goto 全部存在、结局全部可达、跨语言锚点一致、W 层零外部依赖、覆盖率达目标、无卡死路径。S 层脚本逐项跑出 PASS/FAIL 表,任一项 FAIL 即非零退出。

16.1 引擎的装配检查表

Phaser 里的分支叙事实现 做的是一个具体游戏的分支叙事:选项、条件、状态推进、结局判定都在一个可玩的工程里。本章是卷二的收口——把 15 章交付的引擎、状态空间工具、W 层原型与回归基线装配成一张可执行的检查表,并把接口交给卷三做度量。十五节之后零件都在了:状态机与求值(第 1–2 章)、效果与确定性(第 3 章)、回放与摘要(第 4 章)、状态空间与可达性(第 5–6 章)、剪枝与采样(第 7 章)、好感度不变量(第 8 章)、W 层原型(第 9–10 章)、存档(第 11 章)、文本表(第 12 章)、跨语言摘要(第 13 章)、性质测试与规模告警(第 14–15 章)。整卷的章节地图见目录 。

本节不引入新概念,只做一件事:把零件装起来,并逐项检查装配结果。「装配完成」不能是一句感觉,它必须是十二项能跑出来的断言——每一项都返回真或假,不依赖任何人点头。

十二项检查与它们的判据

#检查项判据口径
A1规格可解析meta…metrics 十段全在顶层键存在性
A2引用完整goto 目标、场景边两端、条件引用的名字都存在名字解析
A3写入域合法set→flag、add→好感度、grant/revoke→物品命名空间
A4整数不变量残差 0好感度初值 ∈ [0,100]、区间 = [0,100]、tension ∈ [1,5]、id 唯一静态断言
A5规模对账meta.scale 的声明 == 实测计数比对
A6BFS 与基线一致深度 20 的逐层累计 == 第 5 章产物逐层对账
A7goto 全部存在115 个选项的目标全部命中名字解析
A8结局全部可达5 个结局在采样里全部命中有界采样
A9跨语言锚点一致本脚本摘要 == 第 1 章产物的 first 策略摘要整数摘要
A10W 层零外部依赖两个原型产物存在且不含外部 URL文本扫描
A11覆盖率达目标覆盖率 ≥ coverage_target_permille有界采样
A12无卡死路径采样里「无选项且非结局」的路径 = 0有界采样

十二项里有四项(A8–A12)要真的把状态空间走一遍,前八项是静态的、秒级可判。这个分层不是巧合:静态项保证引擎自洽,动态项保证这张图真的走得通。两者都过,才叫装配完成。

S 层:把清单跑成一张 PASS/FAIL 表

脚本同时是本章的共享入口——条件求值、效果应用、状态键、规范串、摘要都定义在这里,16.2 与 16.3 直接 import 复用,不重复实现。检查表本身只负责把十二项组织成一张表。

#!/usr/bin/env python3
# file: sim/ne_16_1_assembly.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_16_1_assembly.py
"""卷二 16.1 引擎的装配检查表:把全卷交付的零件装成一张可断言的检查表。

十二项检查,每项返回真或假,任一项 FAIL 即 exit 非 0。判据分两类来源:阈值
(覆盖率下限)从 config/story_spec.json 的 metrics 段读;基线(逐层累计可达状态、
确定性策略摘要)从 out/ 下既有产物读 —— 两者都不写死在脚本里。
本模块同时是本章的共享入口:16.2 / 16.3 复用这里的求值 / 效果 / 状态键 / 规范串。
"""
from __future__ import annotations

import json
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SECTIONS = ["meta", "world", "characters", "beats", "scene_graph",
            "dialogue", "conditions", "state", "endings", "metrics"]
DIST = ["web/dist/ne09.html", "web/dist/ne10.html"]
SAMPLE_N, SEED, BFS_LEVELS = 20000, 20261016, 20
AFF_LO, AFF_HI = 0, 100


def load(rel):
    with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
        return json.load(fh)


def eval_cond(c, s):
    if c is None:
        return True
    k = c[0]
    if k == "flag":
        return s["flags"].get(c[1], 0) == c[2]
    if k == "item":
        return s["items"].get(c[1], 0) == c[2]
    if k == "aff":
        v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
        return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
    if k == "and":
        return eval_cond(c[1], s) and eval_cond(c[2], s)
    if k == "or":
        return eval_cond(c[1], s) or eval_cond(c[2], s)
    return not eval_cond(c[1], s)


def apply_effects(eff, s):
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k, v in (eff.get("set") or {}).items():
        flags[k] = v
    for k, v in (eff.get("add") or {}).items():
        aff[k] = min(AFF_HI, max(AFF_LO, aff.get(k, 0) + v))
    for k, v in (eff.get("grant") or {}).items():
        items[k] = v
    for k in (eff.get("revoke") or {}):
        items[k] = 0
    return {"aff": aff, "flags": flags, "items": items}


def init_state(spec):
    aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
           for v in sorted(spec["state"]["vars"])}
    return {"aff": aff, "flags": {f: 0 for f in sorted(spec["state"]["flags"])},
            "items": {i: 0 for i in sorted(spec["state"]["items"])}}


def state_key(node, s, VARS, FLAGS, ITEMS):
    """去重键 = 节点 × 好感度 × flag 位 × 物品位(不含路径)。"""
    return (node, tuple(s["aff"][v] for v in VARS), tuple(s["flags"][f] for f in FLAGS),
            tuple(s["items"][i] for i in ITEMS))


def bfs_layers(spec, by_id, max_level):
    """逐层 BFS 到 max_level,返回每层累计状态数(与 5.2 基线同口径)。"""
    VARS, FLAGS, ITEMS = (sorted(spec["state"][k]) for k in ("vars", "flags", "items"))
    frontier = [state_key(spec["meta"]["start"], init_state(spec), VARS, FLAGS, ITEMS)]
    seen, out, states = set(frontier), [], 0
    for _ in range(max_level + 1):
        out.append(states + len(frontier))
        nxt = []
        for node, av, fv, iv in frontier:
            states += 1
            st = {"aff": dict(zip(VARS, av)), "flags": dict(zip(FLAGS, fv)),
                  "items": dict(zip(ITEMS, iv))}
            for c in (by_id[node].get("choices") or []):
                if not eval_cond(c.get("when"), st):
                    continue
                k = state_key(c["goto"], apply_effects(c.get("effects") or {}, st), VARS, FLAGS, ITEMS)
                if k not in seen:
                    seen.add(k)
                    nxt.append(k)
        frontier = nxt
    return out


def sample(spec, by_id, n, seed):
    """有界采样:返回(结局分布, 走过的对话边, 卡死路径数)。"""
    rng, dist, edges, dead = seed, {}, set(), 0
    for _ in range(n):
        s, node = init_state(spec), spec["meta"]["start"]
        for _step in range(200):
            cur = by_id[node]
            if cur.get("ending"):
                nm = cur["ending"]
                break
            opts = [c for c in (cur.get("choices") or []) if eval_cond(c.get("when"), s)]
            if not opts:
                nm = "(卡死)"
                break
            rng = (rng * 48271) % 2147483647
            c = opts[rng % len(opts)]
            edges.add((node, c["id"]))
            s, node = apply_effects(c.get("effects") or {}, s), c["goto"]
        else:
            nm = "(未终局)"
        dist[nm] = dist.get(nm, 0) + 1
        dead += 1 if nm in ("(卡死)", "(未终局)") else 0
    return dist, edges, dead


def canonical(node, s, path):
    aff = ",".join(f"{k}={s['aff'][k]}" for k in sorted(s["aff"]))
    fl = ",".join(k for k in sorted(s["flags"]) if s["flags"][k])
    it = ",".join(k for k in sorted(s["items"]) if s["items"][k])
    return f"{node}|{aff}|{fl}|{it}|{'>'.join(path)}"


def digest(text):
    h = 2166136261
    for ch in text:
        h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
    return h


def main():
    spec = load("config/story_spec.json")
    st, by_id = spec["state"], {n["id"]: n for n in spec["dialogue"]}
    all_ch = {(n["id"], c["id"]) for n in spec["dialogue"] for c in (n.get("choices") or [])}
    snodes = {x["id"] for x in spec["scene_graph"]["nodes"]}
    ends = {e["name"] for e in spec["endings"]}
    refs = {}

    def walk(c):
        if not c:
            return
        if c[0] in ("flag", "aff", "item"):
            refs.setdefault(c[0], set()).add(c[1])
        elif c[0] in ("and", "or"):
            walk(c[1]), walk(c[2])
        elif c[0] == "not":
            walk(c[1])

    for n in spec["dialogue"]:
        walk(n.get("line_when"))
        for c in (n.get("choices") or []):
            walk(c.get("when"))
    for e in spec["scene_graph"]["edges"] + spec["endings"]:
        walk(e.get("when"))

    bad_write = []
    for n in spec["dialogue"]:
        for c in (n.get("choices") or []):
            e = c.get("effects") or {}
            bad_write += [k for k in (e.get("set") or {}) if k not in set(st["flags"])]
            bad_write += [k for k in (e.get("add") or {}) if k not in set(st["vars"])]
            bad_write += [k for k in list(e.get("grant") or {}) + list(e.get("revoke") or {})
                          if k not in set(st["items"])]
    bad_ref = ((refs.get("flag", set()) - set(st["flags"])) | (refs.get("aff", set()) - set(st["vars"]))
               | (refs.get("item", set()) - set(st["items"])))
    goto_bad = [c["id"] for n in spec["dialogue"] for c in (n.get("choices") or []) if c["goto"] not in by_id]
    edge_bad = [e for e in spec["scene_graph"]["edges"] if e["from"] not in snodes or e["to"] not in snodes]

    resid = sum(1 for c in spec["characters"] if not (AFF_LO <= c["affinity"]["init"] <= AFF_HI)
                or list(c["affinity"]["range"]) != [AFF_LO, AFF_HI])
    resid += sum(1 for b in spec["beats"] if not (1 <= b["tension"] <= 5))
    for seg in (spec["dialogue"], spec["beats"], spec["endings"], spec["characters"]):
        ids = [x["id"] for x in seg]
        resid += 1 if len(ids) != len(set(ids)) else 0

    actual = {"scenes": len(spec["scene_graph"]["nodes"]), "characters": len(spec["characters"]),
              "dialogue_nodes": len(spec["dialogue"]), "choices": len(all_ch),
              "flags": len(st["flags"]), "affinity_vars": len(st["vars"]), "items": len(st["items"]),
              "factions": len(spec["world"]["factions"]), "endings": len(spec["endings"])}
    layers = bfs_layers(spec, by_id, BFS_LEVELS)
    base = [r["cumulative"] for r in load("out/ne_05_2_reachable.json")["layers"]]

    dist, edges, dead = sample(spec, by_id, SAMPLE_N, SEED)
    cov = (len(edges) * 1000) // len(all_ch)
    target = spec["metrics"]["coverage_target_permille"]

    s, node, trail, i = init_state(spec), spec["meta"]["start"], [spec["meta"]["start"]], 0
    while i < 64 and not by_id[node].get("ending"):
        opts = [c for c in (by_id[node].get("choices") or []) if eval_cond(c.get("when"), s)]
        if not opts:
            break
        s, node = apply_effects(opts[0].get("effects") or {}, s), opts[0]["goto"]
        trail.append(node)
        i += 1
    anchor = digest(canonical(node, s, trail))

    ext, dist_ok = [], True
    for rel in DIST:
        p = os.path.join(ROOT, rel)
        dist_ok = dist_ok and os.path.exists(p)
        ext += re.findall(r"https?://", open(p, encoding="utf-8").read()) if os.path.exists(p) else []

    rows = [
        ("A1  规格可解析", all(x in spec for x in SECTIONS), f"{len(SECTIONS)}/{len(SECTIONS)} 段"),
        ("A2  引用完整", not (bad_ref or edge_bad or goto_bad),
         f"条件引用 {sum(len(v) for v in refs.values())} 名、场景边 {len(spec['scene_graph']['edges'])} 条"),
        ("A3  写入域合法", not bad_write, f"set/add/grant 越界 {len(bad_write)}"),
        ("A4  整数不变量残差 0", resid == 0, f"残差 {resid}"),
        ("A5  scale 对账", all(spec["meta"]["scale"].get(k) == v for k, v in actual.items()),
         f"{len(actual)}/{len(actual)} 项一致"),
        ("A6  BFS 与基线一致", layers == base[:BFS_LEVELS + 1], f"深度 {BFS_LEVELS} 累计 {layers[-1]}"),
        ("A7  goto 全部存在", not goto_bad, f"{len(all_ch)}/{len(all_ch)}"),
        ("A8  结局全部可达", sum(1 for k in dist if k in ends) == len(ends),
         f"{sum(1 for k in dist if k in ends)}/{len(ends)}"),
        ("A9  跨语言锚点一致", anchor == load("out/ne_01_3_py.json")[0]["digest"], f"锚点 {anchor}"),
        ("A10 W 层零外部依赖", dist_ok and not ext, f"{len(DIST)}/{len(DIST)} 产物,外链 {len(ext)}"),
        ("A11 覆盖率达目标", cov >= target, f"{cov}‰ ≥ {target}‰"),
        ("A12 无卡死路径", dead == 0, f"采样 {SAMPLE_N} 条,卡死 {dead}"),
    ]
    print("装配检查表(《雾港夜航》config/story_spec.json)")
    for name, ok, ev in rows:
        print(f"{name:<20} {'PASS' if ok else 'FAIL':<5} {ev}")
    passed = sum(1 for _, ok, _ in rows if ok)
    print(f"装配结果 {'PASS' if passed == len(rows) else 'FAIL'}({passed}/{len(rows)})")

    report = {"passed": passed, "total": len(rows),
              "rows": [{"id": n.split()[0], "ok": ok, "evidence": ev} for n, ok, ev in rows],
              "scale_actual": actual, "coverage_permille": cov, "dead_ends": dead,
              "bfs_depth": BFS_LEVELS, "bfs_cumulative": layers[-1], "anchor_digest": anchor,
              "ending_paths": dist, "sample_n": SAMPLE_N, "seed": SEED}
    with open(os.path.join(ROOT, "out/ne_16_1_assembly.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    return 0 if passed == len(rows) else 1


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/ne_16_1_assembly.py:

装配检查表(《雾港夜航》config/story_spec.json)
A1  规格可解析            PASS  10/10 段
A2  引用完整             PASS  条件引用 22 名、场景边 16 条
A3  写入域合法            PASS  set/add/grant 越界 0
A4  整数不变量残差 0        PASS  残差 0
A5  scale 对账         PASS  9/9 项一致
A6  BFS 与基线一致        PASS  深度 20 累计 357845
A7  goto 全部存在        PASS  115/115
A8  结局全部可达           PASS  5/5
A9  跨语言锚点一致          PASS  锚点 4162796975
A10 W 层零外部依赖         PASS  2/2 产物,外链 0
A11 覆盖率达目标           PASS  1000‰ ≥ 850‰
A12 无卡死路径            PASS  采样 20000 条,卡死 0
装配结果 PASS(12/12)

十二项全 PASS,脚本 exit 0。

判读:这张表里哪几行是真的信息

前八项都是 PASS,但它们的性质不同。A1、A5 是存在性:十段字段在、声明规模与实测规模九项全对,说明这份规格没有被写残。A2、A3、A7 是命名空间:115 个选项的目标全部命中、16 条场景边两端都在、条件引用的 22 个名字全部已声明,写错名字的选项一条都没有。A4 的「残差 0」是全书反复引用的那条硬判据,它恒为 0 不是运气——是因为写入域(A3)先把能越界的地方堵死了。

真正需要看的是后四项。A6 的 357845 是深度 20 的累计可达状态数,逐层与第 5 章产物的 layers[].cumulative 对账一致,说明这次装配用的枚举口径与当时没有分歧。A9 的 4162796975 是确定性策略 first 走到底的整数摘要,与第 1 章产物里的同一个数逐位相同。A10 的 外链 0 说明两个原型产物是真正自包含的——不是靠 CDN 撑起来的页面。A11 的 1000‰ 说明 115 条对话边在采样里全部被走到,A12 的 0 说明没有一条采样路径会走进「既没有选项、也不是结局」的死胡同。

观察数字读法
覆盖率达上限115/115 = 1000‰分支全部可达,设计目标 850‰ 留了余量
跨语言锚点一致4162796975Python 与第 1 章的 TS 实现在同一个数上
W 层外链 00原型产物自包含,可离线打开

自检表

检查项合格标准
检查项可断言每一项返回真或假,不依赖人工判断
判据来自规格与产物阈值取自 metrics,基线取自 out/,都不写死
失败即非零退出任一项 FAIL,脚本 exit 非 0
可被复用求值 / 效果 / 状态键 / 规范串 / 摘要作为共享入口导出

装配表是静态的「合格证」。但一张合格证不等于这份规格真的能被三处消费——下一节把穷举、执行、度量在同一份规格上串一遍,把真实输出贴出来。

阅读导航:上一节:15.3 规模告警 · 下一节:16.2 三处消费的联调 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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