站内游戏叙事设计概览 是概论级短文,讲叙事设计有哪些环节;本章把「叙事失调」写成一个可算的残差并给出告警阈值。概论说「角色的秘密不该被随便知道」「敌对派系不该被同时讨好」,但那是经验之谈;本节要做的,是把它变成真源里两组字段的对齐问题,再数出对不上的地方有几处。
9.1 一致性残差的度量
「叙事失调」最常见的写法是一句形容词:「这个角色前后不一致」。形容词不可度量。本节换一个问法:真源里有一部分字段是声明(作者说这个世界应该是怎样),另一部分是行为(规格实际允许玩家做什么)。一致性就是这两者对齐与否——把「声明该做的」和「行为实际做的」逐条比,数出失配计数,残差就是那个计数。
声明域与行为域
真源里两域分得很清,这是本节能动手的前提:
| 域 | 字段 | 例子 |
|---|---|---|
| 声明 | characters[].affinity | 砚、徽、沈、沐、林、孩各声明了 0–100 的好感度 |
| 声明 | characters[].secret | 「他是第十八个名字」等六条秘密 |
| 声明 | world.factions[].stance | 船帮对教会 -1(敌对),教会对船帮也 -1 |
| 声明 | world.rules[] | 「潮涨后不得进入灯塔区」等四条禁则 |
| 行为 | dialogue[].choices[].when | 选项的门:什么条件下这个选项才出现 |
| 行为 | dialogue[].choices[].effects | 选项的后果:置哪个旗标、加谁的好感、发哪件物品 |
声明是「世界应该是怎样」,行为是「规格允许怎样」。两者的差,就是残差要数的东西。
四类失配
本节把残差拆成四类,每类一个整数,都能在真源里直接数出来:
| 类 | 失配定义 | 为什么算失配 |
|---|---|---|
| D 声明域 | 角色声明了 affinity,却不在 state.vars | 声明域 > 状态域:声明了一个推进不了的好感度 |
| K 秘密 | 见证旗标存在无门提供者 | 秘密没有任何门保护,人人可拿 |
| S 立场 | 敌对派系两侧角色都有 add 提供者 | 声明的敌意在可行动作上没留下代价 |
| R 规则 | 进入禁区的选项无门 | 声明的禁则可被自由违反 |
每一类都靠一张读法表(哪个旗标是秘密的见证、哪个前缀是禁区)——读法表是本节自写的建模步骤,显式列出以便审阅;换一种读法结论会变。这正是「proxy」的含义:残差是计数,只做相对比较,它回答「这份规格里有多少处声明与许可对不上」,不回答「这份规格好不好」。
S 层:静态残差 + 路径实现
脚本先数静态残差(声明 vs 许可),再沿 2000 条采样路径量路径实现率——声明与许可对不上的那些地方,有多少真的被玩家走到。静态残差说「可能」,路径实现说「发生」。
#!/usr/bin/env python3
# file: sim/nm_09_1_residual_metric.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_09_1_residual_metric.py
"""卷三 9.1 一致性残差的度量(S 层)。
把「声明」与「行为」对齐后统计失配计数。声明来自真源结构字段(characters[].affinity /
secret、world.factions[].stance、world.rules[]);行为来自 dialogue[].choices[].when 与
effects——它们是**许可**,再沿 2000 条采样路径看哪些许可真的被走到。四类失配:
D 声明域失配:角色声明了 affinity,却不在 state.vars(声明域 > 状态域)。
K 秘密失配:见证旗标存在**无门**提供者 → 秘密可无条件触达。
S 立场失配:敌对派系两侧角色都有 add 提供者 → 路径级双向示好无代价。
R 规则失配:进入灯塔区的选项**无门** → 禁则可自由违反。
残差是**计数**,只做相对比较。与卷一 14.1/14.2 的分工:那里做形式化与静态对齐,本节做
**度量**——除静态残差外,还量「这些可能性有多少真的被走到」。只读真源,产出
out/nm_09_1_residual.json。
"""
from __future__ import annotations
import json
import os
import sys
AFF_HI, TRUST_GATE = 100, 85 # 读法表:affinity.gates.trust,6 角色一致
WITNESS = {"yan": "found_yan_journal", "hui": "hui_trusts", "shen": "shen_trusts",
"mu": "mu_trusts", "lin": "met_lin", "hai": "promised_hai"}
LIGHTHOUSE = ("d_lb_", "d_lt_") # rule_curfew 的禁区:灯塔基座 + 顶层
ENEMY = ("hui", "shen") # guild ↔ church:stance 互为 -1 的唯一一对
N, SEED, STEP_CAP = 2000, 20261016, 64
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")
def load_spec() -> dict:
with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def choices(spec: dict) -> list:
return [(n, c) for n in spec["dialogue"] for c in (n.get("choices") or [])]
def eff(c: dict) -> dict:
return c.get("effects") or {}
def sets(c: dict, flag: str) -> bool:
return (eff(c).get("set") or {}).get(flag) == 1
def cond_ok(c, s: dict) -> bool:
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 cond_ok(c[1], s) and cond_ok(c[2], s)
if k == "or":
return cond_ok(c[1], s) or cond_ok(c[2], s)
return not cond_ok(c[1], s)
def init_state(spec: dict) -> dict:
aff = {v: next(c for c in spec["characters"] if c["id"] == v)["affinity"]["init"]
for v in sorted(spec["state"]["vars"])}
return {"node": spec["meta"]["start"], "aff": aff,
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])}}
def apply_effects(effects: dict, s: dict) -> dict:
aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
for k, v in (effects.get("set") or {}).items():
flags[k] = v
for k, v in (effects.get("add") or {}).items():
aff[k] = min(AFF_HI, max(0, aff.get(k, 0) + v))
for k, v in (effects.get("grant") or {}).items():
items[k] = v
for k in (effects.get("revoke") or {}):
items[k] = 0
return {"node": None, "aff": aff, "flags": flags, "items": items}
def static_residual(spec: dict) -> dict:
declared = [c["id"] for c in spec["characters"] if "affinity" in c]
d_missing = [cid for cid in declared if cid not in set(spec["state"]["vars"])]
k_rows = []
for ch in spec["characters"]:
flag = WITNESS[ch["id"]]
prov = [c for _, c in choices(spec) if sets(c, flag)]
gated = [c for c in prov if c.get("when") is not None]
k_rows.append({"char": ch["id"], "witness": flag, "providers": len(prov),
"gated": len(gated), "ungated": len(prov) - len(gated),
"mismatch": len(prov) > 0 and len(prov) - len(gated) > 0})
fac = {c["id"]: c["faction"] for c in spec["characters"]}
adders: dict = {}
for _, c in choices(spec):
for t in (eff(c).get("add") or {}):
adders[t] = adders.get(t, 0) + 1
a, b = ENEMY
fa, fb = fac[a], fac[b]
s_pairs = ([{"a": a, "b": b, "fa": fa, "fb": fb, "providers_a": adders[a],
"providers_b": adders[b]}] if adders.get(a) and adders.get(b) else [])
r_hits = [f"{n['id']}/{c['id']}" for n, c in choices(spec)
if c["goto"].startswith(LIGHTHOUSE) and not n["id"].startswith(LIGHTHOUSE)
and c.get("when") is None]
return {"D": d_missing, "K": k_rows, "S": s_pairs, "R": r_hits}
def sample_paths(spec: dict, n: int, seed: int) -> dict:
"""MINSTD 可播种采样;三类路径级失配各计触发路径数(每路径至多一次)。"""
by = {nn["id"]: nn for nn in spec["dialogue"]}
init = init_state(spec)
rng, hit_chars, hit_stance, hit_curfew, secret_paths = seed, set(), 0, 0, 0
def rnd(mod: int) -> int:
nonlocal rng
rng = (rng * 48271) % 2147483647
return rng % mod
for _ in range(n):
node, s = spec["meta"]["start"], {k: (dict(v) if isinstance(v, dict) else v)
for k, v in init.items()}
entered, hit_here = False, False
for _step in range(STEP_CAP):
cur = by[node]
if cur.get("ending"):
break
opts = [c for c in (cur.get("choices") or []) if cond_ok(c.get("when"), s)]
if not opts:
break
c = opts[rnd(len(opts))]
if c["goto"].startswith(LIGHTHOUSE) and not node.startswith(LIGHTHOUSE) and c.get("when") is None:
entered = True
for cid, flag in WITNESS.items():
if sets(c, flag) and s["aff"].get(cid, 0) < TRUST_GATE:
hit_chars.add(cid)
hit_here = True
s = apply_effects(c.get("effects") or {}, s)
s["node"] = node = c["goto"]
hit_curfew += int(entered)
secret_paths += int(hit_here)
if s["aff"].get("hui", 0) > init["aff"]["hui"] and s["aff"].get("shen", 0) > init["aff"]["shen"]:
hit_stance += 1
return {"n": n, "secret_chars": sorted(hit_chars), "secret_paths": secret_paths,
"stance_paths": hit_stance, "curfew_paths": hit_curfew}
def main() -> int:
spec = load_spec()
st = static_residual(spec)
d, k = len(st["D"]), sum(1 for x in st["K"] if x["mismatch"])
s, r = len(st["S"]), len(st["R"])
total = d + k + s + r
path = sample_paths(spec, N, SEED)
print(f"spec={spec['meta']['title']} 角色 {len(spec['characters'])} 状态域好感度 {len(spec['state']['vars'])} 选项 {len(choices(spec))}")
print("静态一致性残差(声明 vs 许可)——proxy:失配计数,只做相对比较")
print(f" D 声明域失配 {d} {st['D']}(声明 affinity 却不在 state.vars)")
print(f" K 秘密失配 {k} " + " ".join(f"{x['char']}{'✗' if x['mismatch'] else '✓'}" for x in st["K"]))
print(f" S 立场失配 {s} " + " ".join(f"{x['fa']}/{x['a']}↔{x['fb']}/{x['b']}" for x in st["S"]))
print(f" R 规则失配 {r} {st['R']}(进入灯塔区的无门选项)")
print(f" 静态残差 = D + K + S + R = {d} + {k} + {s} + {r} = {total}")
print(f"路径实现(采样 {path['n']} 条,seed={SEED})——proxy:触发路径占比,只做相对比较")
print(f" 秘密低信任触达角色 {path['secret_chars']} 触发路径 {path['secret_paths']}"
f" ({path['secret_paths'] * 1000 // path['n']}‰)")
print(f" 立场双向示好路径 {path['stance_paths']} ({path['stance_paths'] * 1000 // path['n']}‰)")
print(f" 禁区无门进入路径 {path['curfew_paths']} ({path['curfew_paths'] * 1000 // path['n']}‰)")
assert all(0 <= v <= AFF_HI for v in init_state(spec)["aff"].values()), "好感度初值越界"
assert all(0 <= path[key] <= path["n"] for key in ("secret_paths", "stance_paths", "curfew_paths")), "路径计数越界"
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_09_1_residual.json"), "w", encoding="utf-8") as fh:
json.dump({"static": {"D": st["D"], "K": st["K"], "S": st["S"], "R": st["R"]},
"static_total": total, "path": path, "seed": SEED}, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_09_1_residual.json")
print(f"OK nm_09_1_residual_metric:静态残差 {total},路径实现率已量出")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_09_1_residual_metric.py:
spec=雾港夜航 角色 6 状态域好感度 4 选项 115
静态一致性残差(声明 vs 许可)——proxy:失配计数,只做相对比较
D 声明域失配 2 ['lin', 'hai'](声明 affinity 却不在 state.vars)
K 秘密失配 3 yan✗ hui✓ shen✓ mu✓ lin✗ hai✗
S 立场失配 1 guild/hui↔church/shen
R 规则失配 2 ['d_hub_a2/c_hub_a2a', 'd_hub_b2/c_hub_b2a'](进入灯塔区的无门选项)
静态残差 = D + K + S + R = 2 + 3 + 1 + 2 = 8
路径实现(采样 2000 条,seed=20261016)——proxy:触发路径占比,只做相对比较
秘密低信任触达角色 ['hai', 'hui', 'lin', 'mu', 'shen', 'yan'] 触发路径 1816 (908‰)
立场双向示好路径 670 (335‰)
禁区无门进入路径 1505 (752‰)
wrote out/nm_09_1_residual.json
OK nm_09_1_residual_metric:静态残差 8,路径实现率已量出
判读:静态残差 8,路径实现率高得刺眼
第一,四类失配各自可读。 D = 2 是 lin 与 hai——两个角色在真源里声明了 affinity,却不在 state.vars 里,也从无任何选项 add 过它们。这是「声明域 > 状态域」的直接证据:声明了一个推进不了的好感度。K = 3 是 yan / lin / hai——它们的见证旗标存在无门提供者(✗ 标记),而 hui / shen / mu 的秘密都有门保护(✓)。S = 1 是唯一一对互为 -1 的派系(船帮 hui ↔ 教会 shen),两侧都有 add 提供者。R = 2 是两条进入灯塔区却无门的选项。
第二,静态残差 = 2 + 3 + 1 + 2 = 8。 这八处就是「声明该做的」与「行为实际做的」对不上的全部位置。注意它不是缺陷清单——它是一份待审清单:每一处都要人来判断「是漏了门,还是这条声明本就该放宽」。
第三,路径实现率高得刺眼。 六个角色的秘密全都能在低信任(好感 < 85)时被触达,908‰ 的采样路径触发了至少一次;752‰ 的路径无门进入了禁区;335‰ 的路径同时讨好了敌对双方。声明与许可对不上的地方,几乎每一条路径都在踩。 这就是残差必须成为回归信号的理由——如果规格一改、这些数字默默变了却没人知道,声明与行为会越走越远。
| 量 | 值 | 读法 |
|---|---|---|
| 静态残差 D+K+S+R | 8 | 待审清单长度,不是缺陷数 |
| 秘密低信任触达路径 | 908‰ | 六个秘密全部无信任门槛可拿 |
| 立场双向示好路径 | 335‰ | 三成路径同时加了敌对双方好感 |
| 禁区无门进入路径 | 752‰ | 四分之三路径踩过禁则 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 两域分清 | 声明(affinity/secret/stance/rules)与行为(when/effects)分列 |
| 读法显式 | 见证旗标表、禁区前缀、敌对对都写在脚本顶部可审阅 |
| 整数口径 | 残差与实现率全是整数,无浮点 |
| proxy 声明 | 残差是计数、实现率是占比,只做相对比较 |
| 不变量 | 好感度 ∈ [0,100]、路径计数 ≤ 采样数(脚本 assert) |
残差量出来了,但一个静态的数不会自己变成信号。下一节把「规格改一处、残差动多少」做成一张回归表——它决定这个数能不能在演化中当哨兵用。
阅读导航:上一节:8.3 关系曲线的形状分类 · 下一节:9.2 设定-行为对齐的回归 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。