9.1 一致性残差的度量

把「声明」与「行为」对齐后统计失配计数:声明来自角色的 affinity / secret、派系 stance 与规则,行为来自选项的 when / effects 与采样路径。本节把一致性残差量成四个整数——声明域 D、秘密 K、立场 S、规则 R,并沿 2000 条路径量路径实现率,全部显式声明为 proxy。

站内游戏叙事设计概览 是概论级短文,讲叙事设计有哪些环节;本章把「叙事失调」写成一个可算的残差并给出告警阈值。概论说「角色的秘密不该被随便知道」「敌对派系不该被同时讨好」,但那是经验之谈;本节要做的,是把它变成真源里两组字段的对齐问题,再数出对不上的地方有几处。

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+R8待审清单长度,不是缺陷数
秘密低信任触达路径908‰六个秘密全部无信任门槛可拿
立场双向示好路径335‰三成路径同时加了敌对双方好感
禁区无门进入路径752‰四分之三路径踩过禁则

自检表

检查项合格标准
两域分清声明(affinity/secret/stance/rules)与行为(when/effects)分列
读法显式见证旗标表、禁区前缀、敌对对都写在脚本顶部可审阅
整数口径残差与实现率全是整数,无浮点
proxy 声明残差是计数、实现率是占比,只做相对比较
不变量好感度 ∈ [0,100]、路径计数 ≤ 采样数(脚本 assert)

残差量出来了,但一个静态的数不会自己变成信号。下一节把「规格改一处、残差动多少」做成一张回归表——它决定这个数能不能在演化中当哨兵用。

阅读导航:上一节:8.3 关系曲线的形状分类 · 下一节:9.2 设定-行为对齐的回归 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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