14.1 规格演化的影响面

改一处规格,哪些路径与指标会变?本节用集合差回答:拿卷二 4.3 的 500 条采样序列当基线,在改动后的规格上重放,逐项量被阻断路径数、终局状态集差、结局集差与边/节点/场景/节拍集差。五处小改动各自给出实测数字,并说明为什么影响面不能靠重跑穷举来算。

站内 《游戏设计》卷一《玩法机制与可执行规格》 做的是玩法规格的消费与演化对机制指标的影响;本章做的是叙事规格演化对路径与曲线的影响。度量对象不同——那边看一局战斗的动力学(回合数、资源、行动密度),这边看整张故事图的路径分布(覆盖率、结局分布、张力曲线)。规格都是整数化的真源,但一边量的是「这一局打了几个回合」,另一边量的是「这张图被走成了什么形状」。

改一处规格,影响面有多大?这一节把「影响面」定义成一个可算的集合差,然后对五处典型的小改动各算一次。全部数字本机实跑,全部指标都是 proxy:只做相对比较,不声称叙事更好或更差。

14.1 规格演化的影响面

config/story_spec.json 是一份会被反复改的活文档:加一个 flag 门、调一个好感度阈值、补一条 goto、改一个节拍的张力标注。改动本身可能只有一行,但它在路径空间里的回声可能很大——也可能为零。

「影响面」的口径必须选一个可算的:把改动前后的两张故事图都完整穷举一遍再求差,代价是 BFS 全量枚举 9895423 个可达状态(卷二 5.2 实测:峰值内存 3132.8 MB、耗时 95.3 秒一次),五处改动就是五遍。所以本节改用基线序列重放:拿卷二 4.3 已经落盘的 500 条采样序列(out/ne_04_3_py.json,seed=20261016)当路径宇宙,把同一批序列在改动后的规格上逐条重放,再对四类集合求对称差:

集合含义读法
终局状态集每条序列走到的终局规范串状态差大 = 路径的落点被改写
结局集序列走到过哪些结局名结局差 = 有结局新增或消失
去重边集序列走过的 (节点, goto)边差 = 图的骨架被动过
节点 / 场景 / 节拍集序列经过的节点、场景、节拍覆盖面的位移

另外记一个不在这四类里的量:被阻断的路径数——某一步的选项在新规格下条件不再成立,序列走不下去,只能截断。

五处小改动

五处改动写在 config/nm_14_1_mutations.json 里,只在内存里施加,绝不写回真源:

{
  "_note": "卷三 14.1 五处「小改动」的声明式定义;只在内存里施加,绝不写回 story_spec.json。",
  "baseline": {"source": "out/ne_04_3_py.json", "samples": 500, "seed": 20261016},
  "mutations": [
    {"id": "M1_gate_dawn_fallback", "kind": "set_when", "target": "c_dq_1c",
     "when": ["flag", "knows_tide_rule", 1],
     "note": "给破晓码头的无门回退选项加一个 flag 门(加一个 flag)。"},
    {"id": "M2_raise_lit_threshold", "kind": "set_when", "target": "c_dq_2a",
     "when": ["and", ["flag", "found_yan_journal", 1], ["aff", "yan", ">=", 70]],
     "note": "把「灯守」结局的好感度门槛 40 → 70(改一个 threshold)。"},
    {"id": "M3_add_shortcut_goto", "kind": "add_choice", "node": "d_chapel_1",
     "choice": {"id": "c_chapel_1d", "text": "(捷径)直接回码头", "when": null,
                "effects": null, "goto": "d_hub_a1"},
     "note": "在礼拜堂加一条直达码头的 goto(加一条 goto),引入一条全新的去重边。"},
    {"id": "M4_beat_tension_shift", "kind": "set_beat_tension", "beat": "b01", "tension": 2,
     "note": "把一个节拍的张力 1 → 2,不动 metrics.target_tension_curve(改一个节拍张力)。"},
    {"id": "M5_gate_lighthouse_entry", "kind": "set_when", "target": "c_hub_a2a",
     "when": ["flag", "met_mu", 1],
     "note": "给灯塔基座的入口加一个 flag 门(原本无门,是第 9 章 R 类规则失配之一)。"}
  ]
}

四种改动类型(set_when / add_choice / set_beat_tension)刻意挑了四种语义不同的编辑:加门、调阈值、加边、改标注。它们对路径空间的作用方式完全不一样。

S 层:重放 500 条基线,量出集合差

sim/nm_14_1_evolution_impact.py 读真源与上面的改动表,对每处改动深拷一份规格、施加改动、重放 500 条序列。推进与摘要复用卷一 sim/na_02_digest.py,静态一致性残差复用卷三 sim/nm_09_1_residual_metric.py——两个都是只 import,不改。

#!/usr/bin/env python3
# file: sim/nm_14_1_evolution_impact.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_14_1_evolution_impact.py
"""卷三 14.1 规格演化的影响面(S 层):改一处规格,哪些路径与指标会变。

口径是**集合差**:拿卷二 4.3 的 500 条采样序列(out/ne_04_3_py.json,seed=20261016)
当基线,把同一批序列在**改动后的规格**上重放一遍,逐项比:被阻断的路径数、终局状态集
(规范串)的对称差、结局集差、走过的去重边 / 节点 / 场景 / 节拍集差。

五处改动全部**只在内存里施加**,绝不写回 config/story_spec.json;推进与摘要复用卷一
sim/na_02_digest.py,静态一致性残差复用卷三 sim/nm_09_1_residual_metric.py(都只 import)。
全部整数,不出现浮点。产出 out/nm_14_1_impact.json。
"""
from __future__ import annotations

import copy
import json
import math
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import na_02_digest as d2              # noqa: E402  只读 import:init_state / apply_choice / digest / canonical
import nm_09_1_residual_metric as r9   # noqa: E402  只读 import:static_residual(第 9 章同一口径)

ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/nm_14_1_mutations.json")
BASELINE = os.path.join(OUT, "ne_04_3_py.json")
JSON_PATH = os.path.join(OUT, "nm_14_1_impact.json")


def load_json(path: str):
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


def apply_mutation(spec: dict, mut: dict) -> dict:
    """把一处小改动施加到规格副本上(内存内,不改原文件)。"""
    clone = copy.deepcopy(spec)
    kind = mut["kind"]
    if kind == "set_when":
        hit = 0
        for n in clone["dialogue"]:
            for c in (n.get("choices") or []):
                if c["id"] == mut["target"]:
                    c["when"] = copy.deepcopy(mut["when"])
                    hit += 1
        assert hit == 1, f"{mut['id']}: 目标选项 {mut['target']} 命中 {hit} 次"
    elif kind == "add_choice":
        node = next(n for n in clone["dialogue"] if n["id"] == mut["node"])
        node.setdefault("choices", []).append(copy.deepcopy(mut["choice"]))
    elif kind == "set_beat_tension":
        next(b for b in clone["beats"] if b["id"] == mut["beat"])["tension"] = mut["tension"]
    else:
        raise ValueError(f"未知改动类型 {kind}")
    return clone


def replay(spec: dict, seq: list[str]) -> dict:
    """重放一条基线序列;任一步选项不可用即判为被阻断并截断。"""
    s = d2.init_state(spec)
    for k, cid in enumerate(seq):
        try:
            s = d2.apply_choice(spec, s, cid)
        except (KeyError, ValueError):
            return {"blocked": True, "state": s}
    return {"blocked": False, "state": s}


def collect(spec: dict, seqs: list[list[str]]) -> dict:
    """重放整批序列,收终局摘要与四类集合。"""
    by = {n["id"]: n for n in spec["dialogue"]}
    dedup = {(n["id"], c["goto"]) for n in spec["dialogue"] for c in (n.get("choices") or [])}
    canon, endings, edges, nodes, scenes, beats, digests = set(), set(), set(), set(), set(), set(), []
    blocked = 0
    for seq in seqs:
        r = replay(spec, seq)
        st = r["state"]
        blocked += 1 if r["blocked"] else 0
        digests.append(d2.digest(st))
        canon.add(d2.canonical(st))
        path = st["path"]
        endings.add(by[path[-1]].get("ending") if path[-1] in by else None)
        nodes.update(path)
        sc = {by[p]["scene"] for p in path if p in by}
        scenes.update(sc)
        beats.update(b["id"] for b in spec["beats"] if b["scene"] in sc)
        edges.update((path[i], path[i + 1]) for i in range(len(path) - 1))
    return {"blocked": blocked, "digests": digests, "canonical": canon, "endings": endings,
            "edges": edges, "nodes": nodes, "scenes": scenes, "beats": beats, "dedup_total": dedup}


def entropy_milli_bits(counts: list[int], total: int) -> int:
    return int(-sum((c / total) * math.log2(c / total) for c in counts if c > 0) * 1000)


def metrics(spec: dict, seqs: list[list[str]]) -> dict:
    """四个指标 + 结局分布(全部整数:千分位 / 毫)。"""
    c = collect(spec, seqs)
    by = {n["id"]: n for n in spec["dialogue"]}
    order = [e["name"] for e in spec["endings"]]
    dist = {nm: 0 for nm in order}
    for seq in seqs:
        p = replay(spec, seq)["state"]["path"]
        nm = by[p[-1]].get("ending") if p[-1] in by else None
        if nm:
            dist[nm] += 1
    total = sum(dist.values())
    act = [b["tension"] for b in spec["beats"]]
    tgt = list(spec["metrics"]["target_tension_curve"])
    st = r9.static_residual(spec)
    return {
        "coverage_permille": len(c["edges"] & c["dedup_total"]) * 1000 // len(c["dedup_total"]),
        "dedup_edges_total": len(c["dedup_total"]),
        "ending_entropy_milli_bits": entropy_milli_bits([dist[nm] for nm in order], total),
        "ending_distribution": dist,
        "tension_mae_milli": sum(abs(a - t) for a, t in zip(act, tgt)) * 1000 // len(act),
        "consistency_residual": (len(st["D"]) + sum(1 for x in st["K"] if x["mismatch"])
                                 + len(st["S"]) + len(st["R"])),
        "blocked": c["blocked"],
    }


def main() -> int:
    spec = load_json(os.path.join(ROOT, "config/story_spec.json"))
    cfg = load_json(CONFIG)
    base_rows = load_json(BASELINE)
    seqs = [r["choices"] for r in base_rows]
    n = len(seqs)

    # 基线:先确认同一批序列在原规格上重放能复现卷二 4.3 的摘要
    base = collect(spec, seqs)
    base_m = metrics(spec, seqs)
    reproduce = sum(1 for a, b in zip(base["digests"], [r["digest"] for r in base_rows]) if a == b)

    print(f"基线:{n} 条采样序列(out/ne_04_3_py.json,seed={cfg['baseline']['seed']})")
    print(f"  重放复现卷二摘要 {reproduce} / {n}   去重对话边 {base_m['dedup_edges_total']}")
    print(f"  覆盖率 {base_m['coverage_permille']}‰  结局熵 {base_m['ending_entropy_milli_bits']} 毫比特"
          f"  张力 MAE {base_m['tension_mae_milli']} 毫  一致性残差 {base_m['consistency_residual']}")
    print("  基线结局分布 " + " · ".join(f"{k} {v}" for k, v in base_m["ending_distribution"].items()))
    print()

    rows = []
    print(f"{'改动':<26}{'阻断':>6}{'摘要变':>7}{'状态差':>7}{'结局差':>7}{'边差':>6}{'节点差':>7}{'场景差':>7}{'节拍差':>7}")
    for mut in cfg["mutations"]:
        m_spec = apply_mutation(spec, mut)
        c = collect(m_spec, seqs)
        changed = sum(1 for a, b in zip(c["digests"], base["digests"]) if a != b)
        gained = (c["endings"] - {None}) - (base["endings"] - {None})
        lost = (base["endings"] - {None}) - (c["endings"] - {None})
        mm = metrics(m_spec, seqs)
        rows.append({"id": mut["id"], "note": mut["note"], "kind": mut["kind"],
                     "blocked": c["blocked"], "digest_changed": changed,
                     "state_symdiff": len(c["canonical"] ^ base["canonical"]),
                     "ending_symdiff_real": len(gained) + len(lost),
                     "ending_gained": sorted(gained), "ending_lost": sorted(lost),
                     "edge_symdiff": len(c["edges"] ^ base["edges"]),
                     "node_symdiff": len(c["nodes"] ^ base["nodes"]),
                     "scene_symdiff": len(c["scenes"] ^ base["scenes"]),
                     "beat_symdiff": len(c["beats"] ^ base["beats"]), "metrics": mm})
        r = rows[-1]
        print(f"{mut['id']:<26}{c['blocked']:>6}{changed:>7}{r['state_symdiff']:>7}"
              f"{r['ending_symdiff_real']:>7}{r['edge_symdiff']:>6}{r['node_symdiff']:>7}"
              f"{r['scene_symdiff']:>7}{r['beat_symdiff']:>7}")

    print()
    print(f"{'改动':<26}{'覆盖‰':>8}{'熵毫比特':>10}{'MAE毫':>8}{'残差':>6}")
    for r in rows:
        mm = r["metrics"]
        print(f"{r['id']:<26}{mm['coverage_permille']:>8}{mm['ending_entropy_milli_bits']:>10}"
              f"{mm['tension_mae_milli']:>8}{mm['consistency_residual']:>6}")
    print()
    for r in rows:
        print(f"结局分布 {r['id']:<26}"
              + " · ".join(f"{k} {v}" for k, v in r["metrics"]["ending_distribution"].items()))
    print()
    print(f"基线对照:覆盖率 {base_m['coverage_permille']}‰  结局熵 {base_m['ending_entropy_milli_bits']} 毫比特"
          f"  张力 MAE {base_m['tension_mae_milli']} 毫  残差 {base_m['consistency_residual']}")

    assert reproduce == n, "基线重放未能复现卷二摘要"
    for r in rows:
        assert 0 <= r["blocked"] <= n and 0 <= r["digest_changed"] <= n
        assert 0 <= r["metrics"]["coverage_permille"] <= 1000

    os.makedirs(OUT, exist_ok=True)
    with open(JSON_PATH, "w", encoding="utf-8") as fh:
        json.dump({"baseline": {"samples": n, "reproduce": reproduce, **base_m}, "mutations": rows},
                  fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    print("wrote out/nm_14_1_impact.json")
    print("OK nm_14_1_evolution_impact:五处小改动的影响面(集合差)已量出")
    return 0


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

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

基线:500 条采样序列(out/ne_04_3_py.json,seed=20261016)
  重放复现卷二摘要 500 / 500   去重对话边 56
  覆盖率 1000‰  结局熵 1747 毫比特  张力 MAE 0 毫  一致性残差 8
  基线结局分布 灯守 32 · 离港 250 · 真相 7 · 缄默 68 · 沉没 143

改动                            阻断    摘要变    状态差    结局差    边差    节点差    场景差    节拍差
M1_gate_dawn_fallback        127    127    254      0     0      0      0      0
M2_raise_lit_threshold        30     30     60      0     0      0      0      0
M3_add_shortcut_goto           0      0      0      0     0      0      0      0
M4_beat_tension_shift          0      0      0      0     0      0      0      0
M5_gate_lighthouse_entry     123    123    193      0     0      0      0      0

改动                             覆盖‰      熵毫比特    MAE毫    残差
M1_gate_dawn_fallback         1000      1669       0     8
M2_raise_lit_threshold        1000      1534       0     8
M3_add_shortcut_goto           982      1747       0     8
M4_beat_tension_shift         1000      1747      62     8
M5_gate_lighthouse_entry      1000      1749       0     7

结局分布 M1_gate_dawn_fallback     灯守 22 · 离港 206 · 真相 6 · 缄默 42 · 沉没 97
结局分布 M2_raise_lit_threshold    灯守 2 · 离港 250 · 真相 7 · 缄默 68 · 沉没 143
结局分布 M3_add_shortcut_goto      灯守 32 · 离港 250 · 真相 7 · 缄默 68 · 沉没 143
结局分布 M4_beat_tension_shift     灯守 32 · 离港 250 · 真相 7 · 缄默 68 · 沉没 143
结局分布 M5_gate_lighthouse_entry  灯守 15 · 离港 175 · 真相 5 · 缄默 68 · 沉没 114

基线对照:覆盖率 1000‰  结局熵 1747 毫比特  张力 MAE 0 毫  残差 8
wrote out/nm_14_1_impact.json
OK nm_14_1_evolution_impact:五处小改动的影响面(集合差)已量出

判读:影响面是四张集合差,不是一句「有没有影响」

第一,重放 500/500 复现了卷二摘要,基线是可信的。 同一批序列在原规格上重放,终局摘要与 out/ne_04_3_py.json 逐条相同——这说明「集合差」的两端用的是同一个推进语义,差出来的东西才全是改动造成的。

第二,M1 与 M5 是「阻断型」改动,M2 是「重路由型」。 M1 给破晓的无门回退加一个 flag 门,127 条序列在该步走不下去,终局状态集对称差 254(= 127 条失去原落点 + 127 条落在截断点)。M5 给灯塔入口加门,123 条被阻断、状态差 193。M2 只调一个阈值,30 条被阻断、状态差 60。三者的区别在改动落在路径树的什么位置:越靠近主干、越多人经过的选项,影响面越大。

第三,M3 与 M4 的影响面在路径层是空的,但指标层不是。 加一条新 goto(M3)和改一个节拍张力(M4),500 条基线的重放一条都没变——因为它们改的是「基线没走过的边」和「不影响走位的标注」。所以 M3/M4 的阻断、状态差、边差、节点差、场景差、节拍差全是 0。

第四,「结局差 = 0」不等于「结局没变」。 五处改动都没有让任何一个结局新增或消失(结局集对称差恒为 0),但 M1/M2/M5 的分布明显挪了:M1 的灯守 32→22、离港 250→206,熵 1747→1669;M2 的灯守 32→2、熵 1747→1534;M5 的熵反而 1747→1749(轻微上移)。集合差看的是「有没有」,分布看的是「多少」——两件事。

第五,M3 的覆盖率下降是分母造成的。 加一条没人走的新边后,去重边从 56 变 57,走过的边仍是 56,覆盖率 1000‰ → 982‰。行为一个字没变,指标却「变差」了——这是第 14.3 节「指标变了 ≠ 指标变坏了」的第一个实例。

自检表

检查项合格标准本节实测
基线可信500 条重放复现卷二摘要500 / 500
集合差口径终局状态 / 结局 / 边 / 节点 / 场景 / 节拍六类齐算
阻断检测选项不可用即截断计数M1 127 · M2 30 · M5 123
指标口径千分位 / 毫,无浮点覆盖率 ‰ · 熵 毫比特 · MAE 毫
整数不变量覆盖率 ∈ [0,1000]‰982–1000

影响面量出来了,下一步是把「改动前 / 改动后」做成一张能一眼看懂的回归对照表——覆盖率、结局熵、张力 MAE、一致性残差各一列,增量各一行。下一节就把这张表落成可复跑的产物。

阅读导航:上一节:13.3 反推的稳健性 · 下一节:14.2 路径与指标的回归 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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