站内 《游戏设计》卷一《玩法机制与可执行规格》 做的是玩法规格的消费与演化对机制指标的影响;本章做的是叙事规格演化对路径与曲线的影响。度量对象不同——那边看一局战斗的动力学(回合数、资源、行动密度),这边看整张故事图的路径分布(覆盖率、结局分布、张力曲线)。规格都是整数化的真源,但一边量的是「这一局打了几个回合」,另一边量的是「这张图被走成了什么形状」。
改一处规格,影响面有多大?这一节把「影响面」定义成一个可算的集合差,然后对五处典型的小改动各算一次。全部数字本机实跑,全部指标都是 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 路径与指标的回归 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。