14.2 设定与行为的对齐检验
独立游戏的叙事设计与分支对话
里「设定要落到玩法上」是一句劝告。本节把它变成一张对齐表:把 world.rules 的 4 条规则与 world.lore 的 6 条设定逐条拿出来,问一句「规格里有没有行为兑现它」。这是代理指标,只做相对比较——对齐条数是「有多少条被兑现」,不是「设定写得好不好」。
两个方向:提供与读取
lore 的对齐要看两头:lore[].reveal_flag 是否真被某个选项置位(提供者),以及该 flag 是否被某处条件读取(被读取)。只满足前者是「揭示了却没人用」,只满足后者是「引用了却揭不了」——两头都满足才算对齐。
rule 没有 reveal_flag,它的对齐要看是否存在兑现它的门。每条规则映射成一个判据(读法表):禁足区是否所有入口都有门、鸣钟是否都有门、作保信物是否有带门的消费者、教会侧是否对主张灯塔之光者扣好感。
四条规则的读法
规则是自然语言,必须先映射成可判定的读法才能检。这四条读法逐条写在脚本里:
rule_curfew(潮涨后不得进入灯塔区):进入灯塔区的选项是否都有门。rule_bell(沉钟非大难不得鸣):鸣钟(set bell_rung)的选项是否都有门。rule_guild(船渡需人作保):作保信物rope_knot是否有带门的消费者。rule_church(教会不承认灯塔之光):教会侧是否对主张灯塔之光者扣好感。
这张读法表与 14.1 的规则失配表同源、问法不同:14.1 问「有没有一个动作绕过禁则」,本节问「有没有一道门兑现禁则」。同一份规格,两个问法会给出互补的答案——所以两节必须一起看。
对齐表
#!/usr/bin/env python3
# file: sim/na_14_2_alignment.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_14_2_alignment.py
"""14.2 设定与行为的对齐检验(S 层)。
「设定说了」和「规格允许」是两回事。本节逐条检验 4 条 rule 与 6 条 lore 是否被**行为**
兑现:
- lore:`lore[].reveal_flag` 是否真的被某个选项置位(有提供者),并且该 flag 是否
被某处条件**读取**(否则「揭示了却没人用」,是空揭示)。
- rule:声明了一条约束,规格里是否存在一个**兑现它的门**(读法表给出「兑现」的判据)。
对齐表如实报告**对不上的项**,不粉饰。判定与 14.1 共用读法表口径(`is_gated` 可替换),
所以 14.3 的扰动可以在此之上重算缺口 A。
"""
from __future__ import annotations
import json
import os
import sys
import na_14_1_dissonance as d1
def cond_refs(c, out: set) -> None:
"""收集条件里引用的 (kind, name)。"""
if not c:
return
k = c[0]
if k in ("flag", "aff", "item"):
out.add((k, c[1]))
return
if k in ("and", "or"):
cond_refs(c[1], out)
cond_refs(c[2], out)
return
if k == "not":
cond_refs(c[1], out)
return
raise ValueError(f"unknown cond op: {k}")
def all_refs(spec: dict) -> set:
"""规格里所有被**读取**的名字(line_when / choice.when / 场景边 / 地点 / 结局)。"""
refs: set = set()
for n in spec["dialogue"]:
cond_refs(n.get("line_when"), refs)
for c in (n.get("choices") or []):
cond_refs(c.get("when"), refs)
for e in spec["scene_graph"]["edges"]:
cond_refs(e.get("when"), refs)
for loc in spec["world"]["locations"]:
cond_refs(loc.get("unlock_when"), refs)
for ed in spec["endings"]:
cond_refs(ed.get("when"), refs)
return refs
def lore_alignment(spec: dict, refs: set) -> list:
out = []
for L in spec["world"]["lore"]:
flag = L["reveal_flag"]
prov = [f"{n['id']}/{c['id']}" for n, c in d1.choices(spec) if d1.sets(c, flag)]
used = ("flag", flag) in refs
out.append({"lore": L["id"], "reveal_flag": flag, "providers": len(prov),
"used": used, "aligned": len(prov) > 0 and used, "sample": prov[:3]})
return out
def rule_enactment(spec: dict, is_gated=d1.gated) -> list:
"""读法表:每条规则 -> 一个「是否被行为兑现」的判据。
- curfew:**所有**进入灯塔区的选项都必须有门(否则禁区可自由进出)。
- bell:**所有**鸣钟(set bell_rung)的选项都必须有门。
- guild:`rope_knot`(作保信物)必须至少有一个带门的消费者。
- church:必须存在一个教会侧选项,在玩家主张灯塔之光时**扣**沈的好感。
"""
def all_gated(pred):
hit = [f"{n['id']}/{c['id']}" for n, c in d1.choices(spec) if pred(n, c)]
return hit, all(is_gated(c) for n, c in d1.choices(spec) if pred(n, c))
curfew_hit, curfew_ok = all_gated(
lambda n, c: c["goto"].startswith(d1.LIGHTHOUSE_NODES)
and not n["id"].startswith(d1.LIGHTHOUSE_NODES))
bell_hit, bell_ok = all_gated(lambda n, c: d1.sets(c, "bell_rung"))
consumers = [f"{n['id']}/{c['id']}" for n, c in d1.choices(spec)
if "rope_knot" in json.dumps(c.get("when"), ensure_ascii=False)]
church_hit = [f"{n['id']}/{c['id']}" for n, c in d1.choices(spec)
if n.get("speaker") == "shen"
and (d1.eff(c).get("add") or {}).get("shen", 0) < 0
and any(t in json.dumps(c.get("when"), ensure_ascii=False)
for t in ("knows_yan_missing", "saw_lighthouse_light"))]
return [
{"rule": "rule_curfew", "enacted": curfew_ok, "evidence": curfew_hit,
"reading": "进入灯塔区的选项是否都有门"},
{"rule": "rule_bell", "enacted": bell_ok, "evidence": bell_hit,
"reading": "鸣钟选项是否都有门"},
{"rule": "rule_guild", "enacted": bool(consumers), "evidence": consumers,
"reading": "作保信物是否有带门的消费者"},
{"rule": "rule_church", "enacted": bool(church_hit), "evidence": church_hit,
"reading": "教会侧是否对主张灯塔之光者扣好感"},
]
def alignment(spec: dict, is_gated=d1.gated) -> dict:
refs = all_refs(spec)
return {"lore": lore_alignment(spec, refs), "rules": rule_enactment(spec, is_gated)}
def gaps(spec: dict, is_gated=d1.gated) -> int:
a = alignment(spec, is_gated)
return (sum(1 for x in a["lore"] if not x["aligned"])
+ sum(1 for x in a["rules"] if not x["enacted"]))
def main() -> int:
spec = d1.load_spec()
a = alignment(spec)
print(f"spec={spec['meta']['title']} lore {len(a['lore'])} 规则 {len(a['rules'])}")
print("对齐表(如实报告对不上的项)")
print("\n6 条 lore:reveal_flag 是否被行为兑现")
print(f" {'lore':<11}{'reveal_flag':<22}{'提供者':>6}{'被读取':>7} 对齐")
for x in a["lore"]:
print(f" {x['lore']:<11}{x['reveal_flag']:<22}{x['providers']:>6}"
f"{'是' if x['used'] else '否':>7} {'✓' if x['aligned'] else '✗'} {x['sample']}")
print("\n4 条规则:是否存在兑现它的门")
for x in a["rules"]:
print(f" {x['rule']:<14}{'兑现' if x['enacted'] else '未兑现':<8}"
f"{x['reading']:<26} 证据 {x['evidence']}")
lore_bad = [x["lore"] for x in a["lore"] if not x["aligned"]]
rule_bad = [x["rule"] for x in a["rules"] if not x["enacted"]]
g = len(lore_bad) + len(rule_bad)
print(f"\n对齐缺口 A = {g}(lore 未对齐 {len(lore_bad)}:{lore_bad};规则未兑现 {len(rule_bad)}:{rule_bad})")
here = os.path.dirname(os.path.abspath(__file__))
out = os.path.join(here, "../out/na14_alignment.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
json.dump({"lore": a["lore"], "rules": a["rules"], "gaps": g},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/na14_alignment.json")
print(f"OK na_14_2_alignment:对齐表已出,缺口 {g}")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/na_14_2_alignment.py:
spec=雾港夜航 lore 6 规则 4
对齐表(如实报告对不上的项)
6 条 lore:reveal_flag 是否被行为兑现
lore reveal_flag 提供者 被读取 对齐
lore_tide knows_tide_rule 3 是 ✓ ['d_pier_2/c_pier_2a', 'd_lb_1/c_lb_1c', 'd_ho_1/c_ho_1b']
lore_bell knows_bell_legend 5 是 ✓ ['d_pier_2/c_pier_2b', 'd_chapel_1/c_chapel_1b', 'd_mr_2/c_mr_2a']
lore_yan knows_yan_missing 1 是 ✓ ['d_pier_1/c_pier_1a']
lore_light saw_lighthouse_light 7 是 ✓ ['d_hub_a2/c_hub_a2a', 'd_hub_a2/c_hub_a2b', 'd_lb_1/c_lb_1a']
lore_ledgerfound_yan_journal 5 是 ✓ ['d_tc_1/c_tc_1c', 'd_tc_2/c_tc_2c', 'd_tc_3/c_tc_3a']
lore_fog cave_explored 2 是 ✓ ['d_tc_1/c_tc_1a', 'd_tc_1/c_tc_1b']
4 条规则:是否存在兑现它的门
rule_curfew 未兑现 进入灯塔区的选项是否都有门 证据 ['d_hub_a2/c_hub_a2a', 'd_hub_b2/c_hub_b2a']
rule_bell 兑现 鸣钟选项是否都有门 证据 ['d_fg_2/c_fg_2c', 'd_dq_1/c_dq_1b']
rule_guild 兑现 作保信物是否有带门的消费者 证据 ['d_ch_1/c_ch_1c', 'd_mr_2/c_mr_2c']
rule_church 兑现 教会侧是否对主张灯塔之光者扣好感 证据 ['d_chapel_1/c_chapel_1c']
对齐缺口 A = 1(lore 未对齐 0:[];规则未兑现 1:['rule_curfew'])
wrote out/na14_alignment.json
OK na_14_2_alignment:对齐表已出,缺口 1
6 条 lore 全部对齐:每条 reveal_flag 都既有提供者、又被条件读取。最少的是 lore_yan(knows_yan_missing 只有 1 个提供者 c_pier_1a),最多的是 lore_light(saw_lighthouse_light 有 7 个提供者)——提供者多寡不影响「对齐」与否,只影响它有多容易被碰上。
唯一未兑现的一条
4 条规则里 3 条兑现,rule_curfew 未兑现。它的证据是 d_hub_a2/c_hub_a2a 与 d_hub_b2/c_hub_b2a:这两个选项进入灯塔区却没有任何门。规格里灯塔区不是没有门(lighthouse_base 的 enter_when 要求 saw_lighthouse_light),但那是「见过光」的门,不是禁足的门——规则说的「潮涨后不可进入」在行为上没有任何对应。这就是对齐缺口 A = 1。
如实报告:这条缺口不是脚本判错,而是规格确实没把这条规则兑现。修法有两条——给这两个入口加门(例如要求 knows_tide_rule),或者承认这条规则只是氛围文本、从 rules 里降级。修不修由设计者定,脚本只负责把它指出来。
自检表
| 检查项 | 合格标准 | 本案例 |
|---|---|---|
| lore 双向判定 | 提供者与「被读取」都查 | 6/6 对齐 |
| rule 有判据 | 每条规则映射成一个可判的门判据 | 4 条各一条读法 |
| 缺口如实报 | 对不上就列出来,不粉饰 | A = 1(rule_curfew) |
| 证据可定位 | 缺口列出具体选项 id | c_hub_a2a / c_hub_b2a |
| 不替人裁定 | 只报缺口与两条修法,不自动改规格 | 未改 story_spec.json |
对齐缺口 A = 1 会并入 14.3 的残差。把一致性写成 R + S + K + A 之后,下一节要看的是:这个残差在参数扰动下会怎么动——是阶跃,还是连续。
阅读导航:上一节:14.1 叙事失调的形式化 · 下一节:14.3 一致性的残差与告警 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。