14.2 设定与行为的对齐检验

设定说「教会不承认灯塔之光」,规格里却可能允许玩家请执事去点灯。本节逐条检验 4 条 rule 与 6 条 lore 是否被行为兑现:lore 的 reveal_flag 是否真被选项置位并被条件读取,rule 是否有兑现它的门。S 层脚本输出对齐表,如实报告对不上的项——本案例 6 条 lore 全对齐,rule_curfew 未兑现。

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)
证据可定位缺口列出具体选项 idc_hub_a2a / c_hub_b2a
不替人裁定只报缺口与两条修法,不自动改规格未改 story_spec.json

对齐缺口 A = 1 会并入 14.3 的残差。把一致性写成 R + S + K + A 之后,下一节要看的是:这个残差在参数扰动下会怎么动——是阶跃,还是连续。

阅读导航:上一节:14.1 叙事失调的形式化 · 下一节:14.3 一致性的残差与告警 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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